From eff830bdcb8f979da34fb9e8f782efb1598b4a44 Mon Sep 17 00:00:00 2001 From: Josh Bialkowski Date: Thu, 7 Nov 2019 08:01:08 -0800 Subject: Implement jira bridge --- bridge/jira/client.go | 1453 +++++++++++++++++++++++++++++++++++++++++++++++++ bridge/jira/config.go | 239 ++++++++ bridge/jira/export.go | 453 +++++++++++++++ bridge/jira/import.go | 598 ++++++++++++++++++++ bridge/jira/jira.go | 87 +++ 5 files changed, 2830 insertions(+) create mode 100644 bridge/jira/client.go create mode 100644 bridge/jira/config.go create mode 100644 bridge/jira/export.go create mode 100644 bridge/jira/import.go create mode 100644 bridge/jira/jira.go (limited to 'bridge/jira') diff --git a/bridge/jira/client.go b/bridge/jira/client.go new file mode 100644 index 00000000..bfc30039 --- /dev/null +++ b/bridge/jira/client.go @@ -0,0 +1,1453 @@ +package jira + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/cookiejar" + "net/url" + "strconv" + "strings" + "time" + + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/bug" + "github.com/pkg/errors" +) + +var errDone = errors.New("Iteration Done") +var errTransitionNotFound = errors.New("Transition not found") +var errTransitionNotAllowed = errors.New("Transition not allowed") + +// ============================================================================= +// Extended JSON +// ============================================================================= + +const TimeFormat = "2006-01-02T15:04:05.999999999Z0700" + +// ParseTime parse an RFC3339 string with nanoseconds +func ParseTime(timeStr string) (time.Time, error) { + out, err := time.Parse(time.RFC3339Nano, timeStr) + if err != nil { + out, err = time.Parse(TimeFormat, timeStr) + } + return out, err +} + +// MyTime is just a time.Time with a JSON serialization +type MyTime 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) { + str := string(data) + + // Get rid of the quotes "" around the value. + // A second option would be to include them in the date format string + // instead, like so below: + // time.Parse(`"`+time.RFC3339Nano+`"`, s) + str = str[1 : len(str)-1] + + timeObj, err := ParseTime(str) + self.Time = timeObj + return +} + +// ============================================================================= +// JSON Objects +// ============================================================================= + +// Session credential cookie name/value pair received after logging in and +// required to be sent on all subsequent requests +type Session struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// SessionResponse the JSON object returned from a /session query (login) +type SessionResponse struct { + Session Session `json:"session"` +} + +// SessionQuery the JSON object that is POSTed to the /session endpoint +// in order to login and get a session cookie +type SessionQuery struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// User the JSON object representing a JIRA user +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/user +type User struct { + DisplayName string `json:"displayName"` + EmailAddress string `json:"emailAddress"` + Key string `json:"key"` + Name string `json:"name"` +} + +// Comment the JSON object for a single comment item returned in a list of +// comments +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getComments +type Comment struct { + ID string `json:"id"` + Body string `json:"body"` + Author User `json:"author"` + UpdateAuthor User `json:"updateAuthor"` + Created MyTime `json:"created"` + Updated MyTime `json:"updated"` +} + +// CommentPage the JSON object holding a single page of comments returned +// either by direct query or within an issue query +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getComments +type CommentPage struct { + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` + Comments []Comment `json:"comments"` +} + +// NextStartAt return the index of the first item on the next page +func (self *CommentPage) NextStartAt() int { + return self.StartAt + len(self.Comments) +} + +// IsLastPage return true if there are no more items beyond this page +func (self *CommentPage) IsLastPage() bool { + return self.NextStartAt() >= self.Total +} + +// IssueFields the JSON object returned as the "fields" member of an issue. +// There are a very large number of fields and many of them are custom. We +// only grab a few that we need. +// 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"` + Description string `json:"description"` + Summary string `json:"summary"` + Comments CommentPage `json:"comment"` + Labels []string `json:"labels"` +} + +// ChangeLogItem "field-change" data within a changelog entry. A single +// changelog entry might effect multiple fields. For example, closing an issue +// generally requires a change in "status" and "resolution" +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue +type ChangeLogItem struct { + Field string `json:"field"` + FieldType string `json:"fieldtype"` + From string `json:"from"` + FromString string `json:"fromString"` + To string `json:"to"` + ToString string `json:"toString"` +} + +// ChangeLogEntry One entry in a changelog +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue +type ChangeLogEntry struct { + ID string `json:"id"` + Author User `json:"author"` + Created MyTime `json:"created"` + Items []ChangeLogItem `json:"items"` +} + +// ChangeLogPage A collection of changes to issue metadata +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue +type ChangeLogPage struct { + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` + Entries []ChangeLogEntry `json:"histories"` +} + +// NextStartAt return the index of the first item on the next page +func (self *ChangeLogPage) NextStartAt() int { + return self.StartAt + len(self.Entries) +} + +// IsLastPage return true if there are no more items beyond this page +func (self *ChangeLogPage) IsLastPage() bool { + return self.NextStartAt() >= self.Total +} + +// Issue Top-level object for an issue +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue +type Issue struct { + ID string `json:"id"` + Key string `json:"key"` + Fields IssueFields `json:"fields"` + ChangeLog ChangeLogPage `json:"changelog"` +} + +// SearchResult The result type from querying the search endpoint +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/search +type SearchResult struct { + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` + Issues []Issue `json:"issues"` +} + +// NextStartAt return the index of the first item on the next page +func (self *SearchResult) NextStartAt() int { + return self.StartAt + len(self.Issues) +} + +// IsLastPage return true if there are no more items beyond this page +func (self *SearchResult) IsLastPage() bool { + return self.NextStartAt() >= self.Total +} + +// SearchRequest the JSON object POSTed to the /search endpoint +type SearchRequest struct { + JQL string `json:"jql"` + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Fields []string `json:"fields"` +} + +// Project the JSON object representing a project. Note that we don't use all +// the fields so we have only implemented a couple. +type Project struct { + ID string `json:"id,omitempty"` + Key string `json:"key,omitempty"` +} + +// IssueType the JSON object representing an issue type (i.e. "bug", "task") +// Note that we don't use all the fields so we have only implemented a couple. +type IssueType struct { + ID string `json:"id"` +} + +// IssueCreateFields fields that are included in an IssueCreate request +type IssueCreateFields struct { + Project Project `json:"project"` + Summary string `json:"summary"` + Description string `json:"description"` + IssueType IssueType `json:"issuetype"` +} + +// IssueCreate the JSON object that is POSTed to the /issue endpoint to create +// a new issue +type IssueCreate struct { + Fields IssueCreateFields `json:"fields"` +} + +// IssueCreateResult the JSON object returned after issue creation. +type IssueCreateResult struct { + ID string `json:"id"` + Key string `json:"key"` +} + +// CommentCreate the JSOn object that is POSTed to the /comment endpoint to +// create a new comment +type CommentCreate struct { + Body string `json:"body"` +} + +// StatusCategory the JSON object representing a status category +type StatusCategory struct { + ID int `json:"id"` + Key string `json:"key"` + Self string `json:"self"` + ColorName string `json:"colorName"` + Name string `json:"name"` +} + +// Status the JSON object representing a status (i.e. "Open", "Closed") +type Status struct { + ID string `json:"id"` + Name string `json:"name"` + Self string `json:"self"` + Description string `json:"description"` + StatusCategory StatusCategory `json:"statusCategory"` +} + +// Transition the JSON object represenging a transition from one Status to +// another Status in a JIRA workflow +type Transition struct { + ID string `json:"id"` + Name string `json:"name"` + To Status `json:"to"` +} + +// TransitionList the JSON object returned from the /transitions endpoint +type TransitionList struct { + Transitions []Transition `json:"transitions"` +} + +// ServerInfo general server information returned by the /serverInfo endpoint. +// Notably `ServerTime` will tell you the time on the server. +type ServerInfo struct { + BaseURL string `json:"baseUrl"` + Version string `json:"version"` + VersionNumbers []int `json:"versionNumbers"` + BuildNumber int `json:"buildNumber"` + BuildDate MyTime `json:"buildDate"` + ServerTime MyTime `json:"serverTime"` + ScmInfo string `json:"scmInfo"` + BuildPartnerName string `json:"buildPartnerName"` + ServerTitle string `json:"serverTitle"` +} + +// ============================================================================= +// REST Client +// ============================================================================= + +// ClientTransport wraps http.RoundTripper by adding a +// "Content-Type=application/json" header +type ClientTransport struct { + underlyingTransport http.RoundTripper +} + +// RoundTrip overrides the default by adding the content-type header +func (self *ClientTransport) RoundTrip( + req *http.Request) (*http.Response, error) { + req.Header.Add("Content-Type", "application/json") + return self.underlyingTransport.RoundTrip(req) +} + +// Client Thin wrapper around the http.Client providing jira-specific methods +// for APIendpoints +type Client struct { + *http.Client + serverURL string + ctx *context.Context +} + +// 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 { + cookiJar, _ := cookiejar.New(nil) + client := &http.Client{ + Transport: &ClientTransport{underlyingTransport: http.DefaultTransport}, + Jar: cookiJar, + } + + 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 { + if conf[keyCredentialsFile] != "" { + content, err := ioutil.ReadFile(conf[keyCredentialsFile]) + if err != nil { + return err + } + return client.RefreshTokenRaw(content) + } + + 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 = PromptPassword() + if err != nil { + return err + } + } + + return client.RefreshToken(username, password) +} + +// RefreshToken formulate the JSON request object from the user credentials +// and POST it to the /session endpoing and get a session cookie +func (client *Client) RefreshToken(username, password string) error { + params := SessionQuery{ + Username: username, + Password: password, + } + + data, err := json.Marshal(params) + if err != nil { + return err + } + + return client.RefreshTokenRaw(data) +} + +// RefreshTokenRaw POST credentials to the /session endpoing and get a session +// cookie +func (client *Client) RefreshTokenRaw(credentialsJSON []byte) error { + postURL := fmt.Sprintf("%s/rest/auth/1/session", client.serverURL) + + req, err := http.NewRequest("POST", postURL, bytes.NewBuffer(credentialsJSON)) + if err != nil { + return err + } + + urlobj, err := url.Parse(client.serverURL) + if err != nil { + fmt.Printf("Failed to parse %s\n", client.serverURL) + } else { + // Clear out cookies + client.Jar.SetCookies(urlobj, []*http.Cookie{}) + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + defer cancel() + req = req.WithContext(ctx) + } + + response, err := client.Do(req) + if err != nil { + return err + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + content, _ := ioutil.ReadAll(response.Body) + return fmt.Errorf( + "error creating token %v: %s", response.StatusCode, content) + } + + data, _ := ioutil.ReadAll(response.Body) + var aux SessionResponse + err = json.Unmarshal(data, &aux) + if err != nil { + return err + } + + var cookies []*http.Cookie + cookie := &http.Cookie{ + Name: aux.Session.Name, + Value: aux.Session.Value, + } + cookies = append(cookies, cookie) + client.Jar.SetCookies(urlobj, cookies) + + return nil +} + +// ============================================================================= +// Endpoint Wrappers +// ============================================================================= + +// Search Perform an issue a JQL search on the /search endpoint +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/search +func (client *Client) Search(jql string, maxResults int, startAt int) ( + *SearchResult, error) { + url := fmt.Sprintf("%s/rest/api/2/search", client.serverURL) + + requestBody, err := json.Marshal(SearchRequest{ + JQL: jql, + StartAt: startAt, + MaxResults: maxResults, + Fields: []string{ + "comment", + "created", + "creator", + "description", + "labels", + "status", + "summary"}}) + if err != nil { + return nil, err + } + + request, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody)) + if err != nil { + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s, %s", response.StatusCode, + url, requestBody) + return nil, err + } + + var message SearchResult + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &message) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &message, nil +} + +// SearchIterator cursor within paginated results from the /search endpoint +type SearchIterator struct { + client *Client + jql string + searchResult *SearchResult + Err error + + pageSize int + itemIdx int +} + +// HasError returns true if the iterator is holding an error +func (self *SearchIterator) HasError() bool { + if self.Err == errDone { + return false + } + if self.Err == nil { + return false + } + return true +} + +// HasNext returns true if there is another item available in the result set +func (self *SearchIterator) HasNext() bool { + return self.Err == nil && self.itemIdx < len(self.searchResult.Issues) +} + +// Next Return the next item in the result set and advance the iterator. +// Advancing the iterator may require fetching a new page. +func (self *SearchIterator) Next() *Issue { + if self.Err != nil { + return nil + } + + issue := self.searchResult.Issues[self.itemIdx] + if self.itemIdx+1 < len(self.searchResult.Issues) { + // We still have an item left in the currently cached page + self.itemIdx++ + } else { + if self.searchResult.IsLastPage() { + self.Err = errDone + } else { + // There are still more pages to fetch, so fetch the next page and + // cache it + self.searchResult, self.Err = self.client.Search( + self.jql, self.pageSize, self.searchResult.NextStartAt()) + // NOTE(josh): we don't deal with the error now, we just cache it. + // HasNext() will return false and the caller can check the error + // afterward. + self.itemIdx = 0 + } + } + return &issue +} + +// IterSearch return an iterator over paginated results for a JQL search +func (client *Client) IterSearch( + jql string, pageSize int) *SearchIterator { + result, err := client.Search(jql, pageSize, 0) + + iter := &SearchIterator{ + client: client, + jql: jql, + searchResult: result, + Err: err, + pageSize: pageSize, + itemIdx: 0, + } + + return iter +} + +// GetIssue fetches an issue object via the /issue/{IssueIdOrKey} endpoint +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue +func (client *Client) GetIssue( + idOrKey string, fields []string, expand []string, + properties []string) (*Issue, error) { + url := fmt.Sprintf("%s/rest/api/2/issue/%s", client.serverURL, idOrKey) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + err := fmt.Errorf("Creating request %v", err) + return nil, err + } + + query := request.URL.Query() + if len(fields) > 0 { + query.Add("fields", strings.Join(fields, ",")) + } + if len(expand) > 0 { + query.Add("expand", strings.Join(expand, ",")) + } + if len(properties) > 0 { + query.Add("properties", strings.Join(properties, ",")) + } + request.URL.RawQuery = query.Encode() + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String()) + return nil, err + } + + var issue Issue + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &issue) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &issue, nil +} + +// GetComments returns a page of comments via the issue/{IssueIdOrKey}/comment +// endpoint +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getComment +func (client *Client) GetComments( + idOrKey string, maxResults int, startAt int) (*CommentPage, error) { + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/comment", client.serverURL, idOrKey) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + err := fmt.Errorf("Creating request %v", err) + return nil, err + } + + query := request.URL.Query() + if maxResults > 0 { + query.Add("maxResults", fmt.Sprintf("%d", maxResults)) + } + if startAt > 0 { + query.Add("startAt", fmt.Sprintf("%d", startAt)) + } + request.URL.RawQuery = query.Encode() + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String()) + return nil, err + } + + var comments CommentPage + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &comments) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &comments, nil +} + +// CommentIterator cursor within paginated results from the /comment endpoint +type CommentIterator struct { + client *Client + idOrKey string + message *CommentPage + Err error + + pageSize int + itemIdx int +} + +// HasError returns true if the iterator is holding an error +func (self *CommentIterator) HasError() bool { + if self.Err == errDone { + return false + } + if self.Err == nil { + return false + } + return true +} + +// HasNext returns true if there is another item available in the result set +func (self *CommentIterator) HasNext() bool { + return self.Err == nil && self.itemIdx < len(self.message.Comments) +} + +// Next Return the next item in the result set and advance the iterator. +// Advancing the iterator may require fetching a new page. +func (self *CommentIterator) Next() *Comment { + if self.Err != nil { + return nil + } + + comment := self.message.Comments[self.itemIdx] + if self.itemIdx+1 < len(self.message.Comments) { + // We still have an item left in the currently cached page + self.itemIdx++ + } else { + if self.message.IsLastPage() { + self.Err = errDone + } else { + // There are still more pages to fetch, so fetch the next page and + // cache it + self.message, self.Err = self.client.GetComments( + self.idOrKey, self.pageSize, self.message.NextStartAt()) + // NOTE(josh): we don't deal with the error now, we just cache it. + // HasNext() will return false and the caller can check the error + // afterward. + self.itemIdx = 0 + } + } + return &comment +} + +// IterComments returns an iterator over paginated comments within an issue +func (client *Client) IterComments( + idOrKey string, pageSize int) *CommentIterator { + message, err := client.GetComments(idOrKey, pageSize, 0) + + iter := &CommentIterator{ + client: client, + idOrKey: idOrKey, + message: message, + Err: err, + pageSize: pageSize, + itemIdx: 0, + } + + return iter +} + +// GetChangeLog fetchs 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) +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue +func (client *Client) GetChangeLog( + idOrKey string, maxResults int, startAt int) (*ChangeLogPage, error) { + url := fmt.Sprintf("%s/rest/api/2/issue/%s/changelog", client.serverURL, idOrKey) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + err := fmt.Errorf("Creating request %v", err) + return nil, err + } + + query := request.URL.Query() + if maxResults > 0 { + query.Add("maxResults", fmt.Sprintf("%d", maxResults)) + } + if startAt > 0 { + query.Add("startAt", fmt.Sprintf("%d", startAt)) + } + request.URL.RawQuery = query.Encode() + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode == http.StatusNotFound { + // The issue/{IssueIdOrKey}/changelog endpoint is only available on JIRA cloud + // products, not on JIRA server. In order to get the information we have to + // query the issue and ask for a changelog expansion. Unfortunately this means + // that the changelog is not paginated and we have to fetch the entire thing + // at once. Hopefully things don't break for very long changelogs. + issue, err := client.GetIssue( + idOrKey, []string{"*none"}, []string{"changelog"}, []string{}) + if err != nil { + return nil, err + } + + return &issue.ChangeLog, nil + } + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String()) + return nil, err + } + + var changelog ChangeLogPage + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &changelog) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &changelog, nil +} + +// ChangeLogIterator cursor within paginated results from the /search endpoint +type ChangeLogIterator struct { + client *Client + idOrKey string + message *ChangeLogPage + Err error + + pageSize int + itemIdx int +} + +// HasError returns true if the iterator is holding an error +func (self *ChangeLogIterator) HasError() bool { + if self.Err == errDone { + return false + } + if self.Err == nil { + return false + } + return true +} + +// HasNext returns true if there is another item available in the result set +func (self *ChangeLogIterator) HasNext() bool { + return self.Err == nil && self.itemIdx < len(self.message.Entries) +} + +// Next Return the next item in the result set and advance the iterator. +// Advancing the iterator may require fetching a new page. +func (self *ChangeLogIterator) Next() *ChangeLogEntry { + if self.Err != nil { + return nil + } + + item := self.message.Entries[self.itemIdx] + if self.itemIdx+1 < len(self.message.Entries) { + // We still have an item left in the currently cached page + self.itemIdx++ + } else { + if self.message.IsLastPage() { + self.Err = errDone + } else { + // There are still more pages to fetch, so fetch the next page and + // cache it + self.message, self.Err = self.client.GetChangeLog( + self.idOrKey, self.pageSize, self.message.NextStartAt()) + // NOTE(josh): we don't deal with the error now, we just cache it. + // HasNext() will return false and the caller can check the error + // afterward. + self.itemIdx = 0 + } + } + return &item +} + +// IterChangeLog returns an iterator over entries in the changelog for an issue +func (client *Client) IterChangeLog( + idOrKey string, pageSize int) *ChangeLogIterator { + message, err := client.GetChangeLog(idOrKey, pageSize, 0) + + iter := &ChangeLogIterator{ + client: client, + idOrKey: idOrKey, + message: message, + Err: err, + pageSize: pageSize, + itemIdx: 0, + } + + return iter +} + +// GetProject returns the project JSON object given its id or key +func (client *Client) GetProject(projectIDOrKey string) (*Project, error) { + url := fmt.Sprintf( + "%s/rest/api/2/project/%s", client.serverURL, projectIDOrKey) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + return nil, err + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, url) + return nil, err + } + + var project Project + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &project) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &project, nil +} + +// CreateIssue creates a new JIRA issue and returns it +func (client *Client) CreateIssue( + projectIDOrKey, title, body string, extra map[string]interface{}) ( + *IssueCreateResult, error) { + + url := fmt.Sprintf("%s/rest/api/2/issue", client.serverURL) + + fields := make(map[string]interface{}) + fields["summary"] = title + fields["description"] = body + for key, value := range extra { + fields[key] = value + } + + // If the project string is an integer than assume it is an ID. Otherwise it + // is a key. + _, err := strconv.Atoi(projectIDOrKey) + if err == nil { + fields["project"] = map[string]string{"id": projectIDOrKey} + } else { + fields["project"] = map[string]string{"key": projectIDOrKey} + } + + message := make(map[string]interface{}) + message["fields"] = fields + + data, err := json.Marshal(message) + if err != nil { + return nil, err + } + + request, err := http.NewRequest("POST", url, bytes.NewBuffer(data)) + if err != nil { + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusCreated { + content, _ := ioutil.ReadAll(response.Body) + err := fmt.Errorf( + "HTTP response %d, query was %s\n data: %s\n response: %s", + response.StatusCode, request.URL.String(), data, content) + return nil, err + } + + var result IssueCreateResult + + data, _ = ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &result) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &result, nil +} + +// UpdateIssueTitle changes the "summary" field of a JIRA issue +func (client *Client) UpdateIssueTitle( + issueKeyOrID, title string) (time.Time, error) { + + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s", client.serverURL, issueKeyOrID) + var responseTime time.Time + + // NOTE(josh): Since updates are a list of heterogeneous objects let's just + // manually build the JSON text + data, err := json.Marshal(title) + if err != nil { + return responseTime, err + } + + var buffer bytes.Buffer + fmt.Fprintf(&buffer, `{"update":{"summary":[`) + fmt.Fprintf(&buffer, `{"set":%s}`, data) + fmt.Fprintf(&buffer, `]}}`) + + data = buffer.Bytes() + request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data)) + if err != nil { + return responseTime, err + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return responseTime, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + content, _ := ioutil.ReadAll(response.Body) + err := fmt.Errorf( + "HTTP response %d, query was %s\n data: %s\n response: %s", + response.StatusCode, request.URL.String(), data, content) + return responseTime, err + } + + dateHeader, ok := response.Header["Date"] + if !ok || len(dateHeader) != 1 { + // No "Date" header, or empty, or multiple of them. Regardless, we don't + // have a date we can return + return responseTime, nil + } + + responseTime, err = http.ParseTime(dateHeader[0]) + if err != nil { + return time.Time{}, err + } + + return responseTime, nil +} + +// UpdateIssueBody changes the "description" field of a JIRA issue +func (client *Client) UpdateIssueBody( + issueKeyOrID, body string) (time.Time, error) { + + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s", client.serverURL, issueKeyOrID) + var responseTime time.Time + // NOTE(josh): Since updates are a list of heterogeneous objects let's just + // manually build the JSON text + data, err := json.Marshal(body) + if err != nil { + return responseTime, err + } + + var buffer bytes.Buffer + fmt.Fprintf(&buffer, `{"update":{"description":[`) + fmt.Fprintf(&buffer, `{"set":%s}`, data) + fmt.Fprintf(&buffer, `]}}`) + + data = buffer.Bytes() + request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data)) + if err != nil { + return responseTime, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return responseTime, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + content, _ := ioutil.ReadAll(response.Body) + err := fmt.Errorf( + "HTTP response %d, query was %s\n data: %s\n response: %s", + response.StatusCode, request.URL.String(), data, content) + return responseTime, err + } + + dateHeader, ok := response.Header["Date"] + if !ok || len(dateHeader) != 1 { + // No "Date" header, or empty, or multiple of them. Regardless, we don't + // have a date we can return + return responseTime, nil + } + + responseTime, err = http.ParseTime(dateHeader[0]) + if err != nil { + return time.Time{}, err + } + + return responseTime, nil +} + +// AddComment adds a new comment to an issue (and returns it). +func (client *Client) AddComment(issueKeyOrID, body string) (*Comment, error) { + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/comment", client.serverURL, issueKeyOrID) + + params := CommentCreate{Body: body} + data, err := json.Marshal(params) + if err != nil { + return nil, err + } + + request, err := http.NewRequest("POST", url, bytes.NewBuffer(data)) + if err != nil { + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusCreated { + content, _ := ioutil.ReadAll(response.Body) + err := fmt.Errorf( + "HTTP response %d, query was %s\n data: %s\n response: %s", + response.StatusCode, request.URL.String(), data, content) + return nil, err + } + + var result Comment + + data, _ = ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &result) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &result, nil +} + +// UpdateComment changes the text of a comment +func (client *Client) UpdateComment(issueKeyOrID, commentID, body string) ( + *Comment, error) { + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/comment/%s", client.serverURL, issueKeyOrID, + commentID) + + params := CommentCreate{Body: body} + data, err := json.Marshal(params) + if err != nil { + return nil, err + } + + request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data)) + if err != nil { + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String()) + return nil, err + } + + var result Comment + + data, _ = ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &result) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &result, nil +} + +// UpdateLabels changes labels for an issue +func (client *Client) UpdateLabels( + issueKeyOrID string, added, removed []bug.Label) (time.Time, error) { + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/", client.serverURL, issueKeyOrID) + var responseTime time.Time + + // NOTE(josh): Since updates are a list of heterogeneous objects let's just + // manually build the JSON text + var buffer bytes.Buffer + fmt.Fprintf(&buffer, `{"update":{"labels":[`) + first := true + for _, label := range added { + if !first { + fmt.Fprintf(&buffer, ",") + } + fmt.Fprintf(&buffer, `{"add":"%s"}`, label) + first = false + } + for _, label := range removed { + if !first { + fmt.Fprintf(&buffer, ",") + } + fmt.Fprintf(&buffer, `{"remove":"%s"}`, label) + first = false + } + fmt.Fprintf(&buffer, "]}}") + + data := buffer.Bytes() + request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data)) + if err != nil { + return responseTime, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return responseTime, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + content, _ := ioutil.ReadAll(response.Body) + err := fmt.Errorf( + "HTTP response %d, query was %s\n data: %s\n response: %s", + response.StatusCode, request.URL.String(), data, content) + return responseTime, err + } + + dateHeader, ok := response.Header["Date"] + if !ok || len(dateHeader) != 1 { + // No "Date" header, or empty, or multiple of them. Regardless, we don't + // have a date we can return + return responseTime, nil + } + + responseTime, err = http.ParseTime(dateHeader[0]) + if err != nil { + return time.Time{}, err + } + + return responseTime, nil +} + +// GetTransitions returns a list of available transitions for an issue +func (client *Client) GetTransitions(issueKeyOrID string) ( + *TransitionList, error) { + + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/transitions", client.serverURL, issueKeyOrID) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + err := fmt.Errorf("Creating request %v", err) + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String()) + return nil, err + } + + var message TransitionList + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &message) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &message, nil +} + +func getTransitionTo( + tlist *TransitionList, desiredStateNameOrID string) *Transition { + for _, transition := range tlist.Transitions { + if transition.To.ID == desiredStateNameOrID { + return &transition + } else if transition.To.Name == desiredStateNameOrID { + return &transition + } + } + return nil +} + +// DoTransition changes the "status" of an issue +func (client *Client) DoTransition( + issueKeyOrID string, transitionID string) (time.Time, error) { + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/transitions", client.serverURL, issueKeyOrID) + var responseTime time.Time + + // TODO(josh)[767ee72]: Figure out a good way to "configure" the + // open/close state mapping. It would be *great* if we could actually + // *compute* the necessary transitions and prompt for missing metatdata... + // but that is complex + var buffer bytes.Buffer + fmt.Fprintf(&buffer, + `{"transition":{"id":"%s"}, "resolution": {"name": "Done"}}`, + transitionID) + request, err := http.NewRequest("POST", url, bytes.NewBuffer(buffer.Bytes())) + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return responseTime, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String()) + return responseTime, err + } + + dateHeader, ok := response.Header["Date"] + if !ok || len(dateHeader) != 1 { + // No "Date" header, or empty, or multiple of them. Regardless, we don't + // have a date we can return + return responseTime, nil + } + + responseTime, err = http.ParseTime(dateHeader[0]) + if err != nil { + return time.Time{}, err + } + + return responseTime, nil +} + +// GetServerInfo Fetch server information from the /serverinfo endpoint +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue +func (client *Client) GetServerInfo() (*ServerInfo, error) { + url := fmt.Sprintf("%s/rest/api/2/serverinfo", client.serverURL) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + err := fmt.Errorf("Creating request %v", err) + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String()) + return nil, err + } + + var message ServerInfo + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &message) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &message, nil +} + +// GetServerTime returns the current time on the server +func (client *Client) GetServerTime() (MyTime, error) { + var result MyTime + info, err := client.GetServerInfo() + if err != nil { + return result, err + } + return info.ServerTime, nil +} diff --git a/bridge/jira/config.go b/bridge/jira/config.go new file mode 100644 index 00000000..e33b8f28 --- /dev/null +++ b/bridge/jira/config.go @@ -0,0 +1,239 @@ +package jira + +import ( + "bufio" + "encoding/json" + "fmt" + "io/ioutil" + "os" + "strconv" + "strings" + "syscall" + "time" + + "github.com/pkg/errors" + "golang.org/x/crypto/ssh/terminal" + + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/repository" + "github.com/MichaelMure/git-bug/util/interrupt" +) + +const ( + target = "jira" + keyServer = "server" + keyProject = "project" + keyCredentialsFile = "credentials-file" + keyUsername = "username" + keyPassword = "password" + keyMapOpenID = "bug-open-id" + keyMapCloseID = "bug-closed-id" + keyCreateDefaults = "create-issue-defaults" + keyCreateGitBug = "create-issue-gitbug-id" + + defaultTimeout = 60 * time.Second +) + +// Configure sets up the bridge configuration +func (g *Jira) Configure( + repo repository.RepoCommon, params core.BridgeParams) ( + core.Configuration, error) { + conf := make(core.Configuration) + var err error + var url string + var project string + var credentialsFile string + var username string + var password string + var serverURL string + + 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 != "" { + return nil, fmt.Errorf("owner doesn't make sense for jira") + } + + serverURL = params.URL + if url == "" { + // terminal prompt + serverURL, err = prompt("JIRA server URL", "URL") + if err != nil { + return nil, err + } + } + + project = params.Project + if project == "" { + project, err = prompt("JIRA project key", "project") + if err != nil { + return nil, err + } + } + + choice, err := promptCredentialOptions(serverURL) + if err != nil { + return nil, err + } + + if choice == 1 { + credentialsFile, err = prompt("Credentials file path", "path") + if err != nil { + return nil, err + } + } + + username, err = prompt("JIRA username", "username") + if err != nil { + return nil, err + } + + password, err = PromptPassword() + if err != nil { + return nil, err + } + + jsonData, err := json.Marshal( + &SessionQuery{Username: username, Password: password}) + if err != nil { + return nil, err + } + + fmt.Printf("Attempting to login with credentials...\n") + client := NewClient(serverURL, nil) + err = client.RefreshTokenRaw(jsonData) + + // verify access to the project with credentials + _, err = client.GetProject(project) + if err != nil { + return nil, fmt.Errorf( + "Project %s doesn't exist on %s, or authentication credentials for (%s)"+ + " are invalid", + project, serverURL, username) + } + + conf[core.KeyTarget] = target + conf[keyServer] = serverURL + conf[keyProject] = project + if choice == 1 { + conf[keyCredentialsFile] = credentialsFile + err = ioutil.WriteFile(credentialsFile, jsonData, 0644) + if err != nil { + return nil, errors.Wrap( + err, fmt.Sprintf("Unable to write credentials to %s", credentialsFile)) + } + } else if choice == 2 { + conf[keyUsername] = username + conf[keyPassword] = password + } else if choice == 3 { + conf[keyUsername] = username + } + err = g.ValidateConfig(conf) + if err != nil { + return nil, err + } + + return conf, nil +} + +// ValidateConfig returns true if all required keys are present +func (*Jira) ValidateConfig(conf core.Configuration) error { + if v, ok := conf[core.KeyTarget]; !ok { + return fmt.Errorf("missing %s key", core.KeyTarget) + } else if v != target { + return fmt.Errorf("unexpected target name: %v", v) + } + + if _, ok := conf[keyProject]; !ok { + return fmt.Errorf("missing %s key", keyProject) + } + + return nil +} + +const credentialsText = ` +How would you like to store your JIRA login credentials? +[1]: 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. +[2]: git-config: Your credentials will be stored in the git config. Note that + it will contain your JIRA password in clear text. +[3]: 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. +` + +func promptCredentialOptions(serverURL string) (int, error) { + fmt.Print(credentialsText) + for { + fmt.Print("Select option: ") + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + fmt.Println() + if err != nil { + return -1, err + } + + line = strings.TrimRight(line, "\n") + + index, err := strconv.Atoi(line) + if err != nil || (index != 1 && index != 2 && index != 3) { + fmt.Println("invalid input") + continue + } + + return index, nil + } +} + +func prompt(description, name string) (string, error) { + for { + fmt.Printf("%s: ", description) + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return "", err + } + + line = strings.TrimRight(line, "\n") + if line == "" { + fmt.Printf("%s is empty\n", name) + continue + } + + return line, nil + } +} + +// PromptPassword performs interactive input collection to get the user password +func PromptPassword() (string, error) { + termState, err := terminal.GetState(int(syscall.Stdin)) + if err != nil { + return "", err + } + + cancel := interrupt.RegisterCleaner(func() error { + return terminal.Restore(int(syscall.Stdin), termState) + }) + defer cancel() + + for { + fmt.Print("password: ") + bytePassword, err := terminal.ReadPassword(int(syscall.Stdin)) + // new line for coherent formatting, ReadPassword clip the normal new line + // entered by the user + fmt.Println() + + if err != nil { + return "", err + } + + if len(bytePassword) > 0 { + return string(bytePassword), nil + } + + fmt.Println("password is empty") + } +} diff --git a/bridge/jira/export.go b/bridge/jira/export.go new file mode 100644 index 00000000..6472b212 --- /dev/null +++ b/bridge/jira/export.go @@ -0,0 +1,453 @@ +package jira + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/pkg/errors" + + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/bug" + "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/entity" +) + +var errDuplicateMatch = errors.New("Ambiguous match") + +// jiraExporter implement the Exporter interface +type jiraExporter struct { + conf core.Configuration + + // cache identities clients + identityClient map[entity.Id]*Client + + // cache identifiers used to speed up exporting operations + // cleared for each bug + cachedOperationIDs map[entity.Id]string + + // cache labels used to speed up exporting labels events + cachedLabels map[string]string + + // store JIRA project information + project *Project +} + +// Init . +func (self *jiraExporter) Init(conf core.Configuration) error { + self.conf = conf + //TODO: initialize with multiple tokens + self.identityClient = make(map[entity.Id]*Client) + self.cachedOperationIDs = make(map[entity.Id]string) + self.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 (self *jiraExporter) getIdentityClient( + ctx *context.Context, id entity.Id) (*Client, error) { + client, ok := self.identityClient[id] + if ok { + return client, nil + } + + // TODO(josh)[]: The github exporter appears to contain code that will + // allow it to export bugs owned by other people as long as we have a token + // for that identity. I guess the equivalent for us will be as long as we + // have a credentials pair for that identity. + return nil, fmt.Errorf("Not implemented") +} + +// ExportAll export all event made by the current user to Jira +func (self *jiraExporter) ExportAll( + ctx context.Context, repo *cache.RepoCache, since time.Time) ( + <-chan core.ExportResult, error) { + + out := make(chan core.ExportResult) + + user, err := repo.GetUserIdentity() + if err != nil { + return nil, err + } + + // TODO(josh)[]: The github exporter appears to contain code that will + // allow it to export bugs owned by other people as long as we have a token + // for that identity. I guess the equivalent for us will be as long as we + // have a credentials pair for that identity. + client := NewClient(self.conf[keyServer], &ctx) + err = client.Login(self.conf) + self.identityClient[user.Id()] = client + + if err != nil { + return nil, err + } + + client, err = self.getIdentityClient(&ctx, user.Id()) + if err != nil { + return nil, err + } + + self.project, err = client.GetProject(self.conf[keyProject]) + if err != nil { + return nil, err + } + + go func() { + defer close(out) + + var allIdentitiesIds []entity.Id + for id := range self.identityClient { + allIdentitiesIds = append(allIdentitiesIds, id) + } + + allBugsIds := repo.AllBugsIds() + + for _, id := range allBugsIds { + b, err := repo.ResolveBug(id) + if err != nil { + out <- core.NewExportError(errors.Wrap(err, "can't load bug"), id) + return + } + + select { + + case <-ctx.Done(): + // stop iterating if context cancel function is called + return + + default: + snapshot := b.Snapshot() + + // ignore issues whose last modification date is before the query date + // TODO: compare the Lamport time instead of using the unix time + if snapshot.CreatedAt.Before(since) { + out <- core.NewExportNothing(b.Id(), "bug created before the since date") + continue + } + + if snapshot.HasAnyActor(allIdentitiesIds...) { + // try to export the bug and it associated events + self.exportBug(ctx, b, since, out) + } else { + out <- core.NewExportNothing(id, "not an actor") + } + } + } + }() + + return out, nil +} + +// exportBug publish bugs and related events +func (self *jiraExporter) exportBug( + ctx context.Context, b *cache.BugCache, since time.Time, + out chan<- core.ExportResult) { + snapshot := b.Snapshot() + + var bugJiraID string + + // Special case: + // if a user try to export a bug that is not already exported to jira (or + // imported from jira) and we do not have the token of the bug author, + // there is nothing we can do. + + // first operation is always createOp + createOp := snapshot.Operations[0].(*bug.CreateOperation) + author := snapshot.Author + + // skip bug if it was imported from some other bug system + origin, ok := snapshot.GetCreateMetadata(keyOrigin) + if ok && origin != target { + out <- core.NewExportNothing( + b.Id(), fmt.Sprintf("issue tagged with origin: %s", origin)) + return + } + + // skip bug if it is a jira bug but is associated with another project + // (one bridge per JIRA project) + project, ok := snapshot.GetCreateMetadata(keyJiraProject) + if ok && !stringInSlice(project, []string{self.project.ID, self.project.Key}) { + out <- core.NewExportNothing( + b.Id(), fmt.Sprintf("issue tagged with project: %s", project)) + return + } + + // get jira bug ID + jiraID, ok := snapshot.GetCreateMetadata(keyJiraID) + if ok { + out <- core.NewExportNothing(b.Id(), "bug creation already exported") + // 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 := self.getIdentityClient(&ctx, 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", + author.Id().String())) + return + } + + // Load any custom fields required to create an issue from the git + // config file. + fields := make(map[string]interface{}) + defaultFields, hasConf := self.conf[keyCreateDefaults] + if hasConf { + json.Unmarshal([]byte(defaultFields), &fields) + } else { + // If there is no configuration provided, at the very least the + // "issueType" field is always required. 10001 is "story" which I'm + // pretty sure is standard/default on all JIRA instances. + fields["issueType"] = "10001" + } + bugIDField, hasConf := self.conf[keyCreateGitBug] + if hasConf { + // If the git configuration also indicates it, we can assign the git-bug + // id to a custom field to assist in integrations + fields[bugIDField] = b.Id().String() + } + + // create bug + result, err := client.CreateIssue( + self.project.ID, createOp.Title, createOp.Message, fields) + if err != nil { + err := errors.Wrap(err, "exporting jira issue") + out <- core.NewExportError(err, b.Id()) + return + } + + id := result.ID + out <- core.NewExportBug(b.Id()) + // mark bug creation operation as exported + err = markOperationAsExported( + b, createOp.Id(), id, self.project.Key, time.Time{}) + if err != nil { + err := errors.Wrap(err, "marking operation as exported") + out <- core.NewExportError(err, b.Id()) + return + } + + // commit operation to avoid creating multiple issues with multiple pushes + err = b.CommitAsNeeded() + if err != nil { + err := errors.Wrap(err, "bug commit") + out <- core.NewExportError(err, b.Id()) + return + } + + // cache bug jira ID + bugJiraID = id + } + + // cache operation jira id + self.cachedOperationIDs[createOp.Id()] = bugJiraID + + // lookup the mapping from git-bug "status" to JIRA "status" id + statusMap := getStatusMap(self.conf) + + for _, op := range snapshot.Operations[1:] { + // ignore SetMetadata operations + if _, ok := op.(*bug.SetMetadataOperation); ok { + continue + } + + // 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 { + self.cachedOperationIDs[op.Id()] = id + out <- core.NewExportNothing(op.Id(), "already exported operation") + continue + } + + opAuthor := op.GetAuthor() + client, err := self.getIdentityClient(&ctx, opAuthor.Id()) + if err != nil { + out <- core.NewExportNothing( + op.Id(), fmt.Sprintf( + "missing operation author credentials for user %.8s", + author.Id().String())) + continue + } + + var id string + var exportTime time.Time + switch op.(type) { + case *bug.AddCommentOperation: + opr := op.(*bug.AddCommentOperation) + comment, err := client.AddComment(bugJiraID, opr.Message) + if err != nil { + err := errors.Wrap(err, "adding comment") + out <- core.NewExportError(err, b.Id()) + return + } + id = comment.ID + out <- core.NewExportComment(op.Id()) + + // cache comment id + self.cachedOperationIDs[op.Id()] = id + + case *bug.EditCommentOperation: + opr := op.(*bug.EditCommentOperation) + if opr.Target == createOp.Id() { + // An EditCommentOpreation with the Target set to the create operation + // encodes a modification to the long-description/summary. + exportTime, err = client.UpdateIssueBody(bugJiraID, opr.Message) + if err != nil { + err := errors.Wrap(err, "editing issue") + out <- core.NewExportError(err, b.Id()) + return + } + out <- core.NewExportCommentEdition(op.Id()) + id = bugJiraID + } else { + // Otherwise it's an edit to an actual comment. A comment cannot be + // edited before it was created, so it must be the case that we have + // already observed and cached the AddCommentOperation. + commentID, ok := self.cachedOperationIDs[opr.Target] + if !ok { + // Since an edit has to come after the creation, we expect we would + // have cached the creation id. + panic("unexpected error: comment id not found") + } + comment, err := client.UpdateComment(bugJiraID, commentID, opr.Message) + if err != nil { + err := errors.Wrap(err, "editing comment") + out <- core.NewExportError(err, b.Id()) + return + } + out <- core.NewExportCommentEdition(op.Id()) + // JIRA doesn't track all comment edits, they will only tell us about + // the most recent one. We must invent a consistent id for the operation + // so we use the comment ID plus the timestamp of the update, as + // reported by JIRA. Note that this must be consistent with the importer + // during ensureComment() + id = fmt.Sprintf("%s-%d", comment.ID, comment.Updated.Unix()) + } + + case *bug.SetStatusOperation: + opr := op.(*bug.SetStatusOperation) + jiraStatus, hasStatus := statusMap[opr.Status.String()] + if hasStatus { + exportTime, err = UpdateIssueStatus(client, bugJiraID, jiraStatus) + if err != nil { + err := errors.Wrap(err, "editing status") + out <- core.NewExportError(err, b.Id()) + // Failure to update status isn't necessarily a big error. It's + // possible that we just don't have enough information to make that + // update. In this case, just don't export the operation. + continue + } + out <- core.NewExportStatusChange(op.Id()) + // TODO(josh)[c2c6767]: query changelog to get the changelog-id so that + // we don't re-import the same change. + id = bugJiraID + } else { + out <- core.NewExportNothing( + op.Id(), fmt.Sprintf( + "No jira status mapped for %.8s", opr.Status.String())) + } + + case *bug.SetTitleOperation: + opr := op.(*bug.SetTitleOperation) + exportTime, err = client.UpdateIssueTitle(bugJiraID, opr.Title) + if err != nil { + err := errors.Wrap(err, "editing title") + out <- core.NewExportError(err, b.Id()) + return + } + out <- core.NewExportTitleEdition(op.Id()) + // TODO(josh)[c2c6767]: query changelog to get the changelog-id so that + // we don't re-import the same change. + id = bugJiraID + + case *bug.LabelChangeOperation: + opr := op.(*bug.LabelChangeOperation) + exportTime, err = client.UpdateLabels( + bugJiraID, opr.Added, opr.Removed) + if err != nil { + err := errors.Wrap(err, "updating labels") + out <- core.NewExportError(err, b.Id()) + return + } + out <- core.NewExportLabelChange(op.Id()) + // TODO(josh)[c2c6767]: query changelog to get the changelog-id so that + // we don't re-import the same change. + id = bugJiraID + + default: + panic("unhandled operation type case") + } + + // mark operation as exported + // TODO(josh)[c2c6767]: Should we query the changelog after we export? + // Some of the operations above don't record an ID... so we are bound to + // re-import them. It shouldn't cause too much of an issue but we will have + // duplicate edit entries for everything and it would be nice to avoid that. + err = markOperationAsExported( + b, op.Id(), id, self.project.Key, exportTime) + if err != nil { + err := errors.Wrap(err, "marking operation as exported") + out <- core.NewExportError(err, b.Id()) + return + } + + // commit at each operation export to avoid exporting same events multiple + // times + err = b.CommitAsNeeded() + if err != nil { + err := errors.Wrap(err, "bug commit") + out <- core.NewExportError(err, b.Id()) + return + } + } +} + +func markOperationAsExported( + b *cache.BugCache, target entity.Id, jiraID, jiraProject string, + exportTime time.Time) error { + + newMetadata := map[string]string{ + keyJiraID: jiraID, + keyJiraProject: jiraProject, + } + if !exportTime.IsZero() { + newMetadata[keyJiraExportTime] = exportTime.Format(http.TimeFormat) + } + + _, err := b.SetMetadata(target, newMetadata) + return err +} + +// UpdateIssueStatus attempts to change the "status" field by finding a +// transition which achieves the desired state and then performing that +// transition +func UpdateIssueStatus( + client *Client, issueKeyOrID string, desiredStateNameOrID string) ( + time.Time, error) { + + var responseTime time.Time + + tlist, err := client.GetTransitions(issueKeyOrID) + if err != nil { + return responseTime, err + } + + transition := getTransitionTo(tlist, desiredStateNameOrID) + if transition == nil { + return responseTime, errTransitionNotFound + } + + responseTime, err = client.DoTransition(issueKeyOrID, transition.ID) + if err != nil { + return responseTime, err + } + + return responseTime, nil +} diff --git a/bridge/jira/import.go b/bridge/jira/import.go new file mode 100644 index 00000000..6f8aea1d --- /dev/null +++ b/bridge/jira/import.go @@ -0,0 +1,598 @@ +package jira + +import ( + "context" + "fmt" + "net/http" + "sort" + "strings" + "time" + + "github.com/MichaelMure/git-bug/bridge/core" + "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 ( + keyOrigin = "origin" + keyJiraID = "jira-id" + keyJiraOperationID = "jira-derived-id" + keyJiraKey = "jira-key" + keyJiraUser = "jira-user" + keyJiraProject = "jira-project" + keyJiraExportTime = "jira-export-time" + defaultPageSize = 10 +) + +// jiraImporter implement the Importer interface +type jiraImporter struct { + conf core.Configuration + + // send only channel + out chan<- core.ImportResult +} + +// Init . +func (gi *jiraImporter) Init(conf core.Configuration) error { + gi.conf = conf + return nil +} + +// ImportAll iterate over all the configured repository issues and ensure the +// creation of the missing issues / timeline items / edits / label events ... +func (self *jiraImporter) ImportAll( + ctx context.Context, repo *cache.RepoCache, since time.Time) ( + <-chan core.ImportResult, error) { + + sinceStr := since.Format("2006-01-02 15:04") + serverURL := self.conf[keyServer] + project := self.conf[keyProject] + // TODO(josh)[da52062]: Validate token and if it is expired then prompt for + // credentials and generate a new one + out := make(chan core.ImportResult) + self.out = out + + go func() { + defer close(self.out) + + client := NewClient(serverURL, &ctx) + err := client.Login(self.conf) + if err != nil { + out <- core.NewImportError(err, "") + return + } + + message, err := 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 = + client.IterSearch(jql, defaultPageSize); searchIter.HasNext(); { + issue := searchIter.Next() + bug, err := self.ensureIssue(repo, *issue) + if err != nil { + err := fmt.Errorf("issue creation: %v", err) + out <- core.NewImportError(err, "") + return + } + + var commentIter *CommentIterator + for commentIter = + client.IterComments(issue.ID, defaultPageSize); commentIter.HasNext(); { + comment := commentIter.Next() + self.ensureComment(repo, bug, *comment) + } + if commentIter.HasError() { + out <- core.NewImportError(commentIter.Err, "") + } + + snapshot := bug.Snapshot() + opIdx := 0 + + var changelogIter *ChangeLogIterator + for changelogIter = + 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( + keyJiraExportTime) + if !hasTime { + continue + } + exportTime, err = http.ParseTime(exportTimeStr) + if err != nil { + continue + } + if !exportTime.Before(changelogEntry.Created.Time) { + break + } + } + if opIdx < len(snapshot.Operations) { + self.ensureChange( + repo, bug, *changelogEntry, snapshot.Operations[opIdx]) + } else { + self.ensureChange(repo, bug, *changelogEntry, nil) + } + + } + if changelogIter.HasError() { + out <- core.NewImportError(changelogIter.Err, "") + } + + if err := bug.CommitAsNeeded(); 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 (self *jiraImporter) ensurePerson( + repo *cache.RepoCache, user User) (*cache.IdentityCache, error) { + + // Look first in the cache + i, err := repo.ResolveIdentityImmutableMetadata( + keyJiraUser, 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{ + keyJiraUser: string(user.Key), + }, + ) + + if err != nil { + return nil, err + } + + self.out <- core.NewImportIdentity(i.Id()) + return i, nil +} + +// Create a bug.Bug based from a JIRA issue +func (self *jiraImporter) ensureIssue( + repo *cache.RepoCache, issue Issue) (*cache.BugCache, error) { + author, err := self.ensurePerson(repo, issue.Fields.Creator) + if err != nil { + return nil, err + } + + // TODO(josh)[f8808eb]: Consider looking up the git-bug entry directly from + // the jira field which contains it, if we have a custom field configured + // to store git-bug IDs. + b, err := repo.ResolveBugCreateMetadata(keyJiraID, 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 + } + + title := fmt.Sprintf("[%s]: %s", issue.Key, issue.Fields.Summary) + b, _, err = repo.NewBugRaw( + author, + issue.Fields.Created.Unix(), + title, + cleanText, + nil, + map[string]string{ + keyOrigin: target, + keyJiraID: issue.ID, + keyJiraKey: issue.Key, + keyJiraProject: self.conf[keyProject], + }) + if err != nil { + return nil, err + } + + self.out <- core.NewImportBug(b.Id()) + } else { + self.out <- core.NewImportNothing("", "bug already imported") + } + + return b, nil +} + +// Return a unique string derived from a unique jira id and a timestamp +func getTimeDerivedID(jiraID string, timestamp MyTime) string { + return fmt.Sprintf("%s-%d", jiraID, timestamp.Unix()) +} + +// Create a bug.Comment from a JIRA comment +func (self *jiraImporter) ensureComment( + repo *cache.RepoCache, b *cache.BugCache, item Comment) error { + // ensure person + author, err := self.ensurePerson(repo, item.Author) + if err != nil { + return err + } + + targetOpID, err := b.ResolveOperationWithMetadata( + keyJiraID, item.ID) + if err == nil { + self.out <- core.NewImportNothing("", "comment already imported") + } else if 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{ + keyJiraID: item.ID, + keyJiraProject: self.conf[keyProject], + }, + ) + if err != nil { + return err + } + + self.out <- core.NewImportComment(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) + targetOpID, err = b.ResolveOperationWithMetadata( + keyJiraID, item.ID) + if err == nil { + self.out <- core.NewImportNothing("", "update already imported") + } else if err != cache.ErrNoMatchingOp { + return err + } + + // ensure editor identity + editor, err := self.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(), + target, + cleanText, + map[string]string{ + keyJiraID: derivedID, + keyJiraProject: self.conf[keyProject], + }, + ) + + if err != nil { + return err + } + + self.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), 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 (self *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(keyJiraOperationID, entry.ID) + if err == nil { + self.out <- core.NewImportNothing( + "", "changelog entry already matched to export") + 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 := self.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 := getStatusMap(self.conf) + + // 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": + // TODO(josh)[d7fd71c]: move set-symmetric-difference code to a helper + // function. Probably in jira.go or something. + fromLabels := strings.Split(item.FromString, " ") + toLabels := strings.Split(item.ToString, " ") + removedLabels, addedLabels, _ := setSymmetricDifference( + fromLabels, toLabels) + + opr, isRightType := potentialOp.(*bug.LabelChangeOperation) + if isRightType && + labelSetsMatch(addedLabels, opr.Added) && + labelSetsMatch(removedLabels, opr.Removed) { + b.SetMetadata(opr.Id(), map[string]string{ + keyJiraOperationID: entry.ID, + }) + self.out <- core.NewImportNothing("", "matched export") + return nil + } + + case "status": + opr, isRightType := potentialOp.(*bug.SetStatusOperation) + if isRightType && statusMap[opr.Status.String()] == item.ToString { + b.SetMetadata(opr.Id(), map[string]string{ + keyJiraOperationID: entry.ID, + }) + self.out <- core.NewImportNothing("", "matched export") + 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.ToString { + b.SetMetadata(opr.Id(), map[string]string{ + keyJiraOperationID: entry.ID, + }) + self.out <- core.NewImportNothing("", "matched export") + 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 { + b.SetMetadata(opr.Id(), map[string]string{ + keyJiraOperationID: entry.ID, + }) + self.out <- core.NewImportNothing("", "matched export") + 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(keyJiraOperationID, derivedID) + if err == nil { + self.out <- core.NewImportNothing("", "update already imported") + continue + } else if err != cache.ErrNoMatchingOp { + return err + } + + switch item.Field { + case "labels": + // TODO(josh)[d7fd71c]: move set-symmetric-difference code to a helper + // function. Probably in jira.go or something. + fromLabels := strings.Split(item.FromString, " ") + toLabels := strings.Split(item.ToString, " ") + removedLabels, addedLabels, _ := setSymmetricDifference( + fromLabels, toLabels) + + op, err := b.ForceChangeLabelsRaw( + author, + entry.Created.Unix(), + addedLabels, + removedLabels, + map[string]string{ + keyJiraID: entry.ID, + keyJiraOperationID: derivedID, + keyJiraProject: self.conf[keyProject], + }, + ) + if err != nil { + return err + } + + self.out <- core.NewImportLabelChange(op.Id()) + + case "status": + if statusMap[bug.OpenStatus.String()] == item.ToString { + op, err := b.OpenRaw( + author, + entry.Created.Unix(), + map[string]string{ + keyJiraID: entry.ID, + + keyJiraProject: self.conf[keyProject], + keyJiraOperationID: derivedID, + }, + ) + if err != nil { + return err + } + self.out <- core.NewImportStatusChange(op.Id()) + } else if statusMap[bug.ClosedStatus.String()] == item.ToString { + op, err := b.CloseRaw( + author, + entry.Created.Unix(), + map[string]string{ + keyJiraID: entry.ID, + + keyJiraProject: self.conf[keyProject], + keyJiraOperationID: derivedID, + }, + ) + if err != nil { + return err + } + self.out <- core.NewImportStatusChange(op.Id()) + } else { + self.out <- core.NewImportNothing( + "", fmt.Sprintf( + "No git-bug status mapped for jira status %s", item.ToString)) + } + + 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{ + keyJiraID: entry.ID, + keyJiraOperationID: derivedID, + keyJiraProject: self.conf[keyProject], + }, + ) + if err != nil { + return err + } + + self.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.EditBodyRaw( + author, + entry.Created.Unix(), + string(item.ToString), + map[string]string{ + keyJiraID: entry.ID, + keyJiraOperationID: derivedID, + keyJiraProject: self.conf[keyProject], + }, + ) + if err != nil { + return err + } + + self.out <- core.NewImportCommentEdition(op.Id()) + } + + // 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 { + var hasConf bool + statusMap := make(map[string]string) + statusMap[bug.OpenStatus.String()], hasConf = conf[keyMapOpenID] + if !hasConf { + // Default to "1" which is the built-in jira "Open" status + statusMap[bug.OpenStatus.String()] = "1" + } + statusMap[bug.ClosedStatus.String()], hasConf = conf[keyMapCloseID] + if !hasConf { + // Default to "6" which is the built-in jira "Closed" status + statusMap[bug.OpenStatus.String()] = "6" + } + return statusMap +} diff --git a/bridge/jira/jira.go b/bridge/jira/jira.go new file mode 100644 index 00000000..933c1239 --- /dev/null +++ b/bridge/jira/jira.go @@ -0,0 +1,87 @@ +// Package jira contains the Jira bridge implementation +package jira + +import ( + "sort" + + "github.com/MichaelMure/git-bug/bridge/core" +) + +func init() { + core.Register(&Jira{}) +} + +// Jira Main object for the bridge +type Jira struct{} + +// Target returns "jira" +func (*Jira) Target() string { + return target +} + +// NewImporter returns the jira importer +func (*Jira) NewImporter() core.Importer { + return &jiraImporter{} +} + +// NewExporter returns the jira exporter +func (*Jira) NewExporter() core.Exporter { + return &jiraExporter{} +} + +// stringInSlice returns true if needle is found in haystack +func stringInSlice(needle string, haystack []string) bool { + for _, match := range haystack { + if match == needle { + return true + } + } + return false +} + +// Given two string slices, return three lists containing: +// 1. elements found only in the first input list +// 2. elements found only in the second input list +// 3. elements found in both input lists +func setSymmetricDifference( + setA, setB []string) ([]string, []string, []string) { + sort.Strings(setA) + sort.Strings(setB) + + maxLen := len(setA) + len(setB) + onlyA := make([]string, 0, maxLen) + onlyB := make([]string, 0, maxLen) + both := make([]string, 0, maxLen) + + idxA := 0 + idxB := 0 + + for idxA < len(setA) && idxB < len(setB) { + if setA[idxA] < setB[idxB] { + // In the first set, but not the second + onlyA = append(onlyA, setA[idxA]) + idxA++ + } else if setA[idxA] > setB[idxB] { + // In the second set, but not the first + onlyB = append(onlyB, setB[idxB]) + idxB++ + } else { + // In both + both = append(both, setA[idxA]) + idxA++ + idxB++ + } + } + + for ; idxA < len(setA); idxA++ { + // Leftovers in the first set, not the second + onlyA = append(onlyA, setA[idxA]) + } + + for ; idxB < len(setB); idxB++ { + // Leftovers in the second set, not the first + onlyB = append(onlyB, setB[idxB]) + } + + return onlyA, onlyB, both +} -- cgit From 7657a38ff20a7b3b2dbec5e6c981038c871f46e7 Mon Sep 17 00:00:00 2001 From: Josh Bialkowski Date: Thu, 14 Nov 2019 15:56:32 -0800 Subject: codereview #1: clean lint from golangcibot --- bridge/jira/client.go | 7 +++++-- bridge/jira/config.go | 3 +++ bridge/jira/export.go | 14 +++++--------- bridge/jira/import.go | 33 ++++++++++++++++++++++++--------- 4 files changed, 37 insertions(+), 20 deletions(-) (limited to 'bridge/jira') diff --git a/bridge/jira/client.go b/bridge/jira/client.go index bfc30039..486c1a87 100644 --- a/bridge/jira/client.go +++ b/bridge/jira/client.go @@ -1363,6 +1363,9 @@ func (client *Client) DoTransition( `{"transition":{"id":"%s"}, "resolution": {"name": "Done"}}`, transitionID) request, err := http.NewRequest("POST", url, bytes.NewBuffer(buffer.Bytes())) + if err != nil { + return responseTime, err + } if client.ctx != nil { ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) @@ -1378,9 +1381,9 @@ func (client *Client) DoTransition( defer response.Body.Close() if response.StatusCode != http.StatusNoContent { - err := fmt.Errorf( + err := errors.Wrap(errTransitionNotAllowed, fmt.Sprintf( "HTTP response %d, query was %s", response.StatusCode, - request.URL.String()) + request.URL.String())) return responseTime, err } diff --git a/bridge/jira/config.go b/bridge/jira/config.go index e33b8f28..62b7364e 100644 --- a/bridge/jira/config.go +++ b/bridge/jira/config.go @@ -105,6 +105,9 @@ func (g *Jira) Configure( fmt.Printf("Attempting to login with credentials...\n") client := NewClient(serverURL, nil) err = client.RefreshTokenRaw(jsonData) + if err != nil { + return nil, err + } // verify access to the project with credentials _, err = client.GetProject(project) diff --git a/bridge/jira/export.go b/bridge/jira/export.go index 6472b212..fc0d4ad3 100644 --- a/bridge/jira/export.go +++ b/bridge/jira/export.go @@ -15,8 +15,6 @@ import ( "github.com/MichaelMure/git-bug/entity" ) -var errDuplicateMatch = errors.New("Ambiguous match") - // jiraExporter implement the Exporter interface type jiraExporter struct { conf core.Configuration @@ -199,7 +197,10 @@ func (self *jiraExporter) exportBug( fields := make(map[string]interface{}) defaultFields, hasConf := self.conf[keyCreateDefaults] if hasConf { - json.Unmarshal([]byte(defaultFields), &fields) + err = json.Unmarshal([]byte(defaultFields), &fields) + if err != nil { + panic("Invalid JSON in config") + } } else { // If there is no configuration provided, at the very least the // "issueType" field is always required. 10001 is "story" which I'm @@ -278,9 +279,8 @@ func (self *jiraExporter) exportBug( var id string var exportTime time.Time - switch op.(type) { + switch opr := op.(type) { case *bug.AddCommentOperation: - opr := op.(*bug.AddCommentOperation) comment, err := client.AddComment(bugJiraID, opr.Message) if err != nil { err := errors.Wrap(err, "adding comment") @@ -294,7 +294,6 @@ func (self *jiraExporter) exportBug( self.cachedOperationIDs[op.Id()] = id case *bug.EditCommentOperation: - opr := op.(*bug.EditCommentOperation) if opr.Target == createOp.Id() { // An EditCommentOpreation with the Target set to the create operation // encodes a modification to the long-description/summary. @@ -332,7 +331,6 @@ func (self *jiraExporter) exportBug( } case *bug.SetStatusOperation: - opr := op.(*bug.SetStatusOperation) jiraStatus, hasStatus := statusMap[opr.Status.String()] if hasStatus { exportTime, err = UpdateIssueStatus(client, bugJiraID, jiraStatus) @@ -355,7 +353,6 @@ func (self *jiraExporter) exportBug( } case *bug.SetTitleOperation: - opr := op.(*bug.SetTitleOperation) exportTime, err = client.UpdateIssueTitle(bugJiraID, opr.Title) if err != nil { err := errors.Wrap(err, "editing title") @@ -368,7 +365,6 @@ func (self *jiraExporter) exportBug( id = bugJiraID case *bug.LabelChangeOperation: - opr := op.(*bug.LabelChangeOperation) exportTime, err = client.UpdateLabels( bugJiraID, opr.Added, opr.Removed) if err != nil { diff --git a/bridge/jira/import.go b/bridge/jira/import.go index 6f8aea1d..3fcac921 100644 --- a/bridge/jira/import.go +++ b/bridge/jira/import.go @@ -89,7 +89,10 @@ func (self *jiraImporter) ImportAll( for commentIter = client.IterComments(issue.ID, defaultPageSize); commentIter.HasNext(); { comment := commentIter.Next() - self.ensureComment(repo, bug, *comment) + err := self.ensureComment(repo, bug, *comment) + if err != nil { + out <- core.NewImportError(err, "") + } } if commentIter.HasError() { out <- core.NewImportError(commentIter.Err, "") @@ -123,10 +126,13 @@ func (self *jiraImporter) ImportAll( } } if opIdx < len(snapshot.Operations) { - self.ensureChange( + err = self.ensureChange( repo, bug, *changelogEntry, snapshot.Operations[opIdx]) } else { - self.ensureChange(repo, bug, *changelogEntry, nil) + err = self.ensureChange(repo, bug, *changelogEntry, nil) + } + if err != nil { + out <- core.NewImportError(err, "") } } @@ -290,8 +296,8 @@ func (self *jiraImporter) ensureComment( // timestamp. Note that this must be consistent with the exporter during // export of an EditCommentOperation derivedID := getTimeDerivedID(item.ID, item.Updated) - targetOpID, err = b.ResolveOperationWithMetadata( - keyJiraID, item.ID) + _, err = b.ResolveOperationWithMetadata( + keyJiraID, derivedID) if err == nil { self.out <- core.NewImportNothing("", "update already imported") } else if err != cache.ErrNoMatchingOp { @@ -341,7 +347,7 @@ func labelSetsMatch(jiraSet []string, gitbugSet []bug.Label) bool { } sort.Strings(jiraSet) - gitbugStrSet := make([]string, len(gitbugSet), len(gitbugSet)) + gitbugStrSet := make([]string, len(gitbugSet)) for idx, label := range gitbugSet { gitbugStrSet[idx] = label.String() } @@ -420,9 +426,12 @@ func (self *jiraImporter) ensureChange( case "status": opr, isRightType := potentialOp.(*bug.SetStatusOperation) if isRightType && statusMap[opr.Status.String()] == item.ToString { - b.SetMetadata(opr.Id(), map[string]string{ + _, err := b.SetMetadata(opr.Id(), map[string]string{ keyJiraOperationID: entry.ID, }) + if err != nil { + panic("Can't set metadata") + } self.out <- core.NewImportNothing("", "matched export") return nil } @@ -432,9 +441,12 @@ func (self *jiraImporter) ensureChange( // text, but it's the title opr, isRightType := potentialOp.(*bug.SetTitleOperation) if isRightType && opr.Title == item.ToString { - b.SetMetadata(opr.Id(), map[string]string{ + _, err := b.SetMetadata(opr.Id(), map[string]string{ keyJiraOperationID: entry.ID, }) + if err != nil { + panic("Can't set metadata") + } self.out <- core.NewImportNothing("", "matched export") return nil } @@ -446,9 +458,12 @@ func (self *jiraImporter) ensureChange( if isRightType && opr.Target == b.Snapshot().Operations[0].Id() && opr.Message == item.ToString { - b.SetMetadata(opr.Id(), map[string]string{ + _, err := b.SetMetadata(opr.Id(), map[string]string{ keyJiraOperationID: entry.ID, }) + if err != nil { + panic("Can't set metadata") + } self.out <- core.NewImportNothing("", "matched export") return nil } -- cgit From cd889572f7870a62758240b323a9086a76c5120a Mon Sep 17 00:00:00 2001 From: Josh Bialkowski Date: Fri, 22 Nov 2019 22:34:19 -0800 Subject: codereview #2: some cleanup, correct use of nothing-events * return error, don't panic * skipping status export is an error * use switch in config.go * move PromptPassword to input * move client construction into getIdentityClient * use non-pointer context throughout client since it is an interface * remove some TODOs * don't emit multiple nothing-events, just one per bug only if nothing happened. * rename EditBody to EditCreateComment * add configuration notes about additional values * store bug id map in a dictionary in the config * some fixes from testing --- bridge/jira/client.go | 35 +++++++------- bridge/jira/config.go | 56 +++++++---------------- bridge/jira/export.go | 124 ++++++++++++++++++++++++++------------------------ bridge/jira/import.go | 77 +++++++++++++------------------ 4 files changed, 131 insertions(+), 161 deletions(-) (limited to 'bridge/jira') diff --git a/bridge/jira/client.go b/bridge/jira/client.go index 486c1a87..6dd25ccb 100644 --- a/bridge/jira/client.go +++ b/bridge/jira/client.go @@ -15,6 +15,7 @@ import ( "github.com/MichaelMure/git-bug/bridge/core" "github.com/MichaelMure/git-bug/bug" + "github.com/MichaelMure/git-bug/input" "github.com/pkg/errors" ) @@ -318,12 +319,12 @@ func (self *ClientTransport) RoundTrip( type Client struct { *http.Client serverURL string - ctx *context.Context + ctx context.Context } // 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(serverURL string, ctx context.Context) *Client { cookiJar, _ := cookiejar.New(nil) client := &http.Client{ Transport: &ClientTransport{underlyingTransport: http.DefaultTransport}, @@ -353,7 +354,7 @@ func (client *Client) Login(conf core.Configuration) error { password := conf[keyPassword] if password == "" { var err error - password, err = PromptPassword() + password, err = input.PromptPassword() if err != nil { return err } @@ -397,7 +398,7 @@ func (client *Client) RefreshTokenRaw(credentialsJSON []byte) error { } if client.ctx != nil { - ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() req = req.WithContext(ctx) } @@ -465,7 +466,7 @@ func (client *Client) Search(jql string, maxResults int, startAt int) ( } if client.ctx != nil { - ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } @@ -593,7 +594,7 @@ func (client *Client) GetIssue( request.URL.RawQuery = query.Encode() if client.ctx != nil { - ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } @@ -648,7 +649,7 @@ func (client *Client) GetComments( request.URL.RawQuery = query.Encode() if client.ctx != nil { - ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } @@ -776,7 +777,7 @@ func (client *Client) GetChangeLog( request.URL.RawQuery = query.Encode() if client.ctx != nil { - ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } @@ -905,7 +906,7 @@ func (client *Client) GetProject(projectIDOrKey string) (*Project, error) { } if client.ctx != nil { - ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } @@ -972,7 +973,7 @@ func (client *Client) CreateIssue( } if client.ctx != nil { - ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } @@ -1086,7 +1087,7 @@ func (client *Client) UpdateIssueBody( } if client.ctx != nil { - ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } @@ -1138,7 +1139,7 @@ func (client *Client) AddComment(issueKeyOrID, body string) (*Comment, error) { } if client.ctx != nil { - ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } @@ -1189,7 +1190,7 @@ func (client *Client) UpdateComment(issueKeyOrID, commentID, body string) ( } if client.ctx != nil { - ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } @@ -1255,7 +1256,7 @@ func (client *Client) UpdateLabels( } if client.ctx != nil { - ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } @@ -1304,7 +1305,7 @@ func (client *Client) GetTransitions(issueKeyOrID string) ( } if client.ctx != nil { - ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } @@ -1368,7 +1369,7 @@ func (client *Client) DoTransition( } if client.ctx != nil { - ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } @@ -1414,7 +1415,7 @@ func (client *Client) GetServerInfo() (*ServerInfo, error) { } if client.ctx != nil { - ctx, cancel := context.WithTimeout(*client.ctx, defaultTimeout) + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } diff --git a/bridge/jira/config.go b/bridge/jira/config.go index 62b7364e..41070228 100644 --- a/bridge/jira/config.go +++ b/bridge/jira/config.go @@ -8,15 +8,13 @@ import ( "os" "strconv" "strings" - "syscall" "time" "github.com/pkg/errors" - "golang.org/x/crypto/ssh/terminal" "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/input" "github.com/MichaelMure/git-bug/repository" - "github.com/MichaelMure/git-bug/util/interrupt" ) const ( @@ -26,14 +24,20 @@ const ( keyCredentialsFile = "credentials-file" keyUsername = "username" keyPassword = "password" - keyMapOpenID = "bug-open-id" - keyMapCloseID = "bug-closed-id" + keyIDMap = "bug-id-map" keyCreateDefaults = "create-issue-defaults" keyCreateGitBug = "create-issue-gitbug-id" defaultTimeout = 60 * time.Second ) +const moreConfigText = ` +NOTE: There are a few optional configuration values that you can additionally +set in your git configuration to influence the behavior of the bridge. Please +see the notes at: +https://github.com/MichaelMure/git-bug/blob/master/doc/jira_bridge.md +` + // Configure sets up the bridge configuration func (g *Jira) Configure( repo repository.RepoCommon, params core.BridgeParams) ( @@ -91,7 +95,7 @@ func (g *Jira) Configure( return nil, err } - password, err = PromptPassword() + password, err = input.PromptPassword() if err != nil { return nil, err } @@ -121,24 +125,27 @@ func (g *Jira) Configure( conf[core.KeyTarget] = target conf[keyServer] = serverURL conf[keyProject] = project - if choice == 1 { + switch choice { + case 1: conf[keyCredentialsFile] = credentialsFile err = ioutil.WriteFile(credentialsFile, jsonData, 0644) if err != nil { return nil, errors.Wrap( err, fmt.Sprintf("Unable to write credentials to %s", credentialsFile)) } - } else if choice == 2 { + case 2: conf[keyUsername] = username conf[keyPassword] = password - } else if choice == 3 { + case 3: conf[keyUsername] = username } + err = g.ValidateConfig(conf) if err != nil { return nil, err } + fmt.Print(moreConfigText) return conf, nil } @@ -209,34 +216,3 @@ func prompt(description, name string) (string, error) { return line, nil } } - -// PromptPassword performs interactive input collection to get the user password -func PromptPassword() (string, error) { - termState, err := terminal.GetState(int(syscall.Stdin)) - if err != nil { - return "", err - } - - cancel := interrupt.RegisterCleaner(func() error { - return terminal.Restore(int(syscall.Stdin), termState) - }) - defer cancel() - - for { - fmt.Print("password: ") - bytePassword, err := terminal.ReadPassword(int(syscall.Stdin)) - // new line for coherent formatting, ReadPassword clip the normal new line - // entered by the user - fmt.Println() - - if err != nil { - return "", err - } - - if len(bytePassword) > 0 { - return string(bytePassword), nil - } - - fmt.Println("password is empty") - } -} diff --git a/bridge/jira/export.go b/bridge/jira/export.go index fc0d4ad3..8d8d326d 100644 --- a/bridge/jira/export.go +++ b/bridge/jira/export.go @@ -15,10 +15,19 @@ import ( "github.com/MichaelMure/git-bug/entity" ) +var ( + ErrMissingCredentials = errors.New("missing credentials") +) + // jiraExporter implement the Exporter interface 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 @@ -36,7 +45,6 @@ type jiraExporter struct { // Init . func (self *jiraExporter) Init(conf core.Configuration) error { self.conf = conf - //TODO: initialize with multiple tokens self.identityClient = make(map[entity.Id]*Client) self.cachedOperationIDs = make(map[entity.Id]string) self.cachedLabels = make(map[string]string) @@ -47,17 +55,27 @@ func (self *jiraExporter) Init(conf core.Configuration) error { // 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 (self *jiraExporter) getIdentityClient( - ctx *context.Context, id entity.Id) (*Client, error) { + ctx context.Context, id entity.Id) (*Client, error) { client, ok := self.identityClient[id] if ok { return client, nil } - // TODO(josh)[]: The github exporter appears to contain code that will - // allow it to export bugs owned by other people as long as we have a token - // for that identity. I guess the equivalent for us will be as long as we - // have a credentials pair for that identity. - return nil, fmt.Errorf("Not implemented") + client = NewClient(self.conf[keyServer], ctx) + + // 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 != self.userIdentity { + return nil, ErrMissingCredentials + } + err := client.Login(self.conf) + if err != nil { + return nil, err + } + + self.identityClient[id] = client + return client, nil } // ExportAll export all event made by the current user to Jira @@ -72,19 +90,10 @@ func (self *jiraExporter) ExportAll( return nil, err } - // TODO(josh)[]: The github exporter appears to contain code that will - // allow it to export bugs owned by other people as long as we have a token - // for that identity. I guess the equivalent for us will be as long as we - // have a credentials pair for that identity. - client := NewClient(self.conf[keyServer], &ctx) - err = client.Login(self.conf) - self.identityClient[user.Id()] = client - - if err != nil { - return nil, err - } - - client, err = self.getIdentityClient(&ctx, user.Id()) + // NOTE: this is currently only need to mock the credentials database in + // getIdentityClient. + self.userIdentity = user.Id() + client, err := self.getIdentityClient(ctx, user.Id()) if err != nil { return nil, err } @@ -129,7 +138,11 @@ func (self *jiraExporter) ExportAll( if snapshot.HasAnyActor(allIdentitiesIds...) { // try to export the bug and it associated events - self.exportBug(ctx, b, since, out) + err := self.exportBug(ctx, b, since, out) + if err != nil { + out <- core.NewExportError(errors.Wrap(err, "can't export bug"), id) + return + } } else { out <- core.NewExportNothing(id, "not an actor") } @@ -143,7 +156,7 @@ func (self *jiraExporter) ExportAll( // exportBug publish bugs and related events func (self *jiraExporter) exportBug( ctx context.Context, b *cache.BugCache, since time.Time, - out chan<- core.ExportResult) { + out chan<- core.ExportResult) error { snapshot := b.Snapshot() var bugJiraID string @@ -162,7 +175,7 @@ func (self *jiraExporter) exportBug( if ok && origin != target { out <- core.NewExportNothing( b.Id(), fmt.Sprintf("issue tagged with origin: %s", origin)) - return + return nil } // skip bug if it is a jira bug but is associated with another project @@ -171,25 +184,24 @@ func (self *jiraExporter) exportBug( if ok && !stringInSlice(project, []string{self.project.ID, self.project.Key}) { out <- core.NewExportNothing( b.Id(), fmt.Sprintf("issue tagged with project: %s", project)) - return + return nil } // get jira bug ID jiraID, ok := snapshot.GetCreateMetadata(keyJiraID) if ok { - out <- core.NewExportNothing(b.Id(), "bug creation already exported") // 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 := self.getIdentityClient(&ctx, author.Id()) + client, err := self.getIdentityClient(ctx, 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", author.Id().String())) - return + return err } // Load any custom fields required to create an issue from the git @@ -199,13 +211,15 @@ func (self *jiraExporter) exportBug( if hasConf { err = json.Unmarshal([]byte(defaultFields), &fields) if err != nil { - panic("Invalid JSON in config") + return err } } else { // If there is no configuration provided, at the very least the // "issueType" field is always required. 10001 is "story" which I'm // pretty sure is standard/default on all JIRA instances. - fields["issueType"] = "10001" + fields["issuetype"] = map[string]interface{}{ + "id": "10001", + } } bugIDField, hasConf := self.conf[keyCreateGitBug] if hasConf { @@ -220,7 +234,7 @@ func (self *jiraExporter) exportBug( if err != nil { err := errors.Wrap(err, "exporting jira issue") out <- core.NewExportError(err, b.Id()) - return + return err } id := result.ID @@ -231,7 +245,7 @@ func (self *jiraExporter) exportBug( if err != nil { err := errors.Wrap(err, "marking operation as exported") out <- core.NewExportError(err, b.Id()) - return + return err } // commit operation to avoid creating multiple issues with multiple pushes @@ -239,7 +253,7 @@ func (self *jiraExporter) exportBug( if err != nil { err := errors.Wrap(err, "bug commit") out <- core.NewExportError(err, b.Id()) - return + return err } // cache bug jira ID @@ -250,7 +264,10 @@ func (self *jiraExporter) exportBug( self.cachedOperationIDs[createOp.Id()] = bugJiraID // lookup the mapping from git-bug "status" to JIRA "status" id - statusMap := getStatusMap(self.conf) + statusMap, err := getStatusMap(self.conf) + if err != nil { + return err + } for _, op := range snapshot.Operations[1:] { // ignore SetMetadata operations @@ -263,17 +280,15 @@ func (self *jiraExporter) exportBug( // Jira if id, ok := op.GetMetadata(keyJiraID); ok { self.cachedOperationIDs[op.Id()] = id - out <- core.NewExportNothing(op.Id(), "already exported operation") continue } opAuthor := op.GetAuthor() - client, err := self.getIdentityClient(&ctx, opAuthor.Id()) + client, err := self.getIdentityClient(ctx, opAuthor.Id()) if err != nil { - out <- core.NewExportNothing( - op.Id(), fmt.Sprintf( - "missing operation author credentials for user %.8s", - author.Id().String())) + out <- core.NewExportError( + fmt.Errorf("missing operation author credentials for user %.8s", + author.Id().String()), op.Id()) continue } @@ -285,7 +300,7 @@ func (self *jiraExporter) exportBug( if err != nil { err := errors.Wrap(err, "adding comment") out <- core.NewExportError(err, b.Id()) - return + return err } id = comment.ID out <- core.NewExportComment(op.Id()) @@ -301,7 +316,7 @@ func (self *jiraExporter) exportBug( if err != nil { err := errors.Wrap(err, "editing issue") out <- core.NewExportError(err, b.Id()) - return + return err } out <- core.NewExportCommentEdition(op.Id()) id = bugJiraID @@ -319,7 +334,7 @@ func (self *jiraExporter) exportBug( if err != nil { err := errors.Wrap(err, "editing comment") out <- core.NewExportError(err, b.Id()) - return + return err } out <- core.NewExportCommentEdition(op.Id()) // JIRA doesn't track all comment edits, they will only tell us about @@ -343,13 +358,10 @@ func (self *jiraExporter) exportBug( continue } out <- core.NewExportStatusChange(op.Id()) - // TODO(josh)[c2c6767]: query changelog to get the changelog-id so that - // we don't re-import the same change. id = bugJiraID } else { - out <- core.NewExportNothing( - op.Id(), fmt.Sprintf( - "No jira status mapped for %.8s", opr.Status.String())) + out <- core.NewExportError(fmt.Errorf( + "No jira status mapped for %.8s", opr.Status.String()), b.Id()) } case *bug.SetTitleOperation: @@ -357,11 +369,9 @@ func (self *jiraExporter) exportBug( if err != nil { err := errors.Wrap(err, "editing title") out <- core.NewExportError(err, b.Id()) - return + return err } out <- core.NewExportTitleEdition(op.Id()) - // TODO(josh)[c2c6767]: query changelog to get the changelog-id so that - // we don't re-import the same change. id = bugJiraID case *bug.LabelChangeOperation: @@ -370,11 +380,9 @@ func (self *jiraExporter) exportBug( if err != nil { err := errors.Wrap(err, "updating labels") out <- core.NewExportError(err, b.Id()) - return + return err } out <- core.NewExportLabelChange(op.Id()) - // TODO(josh)[c2c6767]: query changelog to get the changelog-id so that - // we don't re-import the same change. id = bugJiraID default: @@ -382,16 +390,12 @@ func (self *jiraExporter) exportBug( } // mark operation as exported - // TODO(josh)[c2c6767]: Should we query the changelog after we export? - // Some of the operations above don't record an ID... so we are bound to - // re-import them. It shouldn't cause too much of an issue but we will have - // duplicate edit entries for everything and it would be nice to avoid that. err = markOperationAsExported( b, op.Id(), id, self.project.Key, exportTime) if err != nil { err := errors.Wrap(err, "marking operation as exported") out <- core.NewExportError(err, b.Id()) - return + return err } // commit at each operation export to avoid exporting same events multiple @@ -400,9 +404,11 @@ func (self *jiraExporter) exportBug( if err != nil { err := errors.Wrap(err, "bug commit") out <- core.NewExportError(err, b.Id()) - return + return err } } + + return nil } func markOperationAsExported( diff --git a/bridge/jira/import.go b/bridge/jira/import.go index 3fcac921..96ef81ab 100644 --- a/bridge/jira/import.go +++ b/bridge/jira/import.go @@ -2,6 +2,7 @@ package jira import ( "context" + "encoding/json" "fmt" "net/http" "sort" @@ -57,7 +58,7 @@ func (self *jiraImporter) ImportAll( go func() { defer close(self.out) - client := NewClient(serverURL, &ctx) + client := NewClient(serverURL, ctx) err := client.Login(self.conf) if err != nil { out <- core.NewImportError(err, "") @@ -140,7 +141,9 @@ func (self *jiraImporter) ImportAll( out <- core.NewImportError(changelogIter.Err, "") } - if err := bug.CommitAsNeeded(); err != nil { + if !bug.NeedCommit() { + out <- core.NewImportNothing(bug.Id(), "no imported operation") + } else if err := bug.Commit(); err != nil { err = fmt.Errorf("bug commit: %v", err) out <- core.NewImportError(err, "") return @@ -194,9 +197,6 @@ func (self *jiraImporter) ensureIssue( return nil, err } - // TODO(josh)[f8808eb]: Consider looking up the git-bug entry directly from - // the jira field which contains it, if we have a custom field configured - // to store git-bug IDs. b, err := repo.ResolveBugCreateMetadata(keyJiraID, issue.ID) if err != nil && err != bug.ErrBugNotExist { return nil, err @@ -226,8 +226,6 @@ func (self *jiraImporter) ensureIssue( } self.out <- core.NewImportBug(b.Id()) - } else { - self.out <- core.NewImportNothing("", "bug already imported") } return b, nil @@ -249,9 +247,7 @@ func (self *jiraImporter) ensureComment( targetOpID, err := b.ResolveOperationWithMetadata( keyJiraID, item.ID) - if err == nil { - self.out <- core.NewImportNothing("", "comment already imported") - } else if err != cache.ErrNoMatchingOp { + if err != nil && err != cache.ErrNoMatchingOp { return err } @@ -298,9 +294,7 @@ func (self *jiraImporter) ensureComment( derivedID := getTimeDerivedID(item.ID, item.Updated) _, err = b.ResolveOperationWithMetadata( keyJiraID, derivedID) - if err == nil { - self.out <- core.NewImportNothing("", "update already imported") - } else if err != cache.ErrNoMatchingOp { + if err != nil && err != cache.ErrNoMatchingOp { return err } @@ -373,8 +367,6 @@ func (self *jiraImporter) ensureChange( // operation and we've already done the match, so we skip this one _, err := b.ResolveOperationWithMetadata(keyJiraOperationID, entry.ID) if err == nil { - self.out <- core.NewImportNothing( - "", "changelog entry already matched to export") return nil } else if err != cache.ErrNoMatchingOp { return err @@ -395,7 +387,10 @@ func (self *jiraImporter) ensureChange( return fmt.Errorf("Received changelog entry with no item! (%s)", entry.ID) } - statusMap := getStatusMap(self.conf) + statusMap, err := getStatusMap(self.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 @@ -405,8 +400,6 @@ func (self *jiraImporter) ensureChange( for _, item := range entry.Items { switch item.Field { case "labels": - // TODO(josh)[d7fd71c]: move set-symmetric-difference code to a helper - // function. Probably in jira.go or something. fromLabels := strings.Split(item.FromString, " ") toLabels := strings.Split(item.ToString, " ") removedLabels, addedLabels, _ := setSymmetricDifference( @@ -416,10 +409,12 @@ func (self *jiraImporter) ensureChange( if isRightType && labelSetsMatch(addedLabels, opr.Added) && labelSetsMatch(removedLabels, opr.Removed) { - b.SetMetadata(opr.Id(), map[string]string{ + _, err := b.SetMetadata(opr.Id(), map[string]string{ keyJiraOperationID: entry.ID, }) - self.out <- core.NewImportNothing("", "matched export") + if err != nil { + return err + } return nil } @@ -430,9 +425,8 @@ func (self *jiraImporter) ensureChange( keyJiraOperationID: entry.ID, }) if err != nil { - panic("Can't set metadata") + return err } - self.out <- core.NewImportNothing("", "matched export") return nil } @@ -445,9 +439,8 @@ func (self *jiraImporter) ensureChange( keyJiraOperationID: entry.ID, }) if err != nil { - panic("Can't set metadata") + return err } - self.out <- core.NewImportNothing("", "matched export") return nil } @@ -462,9 +455,8 @@ func (self *jiraImporter) ensureChange( keyJiraOperationID: entry.ID, }) if err != nil { - panic("Can't set metadata") + return err } - self.out <- core.NewImportNothing("", "matched export") return nil } } @@ -477,7 +469,6 @@ func (self *jiraImporter) ensureChange( derivedID := getIndexDerivedID(entry.ID, idx) _, err := b.ResolveOperationWithMetadata(keyJiraOperationID, derivedID) if err == nil { - self.out <- core.NewImportNothing("", "update already imported") continue } else if err != cache.ErrNoMatchingOp { return err @@ -485,8 +476,6 @@ func (self *jiraImporter) ensureChange( switch item.Field { case "labels": - // TODO(josh)[d7fd71c]: move set-symmetric-difference code to a helper - // function. Probably in jira.go or something. fromLabels := strings.Split(item.FromString, " ") toLabels := strings.Split(item.ToString, " ") removedLabels, addedLabels, _ := setSymmetricDifference( @@ -541,9 +530,9 @@ func (self *jiraImporter) ensureChange( } self.out <- core.NewImportStatusChange(op.Id()) } else { - self.out <- core.NewImportNothing( - "", fmt.Sprintf( - "No git-bug status mapped for jira status %s", item.ToString)) + self.out <- core.NewImportError( + fmt.Errorf( + "No git-bug status mapped for jira status %s", item.ToString), "") } case "summary": @@ -568,7 +557,7 @@ func (self *jiraImporter) ensureChange( case "description": // NOTE(josh): JIRA calls it "description", which sounds more like the // title but it's actually the body - op, err := b.EditBodyRaw( + op, err := b.EditCreateCommentRaw( author, entry.Created.Unix(), string(item.ToString), @@ -596,18 +585,16 @@ func (self *jiraImporter) ensureChange( return nil } -func getStatusMap(conf core.Configuration) map[string]string { - var hasConf bool - statusMap := make(map[string]string) - statusMap[bug.OpenStatus.String()], hasConf = conf[keyMapOpenID] +func getStatusMap(conf core.Configuration) (map[string]string, error) { + mapStr, hasConf := conf[keyIDMap] if !hasConf { - // Default to "1" which is the built-in jira "Open" status - statusMap[bug.OpenStatus.String()] = "1" + return map[string]string{ + bug.OpenStatus.String(): "1", + bug.ClosedStatus.String(): "6", + }, nil } - statusMap[bug.ClosedStatus.String()], hasConf = conf[keyMapCloseID] - if !hasConf { - // Default to "6" which is the built-in jira "Closed" status - statusMap[bug.OpenStatus.String()] = "6" - } - return statusMap + + statusMap := make(map[string]string) + err := json.Unmarshal([]byte(mapStr), &statusMap) + return statusMap, err } -- cgit From a59aaebc7e2fb6b1d14d6637cad7522463c0b25f Mon Sep 17 00:00:00 2001 From: Josh Bialkowski Date: Wed, 4 Dec 2019 22:50:35 -0800 Subject: codreview #3: two credential types, more fixes * Support both token and session credential types * use getTimeDervedID in export.go * keyOrigin -> core.KeyOrigin * fix one indentation * remove project key from operation metadata * fix missing credentials codepath if not using sidecar --- bridge/jira/client.go | 63 +++++++++++++++++++++++++++++----- bridge/jira/config.go | 94 ++++++++++++++++++++++++++++++++++----------------- bridge/jira/export.go | 4 +-- bridge/jira/import.go | 24 ++++--------- 4 files changed, 126 insertions(+), 59 deletions(-) (limited to 'bridge/jira') diff --git a/bridge/jira/client.go b/bridge/jira/client.go index 6dd25ccb..adaad94d 100644 --- a/bridge/jira/client.go +++ b/bridge/jira/client.go @@ -3,6 +3,7 @@ package jira import ( "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "io/ioutil" @@ -305,15 +306,27 @@ type ServerInfo struct { // "Content-Type=application/json" header type ClientTransport struct { underlyingTransport http.RoundTripper + basicAuthString string } // RoundTrip overrides the default by adding the content-type header func (self *ClientTransport) RoundTrip( req *http.Request) (*http.Response, error) { req.Header.Add("Content-Type", "application/json") + if self.basicAuthString != "" { + req.Header.Add("Authorization", + fmt.Sprintf("Basic %s", self.basicAuthString)) + } + return self.underlyingTransport.RoundTrip(req) } +func (self *ClientTransport) SetCredentials( + username string, token string) { + credString := fmt.Sprintf("%s:%s", username, token) + self.basicAuthString = base64.StdEncoding.EncodeToString([]byte(credString)) +} + // Client Thin wrapper around the http.Client providing jira-specific methods // for APIendpoints type Client struct { @@ -336,12 +349,26 @@ func NewClient(serverURL string, ctx context.Context) *Client { // 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 } - return client.RefreshTokenRaw(content) + + 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] @@ -360,12 +387,18 @@ func (client *Client) Login(conf core.Configuration) error { } } - return client.RefreshToken(username, password) + switch credType { + case "SESSION": + return client.RefreshSessionToken(username, password) + case "TOKEN": + return client.SetTokenCredentials(username, password) + } + return fmt.Errorf("Unexpected credType: %s", credType) } -// RefreshToken formulate the JSON request object from the user credentials -// and POST it to the /session endpoing and get a session cookie -func (client *Client) RefreshToken(username, password string) error { +// RefreshSessionToken formulate the JSON request object from the user +// credentials and POST it to the /session endpoing and get a session cookie +func (client *Client) RefreshSessionToken(username, password string) error { params := SessionQuery{ Username: username, Password: password, @@ -376,12 +409,24 @@ func (client *Client) RefreshToken(username, password string) error { return err } - return client.RefreshTokenRaw(data) + return client.RefreshSessionTokenRaw(data) +} + +// SetTokenCredentials POST credentials to the /session endpoing and get a +// session cookie +func (client *Client) SetTokenCredentials(username, password string) error { + switch transport := client.Transport.(type) { + case *ClientTransport: + transport.SetCredentials(username, password) + default: + return fmt.Errorf("Invalid transport type") + } + return nil } -// RefreshTokenRaw POST credentials to the /session endpoing and get a session -// cookie -func (client *Client) RefreshTokenRaw(credentialsJSON []byte) error { +// RefreshSessionTokenRaw POST credentials to the /session endpoing and get a +// session cookie +func (client *Client) RefreshSessionTokenRaw(credentialsJSON []byte) error { postURL := fmt.Sprintf("%s/rest/auth/1/session", client.serverURL) req, err := http.NewRequest("POST", postURL, bytes.NewBuffer(credentialsJSON)) diff --git a/bridge/jira/config.go b/bridge/jira/config.go index 41070228..e23ee845 100644 --- a/bridge/jira/config.go +++ b/bridge/jira/config.go @@ -21,6 +21,7 @@ const ( target = "jira" keyServer = "server" keyProject = "project" + keyCredentialsType = "credentials-type" keyCredentialsFile = "credentials-file" keyUsername = "username" keyPassword = "password" @@ -38,6 +39,30 @@ see the notes at: https://github.com/MichaelMure/git-bug/blob/master/doc/jira_bridge.md ` +const credTypeText = ` +JIRA has recently altered it's authentication strategies. Servers deployed +prior to October 1st 2019 must use "SESSION" authentication, whereby the REST +client logs in with an actual username and password, is assigned a session, and +passes the session cookie with each request. JIRA Cloud and servers deployed +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. + +Which authentication mechanism should this bridge use? +[1]: SESSION +[2]: TOKEN +` +const credentialsText = ` +How would you like to store your JIRA login credentials? +[1]: 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. +[2]: git-config: Your credentials will be stored in the git config. Note that + it will contain your JIRA password in clear text. +[3]: 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. +` + // Configure sets up the bridge configuration func (g *Jira) Configure( repo repository.RepoCommon, params core.BridgeParams) ( @@ -78,7 +103,12 @@ func (g *Jira) Configure( } } - choice, err := promptCredentialOptions(serverURL) + credType, err := promptOptions(credTypeText, 1, 2) + if err != nil { + return nil, err + } + + choice, err := promptOptions(credentialsText, 1, 3) if err != nil { return nil, err } @@ -106,25 +136,17 @@ func (g *Jira) Configure( return nil, err } - fmt.Printf("Attempting to login with credentials...\n") - client := NewClient(serverURL, nil) - err = client.RefreshTokenRaw(jsonData) - if err != nil { - return nil, err - } - - // verify access to the project with credentials - _, err = client.GetProject(project) - if err != nil { - return nil, fmt.Errorf( - "Project %s doesn't exist on %s, or authentication credentials for (%s)"+ - " are invalid", - project, serverURL, username) - } - conf[core.KeyTarget] = target conf[keyServer] = serverURL conf[keyProject] = project + + switch credType { + case 1: + conf[keyCredentialsType] = "SESSION" + case 2: + conf[keyCredentialsType] = "TOKEN" + } + switch choice { case 1: conf[keyCredentialsFile] = credentialsFile @@ -145,6 +167,23 @@ func (g *Jira) Configure( return nil, err } + fmt.Printf("Attempting to login with credentials...\n") + client := NewClient(serverURL, nil) + err = client.Login(conf) + if err != nil { + return nil, err + } + + // verify access to the project with credentials + fmt.Printf("Checking project ...\n") + _, err = client.GetProject(project) + if err != nil { + return nil, fmt.Errorf( + "Project %s doesn't exist on %s, or authentication credentials for (%s)"+ + " are invalid", + project, serverURL, username) + } + fmt.Print(moreConfigText) return conf, nil } @@ -164,19 +203,8 @@ func (*Jira) ValidateConfig(conf core.Configuration) error { return nil } -const credentialsText = ` -How would you like to store your JIRA login credentials? -[1]: 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. -[2]: git-config: Your credentials will be stored in the git config. Note that - it will contain your JIRA password in clear text. -[3]: 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. -` - -func promptCredentialOptions(serverURL string) (int, error) { - fmt.Print(credentialsText) +func promptOptions(description string, minVal, maxVal int) (int, error) { + fmt.Print(description) for { fmt.Print("Select option: ") @@ -189,10 +217,14 @@ func promptCredentialOptions(serverURL string) (int, error) { line = strings.TrimRight(line, "\n") index, err := strconv.Atoi(line) - if err != nil || (index != 1 && index != 2 && index != 3) { + if err != nil { fmt.Println("invalid input") continue } + if index < minVal || index > maxVal { + fmt.Println("invalid choice") + continue + } return index, nil } diff --git a/bridge/jira/export.go b/bridge/jira/export.go index 8d8d326d..02ec5e14 100644 --- a/bridge/jira/export.go +++ b/bridge/jira/export.go @@ -171,7 +171,7 @@ func (self *jiraExporter) exportBug( author := snapshot.Author // skip bug if it was imported from some other bug system - origin, ok := snapshot.GetCreateMetadata(keyOrigin) + origin, ok := snapshot.GetCreateMetadata(core.KeyOrigin) if ok && origin != target { out <- core.NewExportNothing( b.Id(), fmt.Sprintf("issue tagged with origin: %s", origin)) @@ -342,7 +342,7 @@ func (self *jiraExporter) exportBug( // so we use the comment ID plus the timestamp of the update, as // reported by JIRA. Note that this must be consistent with the importer // during ensureComment() - id = fmt.Sprintf("%s-%d", comment.ID, comment.Updated.Unix()) + id = getTimeDerivedID(comment.ID, comment.Updated) } case *bug.SetStatusOperation: diff --git a/bridge/jira/import.go b/bridge/jira/import.go index 96ef81ab..346aa6fd 100644 --- a/bridge/jira/import.go +++ b/bridge/jira/import.go @@ -17,7 +17,6 @@ import ( ) const ( - keyOrigin = "origin" keyJiraID = "jira-id" keyJiraOperationID = "jira-derived-id" keyJiraKey = "jira-key" @@ -216,7 +215,7 @@ func (self *jiraImporter) ensureIssue( cleanText, nil, map[string]string{ - keyOrigin: target, + core.KeyOrigin: target, keyJiraID: issue.ID, keyJiraKey: issue.Key, keyJiraProject: self.conf[keyProject], @@ -237,8 +236,8 @@ func getTimeDerivedID(jiraID string, timestamp MyTime) string { } // Create a bug.Comment from a JIRA comment -func (self *jiraImporter) ensureComment( - repo *cache.RepoCache, b *cache.BugCache, item Comment) error { +func (self *jiraImporter) ensureComment(repo *cache.RepoCache, + b *cache.BugCache, item Comment) error { // ensure person author, err := self.ensurePerson(repo, item.Author) if err != nil { @@ -271,8 +270,7 @@ func (self *jiraImporter) ensureComment( cleanText, nil, map[string]string{ - keyJiraID: item.ID, - keyJiraProject: self.conf[keyProject], + keyJiraID: item.ID, }, ) if err != nil { @@ -315,8 +313,7 @@ func (self *jiraImporter) ensureComment( target, cleanText, map[string]string{ - keyJiraID: derivedID, - keyJiraProject: self.conf[keyProject], + keyJiraID: derivedID, }, ) @@ -489,7 +486,6 @@ func (self *jiraImporter) ensureChange( map[string]string{ keyJiraID: entry.ID, keyJiraOperationID: derivedID, - keyJiraProject: self.conf[keyProject], }, ) if err != nil { @@ -504,9 +500,7 @@ func (self *jiraImporter) ensureChange( author, entry.Created.Unix(), map[string]string{ - keyJiraID: entry.ID, - - keyJiraProject: self.conf[keyProject], + keyJiraID: entry.ID, keyJiraOperationID: derivedID, }, ) @@ -519,9 +513,7 @@ func (self *jiraImporter) ensureChange( author, entry.Created.Unix(), map[string]string{ - keyJiraID: entry.ID, - - keyJiraProject: self.conf[keyProject], + keyJiraID: entry.ID, keyJiraOperationID: derivedID, }, ) @@ -545,7 +537,6 @@ func (self *jiraImporter) ensureChange( map[string]string{ keyJiraID: entry.ID, keyJiraOperationID: derivedID, - keyJiraProject: self.conf[keyProject], }, ) if err != nil { @@ -564,7 +555,6 @@ func (self *jiraImporter) ensureChange( map[string]string{ keyJiraID: entry.ID, keyJiraOperationID: derivedID, - keyJiraProject: self.conf[keyProject], }, ) if err != nil { -- cgit From 98bd372e604285cf79ffcf04d0fdf423200cab8f Mon Sep 17 00:00:00 2001 From: Josh Bialkowski Date: Mon, 9 Dec 2019 14:17:09 -0800 Subject: codereview #4: fixes from testing * don't prefix imported title's with jira ID * fix import new comment due to wrong variable name * fix double import of comment edition due to improper err check * fix JIRA cloud paginated changelog has a different JSON field then the embedded changelog in the JIRA server issue object * fix splitting label strings yielded an empty string as a label value --- bridge/jira/client.go | 14 +++++++++++++- bridge/jira/import.go | 37 +++++++++++++++++++++++++++++-------- 2 files changed, 42 insertions(+), 9 deletions(-) (limited to 'bridge/jira') diff --git a/bridge/jira/client.go b/bridge/jira/client.go index adaad94d..040c988e 100644 --- a/bridge/jira/client.go +++ b/bridge/jira/client.go @@ -165,7 +165,9 @@ type ChangeLogPage struct { StartAt int `json:"startAt"` MaxResults int `json:"maxResults"` Total int `json:"total"` + IsLast bool `json:"isLast"` // Cloud-only Entries []ChangeLogEntry `json:"histories"` + Values []ChangeLogEntry `json:"values"` } // NextStartAt return the index of the first item on the next page @@ -175,6 +177,9 @@ func (self *ChangeLogPage) NextStartAt() int { // IsLastPage return true if there are no more items beyond this page func (self *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 } @@ -804,7 +809,8 @@ func (client *Client) IterComments( // https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue func (client *Client) GetChangeLog( idOrKey string, maxResults int, startAt int) (*ChangeLogPage, error) { - url := fmt.Sprintf("%s/rest/api/2/issue/%s/changelog", client.serverURL, idOrKey) + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/changelog", client.serverURL, idOrKey) request, err := http.NewRequest("GET", url, nil) if err != nil { @@ -865,6 +871,12 @@ func (client *Client) GetChangeLog( return nil, err } + // JIRA cloud returns changelog entries in the "values" list, whereas JIRA + // server returns them in the "histories" list when embedded in an issue + // object. + changelog.Entries = changelog.Values + changelog.Values = nil + return &changelog, nil } diff --git a/bridge/jira/import.go b/bridge/jira/import.go index 346aa6fd..d4156615 100644 --- a/bridge/jira/import.go +++ b/bridge/jira/import.go @@ -207,7 +207,7 @@ func (self *jiraImporter) ensureIssue( return nil, err } - title := fmt.Sprintf("[%s]: %s", issue.Key, issue.Fields.Summary) + title := issue.Fields.Summary b, _, err = repo.NewBugRaw( author, issue.Fields.Created.Unix(), @@ -278,6 +278,7 @@ func (self *jiraImporter) ensureComment(repo *cache.RepoCache, } self.out <- core.NewImportComment(op.Id()) + targetOpID = op.Id() } // If there are no updates to this comment, then we are done @@ -292,7 +293,12 @@ func (self *jiraImporter) ensureComment(repo *cache.RepoCache, derivedID := getTimeDerivedID(item.ID, item.Updated) _, err = b.ResolveOperationWithMetadata( keyJiraID, derivedID) - if err != nil && err != cache.ErrNoMatchingOp { + if err == nil { + // Already imported this edition + return nil + } + + if err != cache.ErrNoMatchingOp { return err } @@ -310,7 +316,7 @@ func (self *jiraImporter) ensureComment(repo *cache.RepoCache, op, err := b.EditCommentRaw( editor, item.Updated.Unix(), - target, + targetOpID, cleanText, map[string]string{ keyJiraID: derivedID, @@ -397,8 +403,8 @@ func (self *jiraImporter) ensureChange( for _, item := range entry.Items { switch item.Field { case "labels": - fromLabels := strings.Split(item.FromString, " ") - toLabels := strings.Split(item.ToString, " ") + fromLabels := removeEmpty(strings.Split(item.FromString, " ")) + toLabels := removeEmpty(strings.Split(item.ToString, " ")) removedLabels, addedLabels, _ := setSymmetricDifference( fromLabels, toLabels) @@ -467,14 +473,15 @@ func (self *jiraImporter) ensureChange( _, err := b.ResolveOperationWithMetadata(keyJiraOperationID, derivedID) if err == nil { continue - } else if err != cache.ErrNoMatchingOp { + } + if err != cache.ErrNoMatchingOp { return err } switch item.Field { case "labels": - fromLabels := strings.Split(item.FromString, " ") - toLabels := strings.Split(item.ToString, " ") + fromLabels := removeEmpty(strings.Split(item.FromString, " ")) + toLabels := removeEmpty(strings.Split(item.ToString, " ")) removedLabels, addedLabels, _ := setSymmetricDifference( fromLabels, toLabels) @@ -562,6 +569,9 @@ func (self *jiraImporter) ensureChange( } self.out <- core.NewImportCommentEdition(op.Id()) + + default: + fmt.Printf("Unhandled changelog event %s\n", item.Field) } // Other Examples: @@ -588,3 +598,14 @@ func getStatusMap(conf core.Configuration) (map[string]string, error) { err := json.Unmarshal([]byte(mapStr), &statusMap) return statusMap, 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 +} -- cgit From 4e64c834e2cd672f3daff59fe8117873688dfebc Mon Sep 17 00:00:00 2001 From: Josh Bialkowski Date: Fri, 13 Dec 2019 13:17:26 -0800 Subject: codereview #5: reverse-map and ImportWarning * Fix git config reader can't read values with spaces * Add bug-id-revmap config option for the reverse map, and use this in the importer * Add NewImportWarning for things that aren't exactly errors. Use this for unhandled changelog events. * Add NewExportWarning for things that aren't exactly errors. Use this for un-exportable status changes. * Strip newlines from titles on import --- bridge/jira/config.go | 1 + bridge/jira/export.go | 2 +- bridge/jira/import.go | 97 +++++++++++++++++++++++++++++++++++---------------- 3 files changed, 68 insertions(+), 32 deletions(-) (limited to 'bridge/jira') diff --git a/bridge/jira/config.go b/bridge/jira/config.go index e23ee845..fc21111d 100644 --- a/bridge/jira/config.go +++ b/bridge/jira/config.go @@ -26,6 +26,7 @@ const ( keyUsername = "username" keyPassword = "password" keyIDMap = "bug-id-map" + keyIDRevMap = "bug-id-revmap" keyCreateDefaults = "create-issue-defaults" keyCreateGitBug = "create-issue-gitbug-id" diff --git a/bridge/jira/export.go b/bridge/jira/export.go index 02ec5e14..238252ee 100644 --- a/bridge/jira/export.go +++ b/bridge/jira/export.go @@ -351,7 +351,7 @@ func (self *jiraExporter) exportBug( exportTime, err = UpdateIssueStatus(client, bugJiraID, jiraStatus) if err != nil { err := errors.Wrap(err, "editing status") - out <- core.NewExportError(err, b.Id()) + out <- core.NewExportWarning(err, b.Id()) // Failure to update status isn't necessarily a big error. It's // possible that we just don't have enough information to make that // update. In this case, just don't export the operation. diff --git a/bridge/jira/import.go b/bridge/jira/import.go index d4156615..90ba5268 100644 --- a/bridge/jira/import.go +++ b/bridge/jira/import.go @@ -207,7 +207,9 @@ func (self *jiraImporter) ensureIssue( return nil, err } - title := issue.Fields.Summary + // 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(), @@ -390,7 +392,7 @@ func (self *jiraImporter) ensureChange( return fmt.Errorf("Received changelog entry with no item! (%s)", entry.ID) } - statusMap, err := getStatusMap(self.conf) + statusMap, err := getStatusMapReverse(self.conf) if err != nil { return err } @@ -423,7 +425,7 @@ func (self *jiraImporter) ensureChange( case "status": opr, isRightType := potentialOp.(*bug.SetStatusOperation) - if isRightType && statusMap[opr.Status.String()] == item.ToString { + if isRightType && statusMap[opr.Status.String()] == item.To { _, err := b.SetMetadata(opr.Id(), map[string]string{ keyJiraOperationID: entry.ID, }) @@ -437,7 +439,7 @@ func (self *jiraImporter) ensureChange( // 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.ToString { + if isRightType && opr.Title == item.To { _, err := b.SetMetadata(opr.Id(), map[string]string{ keyJiraOperationID: entry.ID, }) @@ -502,36 +504,42 @@ func (self *jiraImporter) ensureChange( self.out <- core.NewImportLabelChange(op.Id()) case "status": - if statusMap[bug.OpenStatus.String()] == item.ToString { - op, err := b.OpenRaw( - author, - entry.Created.Unix(), - map[string]string{ - keyJiraID: entry.ID, - keyJiraOperationID: derivedID, - }, - ) - if err != nil { - return err - } - self.out <- core.NewImportStatusChange(op.Id()) - } else if statusMap[bug.ClosedStatus.String()] == item.ToString { - op, err := b.CloseRaw( - author, - entry.Created.Unix(), - map[string]string{ - keyJiraID: entry.ID, - keyJiraOperationID: derivedID, - }, - ) - if err != nil { - return err + statusStr, hasMap := statusMap[item.To] + if hasMap { + switch statusStr { + case bug.OpenStatus.String(): + op, err := b.OpenRaw( + author, + entry.Created.Unix(), + map[string]string{ + keyJiraID: entry.ID, + keyJiraOperationID: derivedID, + }, + ) + if err != nil { + return err + } + self.out <- core.NewImportStatusChange(op.Id()) + + case bug.ClosedStatus.String(): + op, err := b.CloseRaw( + author, + entry.Created.Unix(), + map[string]string{ + keyJiraID: entry.ID, + keyJiraOperationID: derivedID, + }, + ) + if err != nil { + return err + } + self.out <- core.NewImportStatusChange(op.Id()) } - self.out <- core.NewImportStatusChange(op.Id()) } else { self.out <- core.NewImportError( fmt.Errorf( - "No git-bug status mapped for jira status %s", item.ToString), "") + "No git-bug status mapped for jira status %s (%s)", + item.ToString, item.To), "") } case "summary": @@ -571,7 +579,9 @@ func (self *jiraImporter) ensureChange( self.out <- core.NewImportCommentEdition(op.Id()) default: - fmt.Printf("Unhandled changelog event %s\n", item.Field) + self.out <- core.NewImportWarning( + fmt.Errorf( + "Unhandled changelog event %s", item.Field), "") } // Other Examples: @@ -599,6 +609,31 @@ func getStatusMap(conf core.Configuration) (map[string]string, error) { 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[keyIDRevMap] + 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 { -- cgit From dca85b309a0a82e9993a345964d0831ab2876fb4 Mon Sep 17 00:00:00 2001 From: Josh Bialkowski Date: Wed, 18 Dec 2019 07:49:49 -0800 Subject: repair after rebase --- bridge/jira/config.go | 20 ++++++++++---------- bridge/jira/export.go | 5 +++-- bridge/jira/import.go | 11 ++++++----- bridge/jira/jira.go | 4 ---- 4 files changed, 19 insertions(+), 21 deletions(-) (limited to 'bridge/jira') diff --git a/bridge/jira/config.go b/bridge/jira/config.go index fc21111d..59076564 100644 --- a/bridge/jira/config.go +++ b/bridge/jira/config.go @@ -13,8 +13,8 @@ import ( "github.com/pkg/errors" "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/cache" "github.com/MichaelMure/git-bug/input" - "github.com/MichaelMure/git-bug/repository" ) const ( @@ -66,7 +66,7 @@ How would you like to store your JIRA login credentials? // Configure sets up the bridge configuration func (g *Jira) Configure( - repo repository.RepoCommon, params core.BridgeParams) ( + repo *cache.RepoCache, params core.BridgeParams) ( core.Configuration, error) { conf := make(core.Configuration) var err error @@ -77,11 +77,11 @@ func (g *Jira) Configure( var password string var serverURL string - 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.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 != "" { return nil, fmt.Errorf("owner doesn't make sense for jira") @@ -137,7 +137,7 @@ func (g *Jira) Configure( return nil, err } - conf[core.KeyTarget] = target + conf[core.ConfigKeyTarget] = target conf[keyServer] = serverURL conf[keyProject] = project @@ -191,8 +191,8 @@ func (g *Jira) Configure( // ValidateConfig returns true if all required keys are present func (*Jira) ValidateConfig(conf core.Configuration) error { - if v, ok := conf[core.KeyTarget]; !ok { - return fmt.Errorf("missing %s key", core.KeyTarget) + if v, ok := conf[core.ConfigKeyTarget]; !ok { + return fmt.Errorf("missing %s key", core.ConfigKeyTarget) } else if v != target { return fmt.Errorf("unexpected target name: %v", v) } diff --git a/bridge/jira/export.go b/bridge/jira/export.go index 238252ee..f2b9d507 100644 --- a/bridge/jira/export.go +++ b/bridge/jira/export.go @@ -43,7 +43,8 @@ type jiraExporter struct { } // Init . -func (self *jiraExporter) Init(conf core.Configuration) error { +func (self *jiraExporter) Init(repo *cache.RepoCache, + conf core.Configuration) error { self.conf = conf self.identityClient = make(map[entity.Id]*Client) self.cachedOperationIDs = make(map[entity.Id]string) @@ -171,7 +172,7 @@ func (self *jiraExporter) exportBug( author := snapshot.Author // skip bug if it was imported from some other bug system - origin, ok := snapshot.GetCreateMetadata(core.KeyOrigin) + origin, ok := snapshot.GetCreateMetadata(core.MetaKeyOrigin) if ok && origin != target { out <- core.NewExportNothing( b.Id(), fmt.Sprintf("issue tagged with origin: %s", origin)) diff --git a/bridge/jira/import.go b/bridge/jira/import.go index 90ba5268..2337d8bd 100644 --- a/bridge/jira/import.go +++ b/bridge/jira/import.go @@ -35,7 +35,8 @@ type jiraImporter struct { } // Init . -func (gi *jiraImporter) Init(conf core.Configuration) error { +func (gi *jiraImporter) Init(repo *cache.RepoCache, + conf core.Configuration) error { gi.conf = conf return nil } @@ -217,10 +218,10 @@ func (self *jiraImporter) ensureIssue( cleanText, nil, map[string]string{ - core.KeyOrigin: target, - keyJiraID: issue.ID, - keyJiraKey: issue.Key, - keyJiraProject: self.conf[keyProject], + core.MetaKeyOrigin: target, + keyJiraID: issue.ID, + keyJiraKey: issue.Key, + keyJiraProject: self.conf[keyProject], }) if err != nil { return nil, err diff --git a/bridge/jira/jira.go b/bridge/jira/jira.go index 933c1239..accb9e7c 100644 --- a/bridge/jira/jira.go +++ b/bridge/jira/jira.go @@ -7,10 +7,6 @@ import ( "github.com/MichaelMure/git-bug/bridge/core" ) -func init() { - core.Register(&Jira{}) -} - // Jira Main object for the bridge type Jira struct{} -- cgit From 2792c85b210e191cc51181bfeda66dee4bb9aa52 Mon Sep 17 00:00:00 2001 From: Michael MurĂ© Date: Sun, 9 Feb 2020 20:52:19 +0100 Subject: jira: use the new generalized prompts --- bridge/jira/config.go | 145 ++++++++++++++------------------------------------ 1 file changed, 40 insertions(+), 105 deletions(-) (limited to 'bridge/jira') diff --git a/bridge/jira/config.go b/bridge/jira/config.go index 406bed31..077f258a 100644 --- a/bridge/jira/config.go +++ b/bridge/jira/config.go @@ -1,13 +1,9 @@ package jira import ( - "bufio" "encoding/json" "fmt" "io/ioutil" - "os" - "strconv" - "strings" "github.com/pkg/errors" @@ -30,33 +26,14 @@ client logs in with an actual username and password, is assigned a session, and passes the session cookie with each request. JIRA Cloud and servers deployed 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. - -Which authentication mechanism should this bridge use? -[1]: SESSION -[2]: TOKEN -` -const credentialsText = ` -How would you like to store your JIRA login credentials? -[1]: 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. -[2]: git-config: Your credentials will be stored in the git config. Note that - it will contain your JIRA password in clear text. -[3]: 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. -` +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 + var err error - var url string - var project string - var credentialsFile string - var username string - var password string - var serverURL string // if params.Token != "" || params.TokenStdin { // return nil, fmt.Errorf( @@ -65,73 +42,77 @@ func (g *Jira) Configure(repo *cache.RepoCache, params core.BridgeParams) (core. // } if params.Owner != "" { - return nil, fmt.Errorf("owner doesn't make sense for jira") + fmt.Println("warning: --owner is ineffective for a Jira bridge") } - serverURL = params.URL - if url == "" { + serverURL := params.URL + if serverURL == "" { // terminal prompt - serverURL, err = prompt("JIRA server URL", "URL") + serverURL, err = input.Prompt("JIRA server URL", "URL", input.Required) if err != nil { return nil, err } } + conf[keyServer] = serverURL - project = params.Project + project := params.Project if project == "" { - project, err = prompt("JIRA project key", "project") + project, err = input.Prompt("JIRA project key", "project", input.Required) if err != nil { return nil, err } } + conf[keyProject] = project - credType, err := promptOptions(credTypeText, 1, 2) - if err != nil { - return nil, err - } - - choice, err := promptOptions(credentialsText, 1, 3) + fmt.Println(credTypeText) + credType, err := input.PromptChoice("Authentication mechanism", []string{"SESSION", "TOKEN"}) if err != nil { return nil, err } - if choice == 1 { - credentialsFile, err = prompt("Credentials file path", "path") - if err != nil { - return nil, err - } + switch credType { + case 1: + conf[keyCredentialsType] = "SESSION" + case 2: + conf[keyCredentialsType] = "TOKEN" } - username, err = prompt("JIRA username", "username") + 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 } - password, err = input.PromptPassword("Password", "password", input.Required) + username, err := input.Prompt("JIRA username", "username", input.Required) if err != nil { return nil, err } - jsonData, err := json.Marshal( - &SessionQuery{Username: username, Password: password}) + password, err := input.PromptPassword("Password", "password", input.Required) if err != nil { return nil, err } - conf[core.ConfigKeyTarget] = target - conf[keyServer] = serverURL - conf[keyProject] = project - - switch credType { - case 1: - conf[keyCredentialsType] = "SESSION" - case 2: - conf[keyCredentialsType] = "TOKEN" - } - - switch choice { + switch credTargetChoice { case 1: + // TODO: a validator to see if the path is writable ? + credentialsFile, err := input.Prompt("Credentials file path", "path", input.Required) + if err != nil { + return nil, err + } conf[keyCredentialsFile] = credentialsFile + jsonData, err := json.Marshal(&SessionQuery{Username: username, Password: password}) + if err != nil { + return nil, err + } err = ioutil.WriteFile(credentialsFile, jsonData, 0644) if err != nil { return nil, errors.Wrap( @@ -184,49 +165,3 @@ func (*Jira) ValidateConfig(conf core.Configuration) error { return nil } - -func promptOptions(description string, minVal, maxVal int) (int, error) { - fmt.Print(description) - for { - fmt.Print("Select option: ") - - line, err := bufio.NewReader(os.Stdin).ReadString('\n') - fmt.Println() - if err != nil { - return -1, err - } - - line = strings.TrimRight(line, "\n") - - index, err := strconv.Atoi(line) - if err != nil { - fmt.Println("invalid input") - continue - } - if index < minVal || index > maxVal { - fmt.Println("invalid choice") - continue - } - - return index, nil - } -} - -func prompt(description, name string) (string, error) { - for { - fmt.Printf("%s: ", description) - - line, err := bufio.NewReader(os.Stdin).ReadString('\n') - if err != nil { - return "", err - } - - line = strings.TrimRight(line, "\n") - if line == "" { - fmt.Printf("%s is empty\n", name) - continue - } - - return line, nil - } -} -- cgit From b2ca506210b3eb63c4964e5bb47203fd5341ddf4 Mon Sep 17 00:00:00 2001 From: Michael MurĂ© Date: Sun, 9 Feb 2020 21:08:47 +0100 Subject: jira: admittedly biased go styling --- bridge/jira/client.go | 175 +++++++++++++++++++++++--------------------------- bridge/jira/export.go | 79 ++++++++++------------- bridge/jira/import.go | 98 ++++++++++++---------------- 3 files changed, 154 insertions(+), 198 deletions(-) (limited to 'bridge/jira') diff --git a/bridge/jira/client.go b/bridge/jira/client.go index 6ec1c9dd..15098a3c 100644 --- a/bridge/jira/client.go +++ b/bridge/jira/client.go @@ -315,21 +315,19 @@ type ClientTransport struct { } // RoundTrip overrides the default by adding the content-type header -func (self *ClientTransport) RoundTrip( - req *http.Request) (*http.Response, error) { +func (ct *ClientTransport) RoundTrip(req *http.Request) (*http.Response, error) { req.Header.Add("Content-Type", "application/json") - if self.basicAuthString != "" { + if ct.basicAuthString != "" { req.Header.Add("Authorization", - fmt.Sprintf("Basic %s", self.basicAuthString)) + fmt.Sprintf("Basic %s", ct.basicAuthString)) } - return self.underlyingTransport.RoundTrip(req) + return ct.underlyingTransport.RoundTrip(req) } -func (self *ClientTransport) SetCredentials( - username string, token string) { +func (ct *ClientTransport) SetCredentials(username string, token string) { credString := fmt.Sprintf("%s:%s", username, token) - self.basicAuthString = base64.StdEncoding.EncodeToString([]byte(credString)) + ct.basicAuthString = base64.StdEncoding.EncodeToString([]byte(credString)) } // Client Thin wrapper around the http.Client providing jira-specific methods @@ -490,8 +488,7 @@ func (client *Client) RefreshSessionTokenRaw(credentialsJSON []byte) error { // Search Perform an issue a JQL search on the /search endpoint // https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/search -func (client *Client) Search(jql string, maxResults int, startAt int) ( - *SearchResult, error) { +func (client *Client) Search(jql string, maxResults int, startAt int) (*SearchResult, error) { url := fmt.Sprintf("%s/rest/api/2/search", client.serverURL) requestBody, err := json.Marshal(SearchRequest{ @@ -558,52 +555,51 @@ type SearchIterator struct { } // HasError returns true if the iterator is holding an error -func (self *SearchIterator) HasError() bool { - if self.Err == errDone { +func (si *SearchIterator) HasError() bool { + if si.Err == errDone { return false } - if self.Err == nil { + if si.Err == nil { return false } return true } // HasNext returns true if there is another item available in the result set -func (self *SearchIterator) HasNext() bool { - return self.Err == nil && self.itemIdx < len(self.searchResult.Issues) +func (si *SearchIterator) HasNext() bool { + return si.Err == nil && si.itemIdx < len(si.searchResult.Issues) } // Next Return the next item in the result set and advance the iterator. // Advancing the iterator may require fetching a new page. -func (self *SearchIterator) Next() *Issue { - if self.Err != nil { +func (si *SearchIterator) Next() *Issue { + if si.Err != nil { return nil } - issue := self.searchResult.Issues[self.itemIdx] - if self.itemIdx+1 < len(self.searchResult.Issues) { + issue := si.searchResult.Issues[si.itemIdx] + if si.itemIdx+1 < len(si.searchResult.Issues) { // We still have an item left in the currently cached page - self.itemIdx++ + si.itemIdx++ } else { - if self.searchResult.IsLastPage() { - self.Err = errDone + if si.searchResult.IsLastPage() { + si.Err = errDone } else { // There are still more pages to fetch, so fetch the next page and // cache it - self.searchResult, self.Err = self.client.Search( - self.jql, self.pageSize, self.searchResult.NextStartAt()) + si.searchResult, si.Err = si.client.Search( + si.jql, si.pageSize, si.searchResult.NextStartAt()) // NOTE(josh): we don't deal with the error now, we just cache it. // HasNext() will return false and the caller can check the error // afterward. - self.itemIdx = 0 + si.itemIdx = 0 } } return &issue } // IterSearch return an iterator over paginated results for a JQL search -func (client *Client) IterSearch( - jql string, pageSize int) *SearchIterator { +func (client *Client) IterSearch(jql string, pageSize int) *SearchIterator { result, err := client.Search(jql, pageSize, 0) iter := &SearchIterator{ @@ -620,9 +616,9 @@ func (client *Client) IterSearch( // GetIssue fetches an issue object via the /issue/{IssueIdOrKey} endpoint // https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue -func (client *Client) GetIssue( - idOrKey string, fields []string, expand []string, +func (client *Client) GetIssue(idOrKey string, fields []string, expand []string, properties []string) (*Issue, error) { + url := fmt.Sprintf("%s/rest/api/2/issue/%s", client.serverURL, idOrKey) request, err := http.NewRequest("GET", url, nil) @@ -678,8 +674,7 @@ func (client *Client) GetIssue( // GetComments returns a page of comments via the issue/{IssueIdOrKey}/comment // endpoint // https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getComment -func (client *Client) GetComments( - idOrKey string, maxResults int, startAt int) (*CommentPage, error) { +func (client *Client) GetComments(idOrKey string, maxResults int, startAt int) (*CommentPage, error) { url := fmt.Sprintf( "%s/rest/api/2/issue/%s/comment", client.serverURL, idOrKey) @@ -742,52 +737,51 @@ type CommentIterator struct { } // HasError returns true if the iterator is holding an error -func (self *CommentIterator) HasError() bool { - if self.Err == errDone { +func (ci *CommentIterator) HasError() bool { + if ci.Err == errDone { return false } - if self.Err == nil { + if ci.Err == nil { return false } return true } // HasNext returns true if there is another item available in the result set -func (self *CommentIterator) HasNext() bool { - return self.Err == nil && self.itemIdx < len(self.message.Comments) +func (ci *CommentIterator) HasNext() bool { + return ci.Err == nil && ci.itemIdx < len(ci.message.Comments) } // Next Return the next item in the result set and advance the iterator. // Advancing the iterator may require fetching a new page. -func (self *CommentIterator) Next() *Comment { - if self.Err != nil { +func (ci *CommentIterator) Next() *Comment { + if ci.Err != nil { return nil } - comment := self.message.Comments[self.itemIdx] - if self.itemIdx+1 < len(self.message.Comments) { + comment := ci.message.Comments[ci.itemIdx] + if ci.itemIdx+1 < len(ci.message.Comments) { // We still have an item left in the currently cached page - self.itemIdx++ + ci.itemIdx++ } else { - if self.message.IsLastPage() { - self.Err = errDone + if ci.message.IsLastPage() { + ci.Err = errDone } else { // There are still more pages to fetch, so fetch the next page and // cache it - self.message, self.Err = self.client.GetComments( - self.idOrKey, self.pageSize, self.message.NextStartAt()) + ci.message, ci.Err = ci.client.GetComments( + ci.idOrKey, ci.pageSize, ci.message.NextStartAt()) // NOTE(josh): we don't deal with the error now, we just cache it. // HasNext() will return false and the caller can check the error // afterward. - self.itemIdx = 0 + ci.itemIdx = 0 } } return &comment } // IterComments returns an iterator over paginated comments within an issue -func (client *Client) IterComments( - idOrKey string, pageSize int) *CommentIterator { +func (client *Client) IterComments(idOrKey string, pageSize int) *CommentIterator { message, err := client.GetComments(idOrKey, pageSize, 0) iter := &CommentIterator{ @@ -807,8 +801,7 @@ func (client *Client) IterComments( // /issue/{IssueIdOrKey} with (fields=*none&expand=changelog) // (for JIRA server) // https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue -func (client *Client) GetChangeLog( - idOrKey string, maxResults int, startAt int) (*ChangeLogPage, error) { +func (client *Client) GetChangeLog(idOrKey string, maxResults int, startAt int) (*ChangeLogPage, error) { url := fmt.Sprintf( "%s/rest/api/2/issue/%s/changelog", client.serverURL, idOrKey) @@ -892,52 +885,51 @@ type ChangeLogIterator struct { } // HasError returns true if the iterator is holding an error -func (self *ChangeLogIterator) HasError() bool { - if self.Err == errDone { +func (cli *ChangeLogIterator) HasError() bool { + if cli.Err == errDone { return false } - if self.Err == nil { + if cli.Err == nil { return false } return true } // HasNext returns true if there is another item available in the result set -func (self *ChangeLogIterator) HasNext() bool { - return self.Err == nil && self.itemIdx < len(self.message.Entries) +func (cli *ChangeLogIterator) HasNext() bool { + return cli.Err == nil && cli.itemIdx < len(cli.message.Entries) } // Next Return the next item in the result set and advance the iterator. // Advancing the iterator may require fetching a new page. -func (self *ChangeLogIterator) Next() *ChangeLogEntry { - if self.Err != nil { +func (cli *ChangeLogIterator) Next() *ChangeLogEntry { + if cli.Err != nil { return nil } - item := self.message.Entries[self.itemIdx] - if self.itemIdx+1 < len(self.message.Entries) { + item := cli.message.Entries[cli.itemIdx] + if cli.itemIdx+1 < len(cli.message.Entries) { // We still have an item left in the currently cached page - self.itemIdx++ + cli.itemIdx++ } else { - if self.message.IsLastPage() { - self.Err = errDone + if cli.message.IsLastPage() { + cli.Err = errDone } else { // There are still more pages to fetch, so fetch the next page and // cache it - self.message, self.Err = self.client.GetChangeLog( - self.idOrKey, self.pageSize, self.message.NextStartAt()) + cli.message, cli.Err = cli.client.GetChangeLog( + cli.idOrKey, cli.pageSize, cli.message.NextStartAt()) // NOTE(josh): we don't deal with the error now, we just cache it. // HasNext() will return false and the caller can check the error // afterward. - self.itemIdx = 0 + cli.itemIdx = 0 } } return &item } // IterChangeLog returns an iterator over entries in the changelog for an issue -func (client *Client) IterChangeLog( - idOrKey string, pageSize int) *ChangeLogIterator { +func (client *Client) IterChangeLog(idOrKey string, pageSize int) *ChangeLogIterator { message, err := client.GetChangeLog(idOrKey, pageSize, 0) iter := &ChangeLogIterator{ @@ -994,9 +986,8 @@ func (client *Client) GetProject(projectIDOrKey string) (*Project, error) { } // CreateIssue creates a new JIRA issue and returns it -func (client *Client) CreateIssue( - projectIDOrKey, title, body string, extra map[string]interface{}) ( - *IssueCreateResult, error) { +func (client *Client) CreateIssue(projectIDOrKey, title, body string, + extra map[string]interface{}) (*IssueCreateResult, error) { url := fmt.Sprintf("%s/rest/api/2/issue", client.serverURL) @@ -1063,8 +1054,7 @@ func (client *Client) CreateIssue( } // UpdateIssueTitle changes the "summary" field of a JIRA issue -func (client *Client) UpdateIssueTitle( - issueKeyOrID, title string) (time.Time, error) { +func (client *Client) UpdateIssueTitle(issueKeyOrID, title string) (time.Time, error) { url := fmt.Sprintf( "%s/rest/api/2/issue/%s", client.serverURL, issueKeyOrID) @@ -1078,9 +1068,9 @@ func (client *Client) UpdateIssueTitle( } var buffer bytes.Buffer - fmt.Fprintf(&buffer, `{"update":{"summary":[`) - fmt.Fprintf(&buffer, `{"set":%s}`, data) - fmt.Fprintf(&buffer, `]}}`) + _, _ = fmt.Fprintf(&buffer, `{"update":{"summary":[`) + _, _ = fmt.Fprintf(&buffer, `{"set":%s}`, data) + _, _ = fmt.Fprintf(&buffer, `]}}`) data = buffer.Bytes() request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data)) @@ -1119,8 +1109,7 @@ func (client *Client) UpdateIssueTitle( } // UpdateIssueBody changes the "description" field of a JIRA issue -func (client *Client) UpdateIssueBody( - issueKeyOrID, body string) (time.Time, error) { +func (client *Client) UpdateIssueBody(issueKeyOrID, body string) (time.Time, error) { url := fmt.Sprintf( "%s/rest/api/2/issue/%s", client.serverURL, issueKeyOrID) @@ -1133,9 +1122,9 @@ func (client *Client) UpdateIssueBody( } var buffer bytes.Buffer - fmt.Fprintf(&buffer, `{"update":{"description":[`) - fmt.Fprintf(&buffer, `{"set":%s}`, data) - fmt.Fprintf(&buffer, `]}}`) + _, _ = fmt.Fprintf(&buffer, `{"update":{"description":[`) + _, _ = fmt.Fprintf(&buffer, `{"set":%s}`, data) + _, _ = fmt.Fprintf(&buffer, `]}}`) data = buffer.Bytes() request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data)) @@ -1279,8 +1268,7 @@ func (client *Client) UpdateComment(issueKeyOrID, commentID, body string) ( } // UpdateLabels changes labels for an issue -func (client *Client) UpdateLabels( - issueKeyOrID string, added, removed []bug.Label) (time.Time, error) { +func (client *Client) UpdateLabels(issueKeyOrID string, added, removed []bug.Label) (time.Time, error) { url := fmt.Sprintf( "%s/rest/api/2/issue/%s/", client.serverURL, issueKeyOrID) var responseTime time.Time @@ -1288,23 +1276,23 @@ func (client *Client) UpdateLabels( // NOTE(josh): Since updates are a list of heterogeneous objects let's just // manually build the JSON text var buffer bytes.Buffer - fmt.Fprintf(&buffer, `{"update":{"labels":[`) + _, _ = fmt.Fprintf(&buffer, `{"update":{"labels":[`) first := true for _, label := range added { if !first { - fmt.Fprintf(&buffer, ",") + _, _ = fmt.Fprintf(&buffer, ",") } - fmt.Fprintf(&buffer, `{"add":"%s"}`, label) + _, _ = fmt.Fprintf(&buffer, `{"add":"%s"}`, label) first = false } for _, label := range removed { if !first { - fmt.Fprintf(&buffer, ",") + _, _ = fmt.Fprintf(&buffer, ",") } - fmt.Fprintf(&buffer, `{"remove":"%s"}`, label) + _, _ = fmt.Fprintf(&buffer, `{"remove":"%s"}`, label) first = false } - fmt.Fprintf(&buffer, "]}}") + _, _ = fmt.Fprintf(&buffer, "]}}") data := buffer.Bytes() request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data)) @@ -1349,8 +1337,7 @@ func (client *Client) UpdateLabels( } // GetTransitions returns a list of available transitions for an issue -func (client *Client) GetTransitions(issueKeyOrID string) ( - *TransitionList, error) { +func (client *Client) GetTransitions(issueKeyOrID string) (*TransitionList, error) { url := fmt.Sprintf( "%s/rest/api/2/issue/%s/transitions", client.serverURL, issueKeyOrID) @@ -1393,8 +1380,7 @@ func (client *Client) GetTransitions(issueKeyOrID string) ( return &message, nil } -func getTransitionTo( - tlist *TransitionList, desiredStateNameOrID string) *Transition { +func getTransitionTo(tlist *TransitionList, desiredStateNameOrID string) *Transition { for _, transition := range tlist.Transitions { if transition.To.ID == desiredStateNameOrID { return &transition @@ -1406,8 +1392,7 @@ func getTransitionTo( } // DoTransition changes the "status" of an issue -func (client *Client) DoTransition( - issueKeyOrID string, transitionID string) (time.Time, error) { +func (client *Client) DoTransition(issueKeyOrID string, transitionID string) (time.Time, error) { url := fmt.Sprintf( "%s/rest/api/2/issue/%s/transitions", client.serverURL, issueKeyOrID) var responseTime time.Time @@ -1417,7 +1402,7 @@ func (client *Client) DoTransition( // *compute* the necessary transitions and prompt for missing metatdata... // but that is complex var buffer bytes.Buffer - fmt.Fprintf(&buffer, + _, _ = fmt.Fprintf(&buffer, `{"transition":{"id":"%s"}, "resolution": {"name": "Done"}}`, transitionID) request, err := http.NewRequest("POST", url, bytes.NewBuffer(buffer.Bytes())) diff --git a/bridge/jira/export.go b/bridge/jira/export.go index f2b9d507..f329e490 100644 --- a/bridge/jira/export.go +++ b/bridge/jira/export.go @@ -43,47 +43,42 @@ type jiraExporter struct { } // Init . -func (self *jiraExporter) Init(repo *cache.RepoCache, - conf core.Configuration) error { - self.conf = conf - self.identityClient = make(map[entity.Id]*Client) - self.cachedOperationIDs = make(map[entity.Id]string) - self.cachedLabels = make(map[string]string) +func (je *jiraExporter) Init(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 (self *jiraExporter) getIdentityClient( - ctx context.Context, id entity.Id) (*Client, error) { - client, ok := self.identityClient[id] +func (je *jiraExporter) getIdentityClient(ctx context.Context, id entity.Id) (*Client, error) { + client, ok := je.identityClient[id] if ok { return client, nil } - client = NewClient(self.conf[keyServer], ctx) + client = NewClient(je.conf[keyServer], ctx) // 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 != self.userIdentity { + if id != je.userIdentity { return nil, ErrMissingCredentials } - err := client.Login(self.conf) + err := client.Login(je.conf) if err != nil { return nil, err } - self.identityClient[id] = client + je.identityClient[id] = client return client, nil } // ExportAll export all event made by the current user to Jira -func (self *jiraExporter) ExportAll( - ctx context.Context, repo *cache.RepoCache, since time.Time) ( - <-chan core.ExportResult, error) { - +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() @@ -93,13 +88,13 @@ func (self *jiraExporter) ExportAll( // NOTE: this is currently only need to mock the credentials database in // getIdentityClient. - self.userIdentity = user.Id() - client, err := self.getIdentityClient(ctx, user.Id()) + je.userIdentity = user.Id() + client, err := je.getIdentityClient(ctx, user.Id()) if err != nil { return nil, err } - self.project, err = client.GetProject(self.conf[keyProject]) + je.project, err = client.GetProject(je.conf[keyProject]) if err != nil { return nil, err } @@ -108,7 +103,7 @@ func (self *jiraExporter) ExportAll( defer close(out) var allIdentitiesIds []entity.Id - for id := range self.identityClient { + for id := range je.identityClient { allIdentitiesIds = append(allIdentitiesIds, id) } @@ -139,7 +134,7 @@ func (self *jiraExporter) ExportAll( if snapshot.HasAnyActor(allIdentitiesIds...) { // try to export the bug and it associated events - err := self.exportBug(ctx, b, since, out) + err := je.exportBug(ctx, b, since, out) if err != nil { out <- core.NewExportError(errors.Wrap(err, "can't export bug"), id) return @@ -155,9 +150,7 @@ func (self *jiraExporter) ExportAll( } // exportBug publish bugs and related events -func (self *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, since time.Time, out chan<- core.ExportResult) error { snapshot := b.Snapshot() var bugJiraID string @@ -182,7 +175,7 @@ func (self *jiraExporter) exportBug( // skip bug if it is a jira bug but is associated with another project // (one bridge per JIRA project) project, ok := snapshot.GetCreateMetadata(keyJiraProject) - if ok && !stringInSlice(project, []string{self.project.ID, self.project.Key}) { + if ok && !stringInSlice(project, []string{je.project.ID, je.project.Key}) { out <- core.NewExportNothing( b.Id(), fmt.Sprintf("issue tagged with project: %s", project)) return nil @@ -195,7 +188,7 @@ func (self *jiraExporter) exportBug( bugJiraID = jiraID } else { // check that we have credentials for operation author - client, err := self.getIdentityClient(ctx, author.Id()) + client, err := je.getIdentityClient(ctx, 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 @@ -208,7 +201,7 @@ func (self *jiraExporter) exportBug( // Load any custom fields required to create an issue from the git // config file. fields := make(map[string]interface{}) - defaultFields, hasConf := self.conf[keyCreateDefaults] + defaultFields, hasConf := je.conf[keyCreateDefaults] if hasConf { err = json.Unmarshal([]byte(defaultFields), &fields) if err != nil { @@ -222,7 +215,7 @@ func (self *jiraExporter) exportBug( "id": "10001", } } - bugIDField, hasConf := self.conf[keyCreateGitBug] + bugIDField, hasConf := je.conf[keyCreateGitBug] if hasConf { // If the git configuration also indicates it, we can assign the git-bug // id to a custom field to assist in integrations @@ -231,7 +224,7 @@ func (self *jiraExporter) exportBug( // create bug result, err := client.CreateIssue( - self.project.ID, createOp.Title, createOp.Message, fields) + je.project.ID, createOp.Title, createOp.Message, fields) if err != nil { err := errors.Wrap(err, "exporting jira issue") out <- core.NewExportError(err, b.Id()) @@ -242,7 +235,7 @@ func (self *jiraExporter) exportBug( out <- core.NewExportBug(b.Id()) // mark bug creation operation as exported err = markOperationAsExported( - b, createOp.Id(), id, self.project.Key, time.Time{}) + b, createOp.Id(), id, je.project.Key, time.Time{}) if err != nil { err := errors.Wrap(err, "marking operation as exported") out <- core.NewExportError(err, b.Id()) @@ -262,10 +255,10 @@ func (self *jiraExporter) exportBug( } // cache operation jira id - self.cachedOperationIDs[createOp.Id()] = bugJiraID + je.cachedOperationIDs[createOp.Id()] = bugJiraID // lookup the mapping from git-bug "status" to JIRA "status" id - statusMap, err := getStatusMap(self.conf) + statusMap, err := getStatusMap(je.conf) if err != nil { return err } @@ -280,12 +273,12 @@ func (self *jiraExporter) exportBug( // cache the ID of already exported or imported issues and events from // Jira if id, ok := op.GetMetadata(keyJiraID); ok { - self.cachedOperationIDs[op.Id()] = id + je.cachedOperationIDs[op.Id()] = id continue } opAuthor := op.GetAuthor() - client, err := self.getIdentityClient(ctx, opAuthor.Id()) + client, err := je.getIdentityClient(ctx, opAuthor.Id()) if err != nil { out <- core.NewExportError( fmt.Errorf("missing operation author credentials for user %.8s", @@ -307,7 +300,7 @@ func (self *jiraExporter) exportBug( out <- core.NewExportComment(op.Id()) // cache comment id - self.cachedOperationIDs[op.Id()] = id + je.cachedOperationIDs[op.Id()] = id case *bug.EditCommentOperation: if opr.Target == createOp.Id() { @@ -325,7 +318,7 @@ func (self *jiraExporter) exportBug( // Otherwise it's an edit to an actual comment. A comment cannot be // edited before it was created, so it must be the case that we have // already observed and cached the AddCommentOperation. - commentID, ok := self.cachedOperationIDs[opr.Target] + commentID, ok := je.cachedOperationIDs[opr.Target] if !ok { // Since an edit has to come after the creation, we expect we would // have cached the creation id. @@ -392,7 +385,7 @@ func (self *jiraExporter) exportBug( // mark operation as exported err = markOperationAsExported( - b, op.Id(), id, self.project.Key, exportTime) + b, op.Id(), id, je.project.Key, exportTime) if err != nil { err := errors.Wrap(err, "marking operation as exported") out <- core.NewExportError(err, b.Id()) @@ -412,10 +405,7 @@ func (self *jiraExporter) exportBug( return nil } -func markOperationAsExported( - b *cache.BugCache, target entity.Id, jiraID, jiraProject string, - exportTime time.Time) error { - +func markOperationAsExported(b *cache.BugCache, target entity.Id, jiraID, jiraProject string, exportTime time.Time) error { newMetadata := map[string]string{ keyJiraID: jiraID, keyJiraProject: jiraProject, @@ -431,10 +421,7 @@ func markOperationAsExported( // UpdateIssueStatus attempts to change the "status" field by finding a // transition which achieves the desired state and then performing that // transition -func UpdateIssueStatus( - client *Client, issueKeyOrID string, desiredStateNameOrID string) ( - time.Time, error) { - +func UpdateIssueStatus(client *Client, issueKeyOrID string, desiredStateNameOrID string) (time.Time, error) { var responseTime time.Time tlist, err := client.GetTransitions(issueKeyOrID) diff --git a/bridge/jira/import.go b/bridge/jira/import.go index bc1bf428..6a755a36 100644 --- a/bridge/jira/import.go +++ b/bridge/jira/import.go @@ -35,31 +35,27 @@ type jiraImporter struct { } // Init . -func (gi *jiraImporter) Init(repo *cache.RepoCache, - conf core.Configuration) error { - gi.conf = conf +func (ji *jiraImporter) Init(repo *cache.RepoCache, conf core.Configuration) error { + ji.conf = conf return nil } // ImportAll iterate over all the configured repository issues and ensure the // creation of the missing issues / timeline items / edits / label events ... -func (self *jiraImporter) ImportAll( - ctx context.Context, repo *cache.RepoCache, since time.Time) ( - <-chan core.ImportResult, error) { - +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 := self.conf[keyServer] - project := self.conf[keyProject] + 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 out := make(chan core.ImportResult) - self.out = out + ji.out = out go func() { - defer close(self.out) + defer close(ji.out) client := NewClient(serverURL, ctx) - err := client.Login(self.conf) + err := client.Login(ji.conf) if err != nil { out <- core.NewImportError(err, "") return @@ -79,7 +75,7 @@ func (self *jiraImporter) ImportAll( for searchIter = client.IterSearch(jql, defaultPageSize); searchIter.HasNext(); { issue := searchIter.Next() - bug, err := self.ensureIssue(repo, *issue) + b, err := ji.ensureIssue(repo, *issue) if err != nil { err := fmt.Errorf("issue creation: %v", err) out <- core.NewImportError(err, "") @@ -90,7 +86,7 @@ func (self *jiraImporter) ImportAll( for commentIter = client.IterComments(issue.ID, defaultPageSize); commentIter.HasNext(); { comment := commentIter.Next() - err := self.ensureComment(repo, bug, *comment) + err := ji.ensureComment(repo, b, *comment) if err != nil { out <- core.NewImportError(err, "") } @@ -99,7 +95,7 @@ func (self *jiraImporter) ImportAll( out <- core.NewImportError(commentIter.Err, "") } - snapshot := bug.Snapshot() + snapshot := b.Snapshot() opIdx := 0 var changelogIter *ChangeLogIterator @@ -127,10 +123,9 @@ func (self *jiraImporter) ImportAll( } } if opIdx < len(snapshot.Operations) { - err = self.ensureChange( - repo, bug, *changelogEntry, snapshot.Operations[opIdx]) + err = ji.ensureChange(repo, b, *changelogEntry, snapshot.Operations[opIdx]) } else { - err = self.ensureChange(repo, bug, *changelogEntry, nil) + err = ji.ensureChange(repo, b, *changelogEntry, nil) } if err != nil { out <- core.NewImportError(err, "") @@ -141,9 +136,9 @@ func (self *jiraImporter) ImportAll( out <- core.NewImportError(changelogIter.Err, "") } - if !bug.NeedCommit() { - out <- core.NewImportNothing(bug.Id(), "no imported operation") - } else if err := bug.Commit(); err != nil { + 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 @@ -158,9 +153,7 @@ func (self *jiraImporter) ImportAll( } // Create a bug.Person from a JIRA user -func (self *jiraImporter) ensurePerson( - repo *cache.RepoCache, user User) (*cache.IdentityCache, error) { - +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)) @@ -184,14 +177,13 @@ func (self *jiraImporter) ensurePerson( return nil, err } - self.out <- core.NewImportIdentity(i.Id()) + ji.out <- core.NewImportIdentity(i.Id()) return i, nil } // Create a bug.Bug based from a JIRA issue -func (self *jiraImporter) ensureIssue( - repo *cache.RepoCache, issue Issue) (*cache.BugCache, error) { - author, err := self.ensurePerson(repo, issue.Fields.Creator) +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 } @@ -220,13 +212,13 @@ func (self *jiraImporter) ensureIssue( core.MetaKeyOrigin: target, keyJiraID: issue.ID, keyJiraKey: issue.Key, - keyJiraProject: self.conf[keyProject], + keyJiraProject: ji.conf[keyProject], }) if err != nil { return nil, err } - self.out <- core.NewImportBug(b.Id()) + ji.out <- core.NewImportBug(b.Id()) } return b, nil @@ -238,10 +230,9 @@ func getTimeDerivedID(jiraID string, timestamp MyTime) string { } // Create a bug.Comment from a JIRA comment -func (self *jiraImporter) ensureComment(repo *cache.RepoCache, - b *cache.BugCache, item Comment) error { +func (ji *jiraImporter) ensureComment(repo *cache.RepoCache, b *cache.BugCache, item Comment) error { // ensure person - author, err := self.ensurePerson(repo, item.Author) + author, err := ji.ensurePerson(repo, item.Author) if err != nil { return err } @@ -279,7 +270,7 @@ func (self *jiraImporter) ensureComment(repo *cache.RepoCache, return err } - self.out <- core.NewImportComment(op.Id()) + ji.out <- core.NewImportComment(op.Id()) targetOpID = op.Id() } @@ -293,8 +284,7 @@ func (self *jiraImporter) ensureComment(repo *cache.RepoCache, // 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(keyJiraID, derivedID) if err == nil { // Already imported this edition return nil @@ -305,7 +295,7 @@ func (self *jiraImporter) ensureComment(repo *cache.RepoCache, } // ensure editor identity - editor, err := self.ensurePerson(repo, item.UpdateAuthor) + editor, err := ji.ensurePerson(repo, item.UpdateAuthor) if err != nil { return err } @@ -329,7 +319,7 @@ func (self *jiraImporter) ensureComment(repo *cache.RepoCache, return err } - self.out <- core.NewImportCommentEdition(op.Id()) + ji.out <- core.NewImportCommentEdition(op.Id()) return nil } @@ -363,9 +353,7 @@ func labelSetsMatch(jiraSet []string, gitbugSet []bug.Label) bool { // Create a bug.Operation (or a series of operations) from a JIRA changelog // entry -func (self *jiraImporter) ensureChange( - repo *cache.RepoCache, b *cache.BugCache, entry ChangeLogEntry, - potentialOp bug.Operation) error { +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 @@ -383,7 +371,7 @@ func (self *jiraImporter) ensureChange( // 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 := self.ensurePerson(repo, entry.Author) + author, err := ji.ensurePerson(repo, entry.Author) if err != nil { return err } @@ -392,7 +380,7 @@ func (self *jiraImporter) ensureChange( return fmt.Errorf("Received changelog entry with no item! (%s)", entry.ID) } - statusMap, err := getStatusMapReverse(self.conf) + statusMap, err := getStatusMapReverse(ji.conf) if err != nil { return err } @@ -407,13 +395,10 @@ func (self *jiraImporter) ensureChange( case "labels": fromLabels := removeEmpty(strings.Split(item.FromString, " ")) toLabels := removeEmpty(strings.Split(item.ToString, " ")) - removedLabels, addedLabels, _ := setSymmetricDifference( - fromLabels, toLabels) + removedLabels, addedLabels, _ := setSymmetricDifference(fromLabels, toLabels) opr, isRightType := potentialOp.(*bug.LabelChangeOperation) - if isRightType && - labelSetsMatch(addedLabels, opr.Added) && - labelSetsMatch(removedLabels, opr.Removed) { + if isRightType && labelSetsMatch(addedLabels, opr.Added) && labelSetsMatch(removedLabels, opr.Removed) { _, err := b.SetMetadata(opr.Id(), map[string]string{ keyJiraOperationID: entry.ID, }) @@ -484,8 +469,7 @@ func (self *jiraImporter) ensureChange( case "labels": fromLabels := removeEmpty(strings.Split(item.FromString, " ")) toLabels := removeEmpty(strings.Split(item.ToString, " ")) - removedLabels, addedLabels, _ := setSymmetricDifference( - fromLabels, toLabels) + removedLabels, addedLabels, _ := setSymmetricDifference(fromLabels, toLabels) op, err := b.ForceChangeLabelsRaw( author, @@ -501,7 +485,7 @@ func (self *jiraImporter) ensureChange( return err } - self.out <- core.NewImportLabelChange(op.Id()) + ji.out <- core.NewImportLabelChange(op.Id()) case "status": statusStr, hasMap := statusMap[item.To] @@ -519,7 +503,7 @@ func (self *jiraImporter) ensureChange( if err != nil { return err } - self.out <- core.NewImportStatusChange(op.Id()) + ji.out <- core.NewImportStatusChange(op.Id()) case bug.ClosedStatus.String(): op, err := b.CloseRaw( @@ -533,10 +517,10 @@ func (self *jiraImporter) ensureChange( if err != nil { return err } - self.out <- core.NewImportStatusChange(op.Id()) + ji.out <- core.NewImportStatusChange(op.Id()) } } else { - self.out <- core.NewImportError( + ji.out <- core.NewImportError( fmt.Errorf( "No git-bug status mapped for jira status %s (%s)", item.ToString, item.To), "") @@ -558,7 +542,7 @@ func (self *jiraImporter) ensureChange( return err } - self.out <- core.NewImportTitleEdition(op.Id()) + ji.out <- core.NewImportTitleEdition(op.Id()) case "description": // NOTE(josh): JIRA calls it "description", which sounds more like the @@ -576,10 +560,10 @@ func (self *jiraImporter) ensureChange( return err } - self.out <- core.NewImportCommentEdition(op.Id()) + ji.out <- core.NewImportCommentEdition(op.Id()) default: - self.out <- core.NewImportWarning( + ji.out <- core.NewImportWarning( fmt.Errorf( "Unhandled changelog event %s", item.Field), "") } -- cgit From 5c230cb81e399f12cc7a1c1688b73f549b12a5f0 Mon Sep 17 00:00:00 2001 From: Michael MurĂ© Date: Sat, 15 Feb 2020 16:01:15 +0100 Subject: jira: rework to use the credential system + adapt to refactors --- bridge/jira/client.go | 114 ++++++++++++---------------------- bridge/jira/config.go | 166 +++++++++++++++++++++++++++----------------------- bridge/jira/export.go | 152 +++++++++++++++++++++++++++------------------ bridge/jira/import.go | 133 ++++++++++++++++++++++++---------------- bridge/jira/jira.go | 59 ++++++++++++++---- 5 files changed, 346 insertions(+), 278 deletions(-) (limited to 'bridge/jira') 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 { -- cgit From 01b0a931f737080c46373bee08bb4a21e932140a Mon Sep 17 00:00:00 2001 From: Michael MurĂ© Date: Sat, 15 Feb 2020 16:12:21 +0100 Subject: jira: fix a nil context --- bridge/jira/config.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'bridge/jira') diff --git a/bridge/jira/config.go b/bridge/jira/config.go index c4743448..db52b83d 100644 --- a/bridge/jira/config.go +++ b/bridge/jira/config.go @@ -1,6 +1,7 @@ package jira import ( + "context" "fmt" "github.com/MichaelMure/git-bug/bridge/core" @@ -104,7 +105,7 @@ func (j *Jira) Configure(repo *cache.RepoCache, params core.BridgeParams) (core. } fmt.Printf("Attempting to login with credentials...\n") - client, err := buildClient(nil, baseURL, credType, cred) + client, err := buildClient(context.TODO(), baseURL, credType, cred) if err != nil { return nil, err } -- cgit From bbd31adbfff9d3c57eed7be68250b56a4386d02b Mon Sep 17 00:00:00 2001 From: Josh Bialkowski Date: Mon, 17 Feb 2020 12:43:42 -0800 Subject: Fix jira bridge config didn't save credentials or store URL metadata --- bridge/jira/config.go | 10 ++++++++++ 1 file changed, 10 insertions(+) (limited to 'bridge/jira') diff --git a/bridge/jira/config.go b/bridge/jira/config.go index db52b83d..79fd8507 100644 --- a/bridge/jira/config.go +++ b/bridge/jira/config.go @@ -120,6 +120,14 @@ func (j *Jira) Configure(repo *cache.RepoCache, params core.BridgeParams) (core. project, baseURL, login) } + // don't forget to store the now known valid token + if !auth.IdExist(repo, cred.ID()) { + err = auth.Store(repo, cred) + if err != nil { + return nil, err + } + } + err = core.FinishConfig(repo, metaKeyJiraLogin, login) if err != nil { return nil, err @@ -171,10 +179,12 @@ func promptCredOptions(repo repository.RepoConfig, login, baseUrl string) (auth. } lp := auth.NewLoginPassword(target, login, password) lp.SetMetadata(auth.MetaKeyLogin, login) + lp.SetMetadata(auth.MetaKeyBaseURL, baseUrl) return lp, nil case index == 1: l := auth.NewLogin(target, login) l.SetMetadata(auth.MetaKeyLogin, login) + l.SetMetadata(auth.MetaKeyBaseURL, baseUrl) return l, nil default: panic("missed case") -- cgit From d349137ea5dffa0ae01fbf1a0fba75e11778aabb Mon Sep 17 00:00:00 2001 From: Josh Bialkowski Date: Mon, 17 Feb 2020 13:13:25 -0800 Subject: fix usage of newIdentityRaw --- bridge/jira/import.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bridge/jira') diff --git a/bridge/jira/import.go b/bridge/jira/import.go index bfe83f4d..61870dd1 100644 --- a/bridge/jira/import.go +++ b/bridge/jira/import.go @@ -192,7 +192,7 @@ func (ji *jiraImporter) ensurePerson(repo *cache.RepoCache, user User) (*cache.I i, err = repo.NewIdentityRaw( user.DisplayName, user.EmailAddress, - user.Key, + "", map[string]string{ metaKeyJiraUser: string(user.Key), }, -- cgit From 4827d3f9ab77b01de7e34a9c7b4878fc94b942e5 Mon Sep 17 00:00:00 2001 From: Josh Bialkowski Date: Mon, 17 Feb 2020 13:37:57 -0800 Subject: metaKeyJiraOperationId -> metaKeyJiraDerivedId --- bridge/jira/import.go | 32 ++++++++++++++++---------------- bridge/jira/jira.go | 14 +++++++------- 2 files changed, 23 insertions(+), 23 deletions(-) (limited to 'bridge/jira') diff --git a/bridge/jira/import.go b/bridge/jira/import.go index 61870dd1..f35f114f 100644 --- a/bridge/jira/import.go +++ b/bridge/jira/import.go @@ -383,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(metaKeyJiraOperationId, entry.ID) + _, err := b.ResolveOperationWithMetadata(metaKeyJiraDerivedId, entry.ID) if err == nil { return nil } else if err != cache.ErrNoMatchingOp { @@ -425,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{ - metaKeyJiraOperationId: entry.ID, + metaKeyJiraDerivedId: entry.ID, }) if err != nil { return err @@ -437,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{ - metaKeyJiraOperationId: entry.ID, + metaKeyJiraDerivedId: entry.ID, }) if err != nil { return err @@ -451,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{ - metaKeyJiraOperationId: entry.ID, + metaKeyJiraDerivedId: entry.ID, }) if err != nil { return err @@ -467,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{ - metaKeyJiraOperationId: entry.ID, + metaKeyJiraDerivedId: entry.ID, }) if err != nil { return err @@ -482,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(metaKeyJiraOperationId, derivedID) + _, err := b.ResolveOperationWithMetadata(metaKeyJiraDerivedId, derivedID) if err == nil { continue } @@ -502,8 +502,8 @@ func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, e addedLabels, removedLabels, map[string]string{ - metaKeyJiraId: entry.ID, - metaKeyJiraOperationId: derivedID, + metaKeyJiraId: entry.ID, + metaKeyJiraDerivedId: derivedID, }, ) if err != nil { @@ -521,8 +521,8 @@ func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, e author, entry.Created.Unix(), map[string]string{ - metaKeyJiraId: entry.ID, - metaKeyJiraOperationId: derivedID, + metaKeyJiraId: entry.ID, + metaKeyJiraDerivedId: derivedID, }, ) if err != nil { @@ -535,8 +535,8 @@ func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, e author, entry.Created.Unix(), map[string]string{ - metaKeyJiraId: entry.ID, - metaKeyJiraOperationId: derivedID, + metaKeyJiraId: entry.ID, + metaKeyJiraDerivedId: derivedID, }, ) if err != nil { @@ -559,8 +559,8 @@ func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, e entry.Created.Unix(), string(item.ToString), map[string]string{ - metaKeyJiraId: entry.ID, - metaKeyJiraOperationId: derivedID, + metaKeyJiraId: entry.ID, + metaKeyJiraDerivedId: derivedID, }, ) if err != nil { @@ -577,8 +577,8 @@ func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, e entry.Created.Unix(), string(item.ToString), map[string]string{ - metaKeyJiraId: entry.ID, - metaKeyJiraOperationId: derivedID, + metaKeyJiraId: entry.ID, + metaKeyJiraDerivedId: derivedID, }, ) if err != nil { diff --git a/bridge/jira/jira.go b/bridge/jira/jira.go index 0ba27df3..b891ee3d 100644 --- a/bridge/jira/jira.go +++ b/bridge/jira/jira.go @@ -15,13 +15,13 @@ import ( const ( target = "jira" - metaKeyJiraId = "jira-id" - metaKeyJiraOperationId = "jira-derived-id" - metaKeyJiraKey = "jira-key" - metaKeyJiraUser = "jira-user" - metaKeyJiraProject = "jira-project" - metaKeyJiraExportTime = "jira-export-time" - metaKeyJiraLogin = "jira-login" + metaKeyJiraId = "jira-id" + metaKeyJiraDerivedId = "jira-derived-id" + metaKeyJiraKey = "jira-key" + metaKeyJiraUser = "jira-user" + metaKeyJiraProject = "jira-project" + metaKeyJiraExportTime = "jira-export-time" + metaKeyJiraLogin = "jira-login" confKeyBaseUrl = "base-url" confKeyProject = "project" -- cgit