diff options
Diffstat (limited to 'bridge/jira')
-rw-r--r-- | bridge/jira/client.go | 1453 | ||||
-rw-r--r-- | bridge/jira/config.go | 239 | ||||
-rw-r--r-- | bridge/jira/export.go | 453 | ||||
-rw-r--r-- | bridge/jira/import.go | 598 | ||||
-rw-r--r-- | bridge/jira/jira.go | 87 |
5 files changed, 2830 insertions, 0 deletions
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 +} |