diff options
-rw-r--r-- | bridge/bridges.go | 2 | ||||
-rw-r--r-- | bridge/core/export.go | 6 | ||||
-rw-r--r-- | bridge/core/import.go | 6 | ||||
-rw-r--r-- | bridge/jira/client.go | 1499 | ||||
-rw-r--r-- | bridge/jira/config.go | 167 | ||||
-rw-r--r-- | bridge/jira/export.go | 443 | ||||
-rw-r--r-- | bridge/jira/import.go | 630 | ||||
-rw-r--r-- | bridge/jira/jira.go | 108 | ||||
-rw-r--r-- | bug/op_edit_comment.go | 12 | ||||
-rw-r--r-- | cache/bug_cache.go | 22 | ||||
-rw-r--r-- | doc/jira_bridge.md | 377 |
11 files changed, 3266 insertions, 6 deletions
diff --git a/bridge/bridges.go b/bridge/bridges.go index 5d3066f9..d74a58fa 100644 --- a/bridge/bridges.go +++ b/bridge/bridges.go @@ -5,6 +5,7 @@ import ( "github.com/MichaelMure/git-bug/bridge/core" "github.com/MichaelMure/git-bug/bridge/github" "github.com/MichaelMure/git-bug/bridge/gitlab" + "github.com/MichaelMure/git-bug/bridge/jira" "github.com/MichaelMure/git-bug/bridge/launchpad" "github.com/MichaelMure/git-bug/cache" "github.com/MichaelMure/git-bug/repository" @@ -14,6 +15,7 @@ func init() { core.Register(&github.Github{}) core.Register(&gitlab.Gitlab{}) core.Register(&launchpad.Launchpad{}) + core.Register(&jira.Jira{}) } // Targets return all known bridge implementation target diff --git a/bridge/core/export.go b/bridge/core/export.go index 4397a527..fa531c5f 100644 --- a/bridge/core/export.go +++ b/bridge/core/export.go @@ -27,12 +27,12 @@ const ( // Nothing changed on the bug ExportEventNothing - // Error happened during export - ExportEventError - // Something wrong happened during export that is worth notifying to the user // but not severe enough to consider the export a failure. ExportEventWarning + + // Error happened during export + ExportEventError ) // ExportResult is an event that is emitted during the export process, to diff --git a/bridge/core/import.go b/bridge/core/import.go index f0a6f0c8..0b0b4c68 100644 --- a/bridge/core/import.go +++ b/bridge/core/import.go @@ -30,12 +30,12 @@ const ( // Identity has been created ImportEventIdentity - // Error happened during import - ImportEventError - // Something wrong happened during import that is worth notifying to the user // but not severe enough to consider the import a failure. ImportEventWarning + + // Error happened during import + ImportEventError ) // ImportResult is an event that is emitted during the import process, to diff --git a/bridge/jira/client.go b/bridge/jira/client.go new file mode 100644 index 00000000..15098a3c --- /dev/null +++ b/bridge/jira/client.go @@ -0,0 +1,1499 @@ +package jira + +import ( + "bytes" + "context" + "encoding/base64" + "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/MichaelMure/git-bug/input" + "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"` + 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 +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 { + // 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 +} + +// 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 + basicAuthString string +} + +// RoundTrip overrides the default by adding the content-type header +func (ct *ClientTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Add("Content-Type", "application/json") + if ct.basicAuthString != "" { + req.Header.Add("Authorization", + fmt.Sprintf("Basic %s", ct.basicAuthString)) + } + + return ct.underlyingTransport.RoundTrip(req) +} + +func (ct *ClientTransport) SetCredentials(username string, token string) { + credString := fmt.Sprintf("%s:%s", username, token) + ct.basicAuthString = base64.StdEncoding.EncodeToString([]byte(credString)) +} + +// 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 { + 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 + } + } + + switch credType { + case "SESSION": + return client.RefreshSessionToken(username, password) + case "TOKEN": + return client.SetTokenCredentials(username, password) + } + 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 +func (client *Client) RefreshSessionToken(username, password string) error { + params := SessionQuery{ + Username: username, + Password: password, + } + + data, err := json.Marshal(params) + if err != nil { + return err + } + + 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 +} + +// 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)) + 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 (si *SearchIterator) HasError() bool { + if si.Err == errDone { + return false + } + if si.Err == nil { + return false + } + return true +} + +// HasNext returns true if there is another item available in the result set +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 (si *SearchIterator) Next() *Issue { + if si.Err != nil { + return nil + } + + 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 + si.itemIdx++ + } else { + if si.searchResult.IsLastPage() { + si.Err = errDone + } else { + // There are still more pages to fetch, so fetch the next page and + // cache it + 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. + 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 { + 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 (ci *CommentIterator) HasError() bool { + if ci.Err == errDone { + return false + } + if ci.Err == nil { + return false + } + return true +} + +// HasNext returns true if there is another item available in the result set +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 (ci *CommentIterator) Next() *Comment { + if ci.Err != nil { + return nil + } + + 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 + ci.itemIdx++ + } else { + if ci.message.IsLastPage() { + ci.Err = errDone + } else { + // There are still more pages to fetch, so fetch the next page and + // cache it + 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. + ci.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 + } + + // 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 +} + +// 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 (cli *ChangeLogIterator) HasError() bool { + if cli.Err == errDone { + return false + } + if cli.Err == nil { + return false + } + return true +} + +// HasNext returns true if there is another item available in the result set +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 (cli *ChangeLogIterator) Next() *ChangeLogEntry { + if cli.Err != nil { + return nil + } + + 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 + cli.itemIdx++ + } else { + if cli.message.IsLastPage() { + cli.Err = errDone + } else { + // There are still more pages to fetch, so fetch the next page and + // cache it + 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. + 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 { + 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 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 { + err := errors.Wrap(errTransitionNotAllowed, fmt.Sprintf( + "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..077f258a --- /dev/null +++ b/bridge/jira/config.go @@ -0,0 +1,167 @@ +package jira + +import ( + "encoding/json" + "fmt" + "io/ioutil" + + "github.com/pkg/errors" + + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/input" +) + +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 +` + +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.` + +// 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 + + // 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 == "" { + // terminal prompt + serverURL, err = input.Prompt("JIRA server URL", "URL", input.Required) + if err != nil { + return nil, err + } + } + conf[keyServer] = serverURL + + project := params.Project + if project == "" { + project, err = input.Prompt("JIRA project key", "project", input.Required) + if err != nil { + 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) + if err != nil { + return nil, err + } + + password, err := input.PromptPassword("Password", "password", input.Required) + if err != nil { + return nil, err + } + + 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( + err, fmt.Sprintf("Unable to write credentials to %s", credentialsFile)) + } + case 2: + conf[keyUsername] = username + conf[keyPassword] = password + case 3: + conf[keyUsername] = username + } + + err = g.ValidateConfig(conf) + if err != nil { + 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 +} + +// ValidateConfig returns true if all required keys are present +func (*Jira) ValidateConfig(conf core.Configuration) error { + 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) + } + + if _, ok := conf[keyProject]; !ok { + return fmt.Errorf("missing %s key", keyProject) + } + + return nil +} diff --git a/bridge/jira/export.go b/bridge/jira/export.go new file mode 100644 index 00000000..f329e490 --- /dev/null +++ b/bridge/jira/export.go @@ -0,0 +1,443 @@ +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 ( + 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 + + // 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 (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 (je *jiraExporter) getIdentityClient(ctx context.Context, id entity.Id) (*Client, error) { + client, ok := je.identityClient[id] + if ok { + return client, nil + } + + 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 != je.userIdentity { + return nil, ErrMissingCredentials + } + err := client.Login(je.conf) + if err != nil { + return nil, err + } + + je.identityClient[id] = client + return client, 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() + if err != nil { + return nil, 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 + } + + je.project, err = client.GetProject(je.conf[keyProject]) + if err != nil { + return nil, err + } + + go func() { + defer close(out) + + var allIdentitiesIds []entity.Id + for id := range je.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 + err := je.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") + } + } + } + }() + + return out, nil +} + +// exportBug publish bugs and related events +func (je *jiraExporter) exportBug(ctx context.Context, b *cache.BugCache, since time.Time, out chan<- core.ExportResult) error { + 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(core.MetaKeyOrigin) + if ok && origin != target { + out <- core.NewExportNothing( + b.Id(), fmt.Sprintf("issue tagged with origin: %s", origin)) + return nil + } + + // 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{je.project.ID, je.project.Key}) { + out <- core.NewExportNothing( + b.Id(), fmt.Sprintf("issue tagged with project: %s", project)) + return nil + } + + // get jira bug ID + jiraID, ok := snapshot.GetCreateMetadata(keyJiraID) + 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()) + 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 err + } + + // Load any custom fields required to create an issue from the git + // config file. + fields := make(map[string]interface{}) + defaultFields, hasConf := je.conf[keyCreateDefaults] + if hasConf { + err = json.Unmarshal([]byte(defaultFields), &fields) + if err != nil { + 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"] = map[string]interface{}{ + "id": "10001", + } + } + 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 + fields[bugIDField] = b.Id().String() + } + + // create bug + result, err := client.CreateIssue( + je.project.ID, createOp.Title, createOp.Message, fields) + if err != nil { + err := errors.Wrap(err, "exporting jira issue") + out <- core.NewExportError(err, b.Id()) + return err + } + + id := result.ID + out <- core.NewExportBug(b.Id()) + // mark bug creation operation as exported + err = markOperationAsExported( + 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()) + return err + } + + // 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 err + } + + // cache bug jira ID + bugJiraID = id + } + + // 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 { + 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 { + je.cachedOperationIDs[op.Id()] = id + continue + } + + opAuthor := op.GetAuthor() + client, err := je.getIdentityClient(ctx, opAuthor.Id()) + if err != nil { + out <- core.NewExportError( + fmt.Errorf("missing operation author credentials for user %.8s", + author.Id().String()), op.Id()) + continue + } + + var id string + var exportTime time.Time + switch opr := op.(type) { + case *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 err + } + id = comment.ID + out <- core.NewExportComment(op.Id()) + + // cache comment id + je.cachedOperationIDs[op.Id()] = id + + case *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 err + } + 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 := je.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 err + } + 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 = getTimeDerivedID(comment.ID, comment.Updated) + } + + case *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.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. + continue + } + out <- core.NewExportStatusChange(op.Id()) + id = bugJiraID + } else { + out <- core.NewExportError(fmt.Errorf( + "No jira status mapped for %.8s", opr.Status.String()), b.Id()) + } + + case *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 err + } + out <- core.NewExportTitleEdition(op.Id()) + id = bugJiraID + + case *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 err + } + out <- core.NewExportLabelChange(op.Id()) + id = bugJiraID + + default: + panic("unhandled operation type case") + } + + // mark operation as exported + err = markOperationAsExported( + 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()) + return err + } + + // 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 err + } + } + + return nil +} + +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..6a755a36 --- /dev/null +++ b/bridge/jira/import.go @@ -0,0 +1,630 @@ +package jira + +import ( + "context" + "encoding/json" + "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 ( + 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 (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 (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 + 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( + 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() + b, err := ji.ensureIssue(repo, *issue) + if err != nil { + err := fmt.Errorf("issue creation: %v", err) + out <- core.NewImportError(err, "") + return + } + + var commentIter *CommentIterator + for commentIter = + client.IterComments(issue.ID, defaultPageSize); commentIter.HasNext(); { + comment := commentIter.Next() + err := ji.ensureComment(repo, b, *comment) + if err != nil { + out <- core.NewImportError(err, "") + } + } + if commentIter.HasError() { + out <- core.NewImportError(commentIter.Err, "") + } + + snapshot := b.Snapshot() + opIdx := 0 + + var changelogIter *ChangeLogIterator + for changelogIter = + 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) { + err = ji.ensureChange(repo, b, *changelogEntry, snapshot.Operations[opIdx]) + } else { + err = ji.ensureChange(repo, b, *changelogEntry, nil) + } + if err != nil { + out <- core.NewImportError(err, "") + } + + } + if changelogIter.HasError() { + out <- core.NewImportError(changelogIter.Err, "") + } + + if !b.NeedCommit() { + out <- core.NewImportNothing(b.Id(), "no imported operation") + } else if err := b.Commit(); err != nil { + err = fmt.Errorf("bug commit: %v", err) + out <- core.NewImportError(err, "") + return + } + } + if searchIter.HasError() { + out <- core.NewImportError(searchIter.Err, "") + } + }() + + return out, nil +} + +// Create a bug.Person from a JIRA user +func (ji *jiraImporter) ensurePerson(repo *cache.RepoCache, user User) (*cache.IdentityCache, error) { + // Look first in the cache + i, err := repo.ResolveIdentityImmutableMetadata( + 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 + } + + ji.out <- core.NewImportIdentity(i.Id()) + return i, nil +} + +// Create a bug.Bug based from a JIRA issue +func (ji *jiraImporter) ensureIssue(repo *cache.RepoCache, issue Issue) (*cache.BugCache, error) { + author, err := ji.ensurePerson(repo, issue.Fields.Creator) + if err != nil { + return nil, err + } + + b, err := repo.ResolveBugCreateMetadata(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 + } + + // NOTE(josh): newlines in titles appears to be rare, but it has been seen + // in the wild. It does not appear to be allowed in the JIRA web interface. + title := strings.Replace(issue.Fields.Summary, "\n", "", -1) + b, _, err = repo.NewBugRaw( + author, + issue.Fields.Created.Unix(), + title, + cleanText, + nil, + map[string]string{ + core.MetaKeyOrigin: target, + keyJiraID: issue.ID, + keyJiraKey: issue.Key, + keyJiraProject: ji.conf[keyProject], + }) + if err != nil { + return nil, err + } + + ji.out <- core.NewImportBug(b.Id()) + } + + return b, nil +} + +// Return a unique string derived from a unique jira id and a timestamp +func getTimeDerivedID(jiraID string, timestamp MyTime) string { + return fmt.Sprintf("%s-%d", jiraID, timestamp.Unix()) +} + +// Create a bug.Comment from a JIRA comment +func (ji *jiraImporter) ensureComment(repo *cache.RepoCache, b *cache.BugCache, item Comment) error { + // ensure person + author, err := ji.ensurePerson(repo, item.Author) + if err != nil { + return err + } + + targetOpID, err := b.ResolveOperationWithMetadata( + keyJiraID, item.ID) + if err != nil && err != cache.ErrNoMatchingOp { + return err + } + + // If the comment is a new comment then create it + if targetOpID == "" && err == cache.ErrNoMatchingOp { + var cleanText string + if item.Updated != item.Created { + // We don't know the original text... we only have the updated text. + cleanText = "" + } else { + cleanText, err = text.Cleanup(string(item.Body)) + if err != nil { + return err + } + } + + // add comment operation + op, err := b.AddCommentRaw( + author, + item.Created.Unix(), + cleanText, + nil, + map[string]string{ + keyJiraID: item.ID, + }, + ) + if err != nil { + return err + } + + ji.out <- core.NewImportComment(op.Id()) + targetOpID = op.Id() + } + + // If there are no updates to this comment, then we are done + if item.Updated == item.Created { + return nil + } + + // If there has been an update to this comment, we try to find it in the + // database. We need a unique id so we'll concat the issue id with the update + // timestamp. Note that this must be consistent with the exporter during + // export of an EditCommentOperation + derivedID := getTimeDerivedID(item.ID, item.Updated) + _, err = b.ResolveOperationWithMetadata(keyJiraID, derivedID) + if err == nil { + // Already imported this edition + return nil + } + + if err != cache.ErrNoMatchingOp { + return err + } + + // ensure editor identity + editor, err := ji.ensurePerson(repo, item.UpdateAuthor) + if err != nil { + return err + } + + // comment edition + cleanText, err := text.Cleanup(string(item.Body)) + if err != nil { + return err + } + op, err := b.EditCommentRaw( + editor, + item.Updated.Unix(), + targetOpID, + cleanText, + map[string]string{ + keyJiraID: derivedID, + }, + ) + + if err != nil { + return err + } + + ji.out <- core.NewImportCommentEdition(op.Id()) + + return nil +} + +// Return a unique string derived from a unique jira id and an index into the +// data referred to by that jira id. +func getIndexDerivedID(jiraID string, idx int) string { + return fmt.Sprintf("%s-%d", jiraID, idx) +} + +func labelSetsMatch(jiraSet []string, gitbugSet []bug.Label) bool { + if len(jiraSet) != len(gitbugSet) { + return false + } + + sort.Strings(jiraSet) + gitbugStrSet := make([]string, len(gitbugSet)) + for idx, label := range gitbugSet { + gitbugStrSet[idx] = label.String() + } + sort.Strings(gitbugStrSet) + + for idx, value := range jiraSet { + if value != gitbugStrSet[idx] { + return false + } + } + + return true +} + +// Create a bug.Operation (or a series of operations) from a JIRA changelog +// entry +func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, entry ChangeLogEntry, potentialOp bug.Operation) error { + + // If we have an operation which is already mapped to the entire changelog + // entry then that means this changelog entry was induced by an export + // operation and we've already done the match, so we skip this one + _, err := b.ResolveOperationWithMetadata(keyJiraOperationID, entry.ID) + if err == nil { + return nil + } else if err != cache.ErrNoMatchingOp { + return err + } + + // In general, multiple fields may be changed in changelog entry on + // JIRA. For example, when an issue is closed both its "status" and its + // "resolution" are updated within a single changelog entry. + // I don't thing git-bug has a single operation to modify an arbitrary + // number of fields in one go, so we break up the single JIRA changelog + // entry into individual field updates. + author, err := ji.ensurePerson(repo, entry.Author) + if err != nil { + return err + } + + if len(entry.Items) < 1 { + return fmt.Errorf("Received changelog entry with no item! (%s)", entry.ID) + } + + statusMap, err := getStatusMapReverse(ji.conf) + if err != nil { + return err + } + + // NOTE(josh): first do an initial scan and see if any of the changed items + // matches the current potential operation. If it does, then we know that this + // entire changelog entry was created in response to that git-bug operation. + // So we associate the operation with the entire changelog, and not a specific + // entry. + for _, item := range entry.Items { + switch item.Field { + case "labels": + fromLabels := removeEmpty(strings.Split(item.FromString, " ")) + toLabels := removeEmpty(strings.Split(item.ToString, " ")) + removedLabels, addedLabels, _ := setSymmetricDifference(fromLabels, toLabels) + + opr, isRightType := potentialOp.(*bug.LabelChangeOperation) + if isRightType && labelSetsMatch(addedLabels, opr.Added) && labelSetsMatch(removedLabels, opr.Removed) { + _, err := b.SetMetadata(opr.Id(), map[string]string{ + keyJiraOperationID: entry.ID, + }) + if err != nil { + return err + } + return nil + } + + case "status": + opr, isRightType := potentialOp.(*bug.SetStatusOperation) + if isRightType && statusMap[opr.Status.String()] == item.To { + _, err := b.SetMetadata(opr.Id(), map[string]string{ + keyJiraOperationID: entry.ID, + }) + if err != nil { + return err + } + return nil + } + + case "summary": + // NOTE(josh): JIRA calls it "summary", which sounds more like the body + // text, but it's the title + opr, isRightType := potentialOp.(*bug.SetTitleOperation) + if isRightType && opr.Title == item.To { + _, err := b.SetMetadata(opr.Id(), map[string]string{ + keyJiraOperationID: entry.ID, + }) + if err != nil { + return err + } + return nil + } + + case "description": + // NOTE(josh): JIRA calls it "description", which sounds more like the + // title but it's actually the body + opr, isRightType := potentialOp.(*bug.EditCommentOperation) + if isRightType && + opr.Target == b.Snapshot().Operations[0].Id() && + opr.Message == item.ToString { + _, err := b.SetMetadata(opr.Id(), map[string]string{ + keyJiraOperationID: entry.ID, + }) + if err != nil { + return err + } + return nil + } + } + } + + // Since we didn't match the changelog entry to a known export operation, + // then this is a changelog entry that we should import. We import each + // changelog entry item as a separate git-bug operation. + for idx, item := range entry.Items { + derivedID := getIndexDerivedID(entry.ID, idx) + _, err := b.ResolveOperationWithMetadata(keyJiraOperationID, derivedID) + if err == nil { + continue + } + if err != cache.ErrNoMatchingOp { + return err + } + + switch item.Field { + case "labels": + fromLabels := removeEmpty(strings.Split(item.FromString, " ")) + toLabels := removeEmpty(strings.Split(item.ToString, " ")) + removedLabels, addedLabels, _ := setSymmetricDifference(fromLabels, toLabels) + + op, err := b.ForceChangeLabelsRaw( + author, + entry.Created.Unix(), + addedLabels, + removedLabels, + map[string]string{ + keyJiraID: entry.ID, + keyJiraOperationID: derivedID, + }, + ) + if err != nil { + return err + } + + ji.out <- core.NewImportLabelChange(op.Id()) + + case "status": + statusStr, hasMap := statusMap[item.To] + if hasMap { + switch statusStr { + case bug.OpenStatus.String(): + op, err := b.OpenRaw( + author, + entry.Created.Unix(), + map[string]string{ + keyJiraID: entry.ID, + keyJiraOperationID: derivedID, + }, + ) + if err != nil { + return err + } + ji.out <- core.NewImportStatusChange(op.Id()) + + case bug.ClosedStatus.String(): + op, err := b.CloseRaw( + author, + entry.Created.Unix(), + map[string]string{ + keyJiraID: entry.ID, + keyJiraOperationID: derivedID, + }, + ) + if err != nil { + return err + } + ji.out <- core.NewImportStatusChange(op.Id()) + } + } else { + ji.out <- core.NewImportError( + fmt.Errorf( + "No git-bug status mapped for jira status %s (%s)", + item.ToString, item.To), "") + } + + case "summary": + // NOTE(josh): JIRA calls it "summary", which sounds more like the body + // text, but it's the title + op, err := b.SetTitleRaw( + author, + entry.Created.Unix(), + string(item.ToString), + map[string]string{ + keyJiraID: entry.ID, + keyJiraOperationID: derivedID, + }, + ) + if err != nil { + return err + } + + ji.out <- core.NewImportTitleEdition(op.Id()) + + case "description": + // NOTE(josh): JIRA calls it "description", which sounds more like the + // title but it's actually the body + op, err := b.EditCreateCommentRaw( + author, + entry.Created.Unix(), + string(item.ToString), + map[string]string{ + keyJiraID: entry.ID, + keyJiraOperationID: derivedID, + }, + ) + if err != nil { + return err + } + + ji.out <- core.NewImportCommentEdition(op.Id()) + + default: + ji.out <- core.NewImportWarning( + fmt.Errorf( + "Unhandled changelog event %s", item.Field), "") + } + + // Other Examples: + // "assignee" (jira) + // "Attachment" (jira) + // "Epic Link" (custom) + // "Rank" (custom) + // "resolution" (jira) + // "Sprint" (custom) + } + return nil +} + +func getStatusMap(conf core.Configuration) (map[string]string, error) { + mapStr, hasConf := conf[keyIDMap] + if !hasConf { + return map[string]string{ + bug.OpenStatus.String(): "1", + bug.ClosedStatus.String(): "6", + }, nil + } + + statusMap := make(map[string]string) + err := json.Unmarshal([]byte(mapStr), &statusMap) + return statusMap, err +} + +func getStatusMapReverse(conf core.Configuration) (map[string]string, error) { + fwdMap, err := getStatusMap(conf) + if err != nil { + return fwdMap, err + } + + outMap := map[string]string{} + for key, val := range fwdMap { + outMap[val] = key + } + + mapStr, hasConf := conf[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 { + value = strings.TrimSpace(value) + if value != "" { + output = append(output, value) + } + } + return output +} diff --git a/bridge/jira/jira.go b/bridge/jira/jira.go new file mode 100644 index 00000000..43a11c05 --- /dev/null +++ b/bridge/jira/jira.go @@ -0,0 +1,108 @@ +// Package jira contains the Jira bridge implementation +package jira + +import ( + "sort" + "time" + + "github.com/MichaelMure/git-bug/bridge/core" +) + +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" + + defaultTimeout = 60 * time.Second +) + +var _ core.BridgeImpl = &Jira{} + +// Jira Main object for the bridge +type Jira struct{} + +// Target returns "jira" +func (*Jira) Target() string { + return target +} + +func (*Jira) LoginMetaKey() string { + return metaKeyJiraLogin +} + +// 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 +} diff --git a/bug/op_edit_comment.go b/bug/op_edit_comment.go index 44ee5877..f82e7590 100644 --- a/bug/op_edit_comment.go +++ b/bug/op_edit_comment.go @@ -156,3 +156,15 @@ func EditCommentWithFiles(b Interface, author identity.Interface, unixTime int64 b.Append(editCommentOp) return editCommentOp, nil } + +// Convenience function to edit the body of a bug (the first comment) +func EditCreateComment(b Interface, author identity.Interface, unixTime int64, message string) (*EditCommentOperation, error) { + createOp := b.FirstOp().(*CreateOperation) + return EditComment(b, author, unixTime, createOp.Id(), message) +} + +// Convenience function to edit the body of a bug (the first comment) +func EditCreateCommentWithFiles(b Interface, author identity.Interface, unixTime int64, message string, files []git.Hash) (*EditCommentOperation, error) { + createOp := b.FirstOp().(*CreateOperation) + return EditCommentWithFiles(b, author, unixTime, createOp.Id(), message, files) +} diff --git a/cache/bug_cache.go b/cache/bug_cache.go index 6026190f..b86b31e0 100644 --- a/cache/bug_cache.go +++ b/cache/bug_cache.go @@ -210,6 +210,28 @@ func (c *BugCache) SetTitleRaw(author *IdentityCache, unixTime int64, title stri return op, c.notifyUpdated() } +func (c *BugCache) EditCreateComment(body string) (*bug.EditCommentOperation, error) { + author, err := c.repoCache.GetUserIdentity() + if err != nil { + return nil, err + } + + return c.EditCreateCommentRaw(author, time.Now().Unix(), body, nil) +} + +func (c *BugCache) EditCreateCommentRaw(author *IdentityCache, unixTime int64, body string, metadata map[string]string) (*bug.EditCommentOperation, error) { + op, err := bug.EditCreateComment(c.bug, author.Identity, unixTime, body) + if err != nil { + return nil, err + } + + for key, value := range metadata { + op.SetMetadata(key, value) + } + + return op, c.notifyUpdated() +} + func (c *BugCache) EditComment(target entity.Id, message string) (*bug.EditCommentOperation, error) { author, err := c.repoCache.GetUserIdentity() if err != nil { diff --git a/doc/jira_bridge.md b/doc/jira_bridge.md new file mode 100644 index 00000000..df56bb2d --- /dev/null +++ b/doc/jira_bridge.md @@ -0,0 +1,377 @@ +# JIRA Bridge + +## Design Notes + +### One bridge = one project + +There aren't any huge technical barriers requiring this, but since git-bug lacks +a notion of "project" there is no way to know which project to export new bugs +to as issues. Also, JIRA projects are first-class immutable metadata and so we +*must* get it right on export. Therefore the bridge is configured with the `Key` +for the project it is assigned to. It will only import bugs from that project. + +### JIRA fields + +The bridge currently does nothing to import any of the JIRA fields that don't +have `git-bug` equivalents ("Assignee", "sprint", "story points", etc). +Hopefully the bridge will be able to enable synchronization of these soon. + +### Credentials + +JIRA does not support user/personal access tokens. They have experimental +3-legged oauth support but that requires an API token for the app configured +by the server administrator. The only reliable authentication mechanism then is +the username/password and session-token mechanims. We can aquire a session +token programatically from the username/password but these are very short lived +(i.e. hours or less). As such the bridge currently requires an actual username +and password as user credentials. It supports three options: + +1. Storing both username and password in a separate file referred to by + the `git-config` (I like to use `.git/jira-credentials.json`) +2. Storing the username and password in clear-text in the git config +3. Storing the username only in the git config and asking for the password + on each `push` or `pull`. + +### Issue Creation Defaults + +When a new issues is created in JIRA there are often certain mandatory fields +that require a value or the creation is rejected. In the issue create form on +the JIRA web interface, these are annotated as "required". The `issuetype` is +always required (e.g. "bug", "story", "task", etc). The set of required metadata +is configurable (in JIRA) per `issuetype` so the set might be different between +"bug" and "story", for example. + +For now, the bridge only supports exporting issues as a single `issuetype`. If +no configuration is provied, then the default is `"id": "10001"` which is +`"story"` in the default set of issue types. + +In addition to specifying the `issuetype` of issues created on export, the +bridge will also allow you to specify a constant global set of default values +for any additional required fields. See the configuration section below for the +syntax. + +For longer term goals, see the section below on workflow validation + +### Assign git-bug id to field during issue creation + +JIRA allows for the inclusion of custom "fields" in all of their issues. The +JIRA bridge will store the JIRA issue "id" for any bugs which are synchronized +to JIRA, but it can also assign to a custom JIRA `field` the `git-bug` id. This +way the `git-bug` id can be displayed in the JIRA web interface and certain +integration activities become easier. + +See the configuration section below on how to specify the custom field where the +JIRA bridge should write this information. + + +### Workflows and Transitions + +JIRA issue states are subject to customizable "workflows" (project managers +apparently validate themselves by introducing developer friction). In general, +issues can only transition from one state to another if there is an edge between +them in the state graph (a.k.a. "workflow"). JIRA calls these edges +"transitions". Furthermore, each transition may include a set of mandatory +fields which must be set in order for the transition to succeed. For example the +transition of `"status"` from `"In Progress"` to `"Closed"` might required a +`"resolution"` (i.e. `"Fixed"` or `"Working as intended"`). + +Dealing with complex workflows is going to be challenging. Some long-term +aspirations are described in the section below on "Workflow Validation". +Currently the JIRA bridge isn't very smart about transitions though, so you'll +need to tell it what you want it to do when importing and exporting a state +change (i.e. to "close" or "open" a bug). Currently the bridge accepts +configuration options which map the two `git-bug` statuses ("open", "closed") to +two JIRA statuses. On import, the JIRA status is mapped to a `git-bug` status +(if a mapping exists) and the `git-bug` status is assigned. On export, the +`git-bug` status is mapped to a JIRA status and if a mapping exists the bridge +will query the list of available transitions for the issue. If a transition +exists to the desired state the bridge will attempt to execute the transition. +It does not currently support assigning any fields during the transition so if +any fields are required the transition will fail during export and the status +will be out of sync. + +### JIRA Changelog + +Some operations on JIRA issues are visible in a timeline view known as the +`changelog`. The JIRA cloud product provides an +`/issue/{issueIdOrKey}/changelog` endpoint which provides a paginated view but +the JIRA server product does not. The changelog is visible by querying the issue +with the `expand=changelog` query parameter. Unfortunately in this case the +entire changelog is provided without paging. + +Each changelog entry is identified with a unique string `id`, but within a +single changelog entry is a list of multilple fields that are modified. In other +words a single "event" might atomically change multiple fields. As an example, +when an issue is closed the `"status"` might change to `"closed"` and the +`"resolution"` might change to `"fixed'`. + +When a changelog entry is imported by the JIRA bridge, each individual field +that was changed is treated as a separate `git-bug` operation. In other words a +single JIRA change event might create more than one `git-bug` operation. + +However, when a `git-bug` operation is exported to JIRA it will only create a +single changelog entry. Furthermore, when we modify JIRA issues over the REST +API JIRA does not provide any information to associate that modification event +with the changelog. We must, therefore, herustically match changelog entries +against operations that we performed in order to not import them as duplicate +events. In order to assist in this matching proceess, the bridge will record the +JIRA server time of the response to the `POST` (as reported by the `"Date"` +response header). During import, we keep an iterator to the list of `git-bug` +operations for the bug mapped to the Jira issue. As we walk the JIRA changelog, +we keep the iterator pointing to the first operation with an annotation which is +*not before* that changelog entry. If the changelog entry is the result of an +exported `git-bug` operation, then this must be that operation. We then scan +through the list of changeitems (changed fields) in the changelog entry, and if +we can match a changed field to the candidate `git-bug` operation then we have +identified the match. + +### Unlogged Changes + +Comments (creation and edition) do not show up in the JIRA changelog. However +JIRA reports both a `created` and `updated` date for each comment. If we +import a comment which has an `updated` and `created` field which do not match, +then we treat that as a new comment edition. If we do not already have the +comment imported, then we import an empty comment followed by a comment edition. + +Because comment editions are not uniquely identified in JIRA we identify them +in `git-bug` by concatinating the JIRA issue `id` with the `updated` time of +the edition. + +### Workflow Validation (future) + +The long-term plan for the JIRA bridge is to download and store the workflow +specifiations from the JIRA server. This includes the required metadata for +issue creation, and the status state graph, and the set of required metadata for +status transition. + +When an existing `git-bug` is initially marked for export, the bridge will hook +in and validate the bug state against the required metadata. Then it will prompt +for any missing metadata using a set of UI components appropriate for the field +schema as reported by JIRA. If the user cancels then the bug will not be marked +for export. + +When a bug already marked for JIRA export (including those that were imported) +is modified, the bridge will hook in and validate the modification against the +workflow specifications. It will prompt for any missing metadata as in the +creation process. + +During export, the bridge will validate any export operations and skip them if +we know they will fail due to violation of the cached workflow specification +(i.e. missing required fields for a transition). A list of bugs "blocked for +export" will be available to query. A UI command will allow the user to inspect +and resolve any bugs that are "blocked for export". + +## Configuration + +As mentioned in the notes above, there are a few optional configuration fields +that can be set beyond those that are prompted for during the initial bridge +configuration. You can set these options in your `.git/config` file: + +### Issue Creation Defaults + +The format for this config entry is a JSON object containing fields you wish to +set during issue creation when exproting bugs. If you provide a value for this +configuration option, it must include at least the `"issuetype"` field, or +the bridge will not be able to export any new issues. + +Let's say that we want bugs exported to JIRA to have a default issue type of +"Story" which is `issuetype` with id `10001`. Then we will add the following +entry to our git-config: + +``` +create-issue-defaults = {"issuetype":"10001"} +``` + +If you needed an additional required field `customfield_1234` and you wanted to +provide a default value of `"default"` then you would add the following to your +config: + +``` +create-issue-defaults = {"issuetype":"10001","customfield_1234":"default"} +``` + +Note that the content of this value is merged verbatim to the JSON object that +is `POST`ed to the JIRA rest API, so you can use arbitrary valid JSON. + + +### Assign git-bug id to field + +If you want the bridge to fill a JIRA field with the `git-bug` id when exporting +issues, then provide the name of the field: + +``` +create-issue-gitbug-id = "customfield_5678" +``` + +### Status Map + +You can specify the mapping between `git-bug` status and JIRA status id's using +the following: +``` +bug-id-map = {\"open\": \"1\", \"closed\": \"6\"} +``` + +The format of the map is `<git-bug-status-name>: <jira-status-id>`. In general +your jira instance will have more statuses than `git-bug` will and you may map +more than one jira-status to a git-bug status. You can do this with +`bug-id-revmap`: +``` +bug-id-revmap = {\"10109\": \"open\", \"10006\": \"open\", \"10814\": \"open\"} +``` + +The reverse map `bug-id-revmap` will automatically include the inverse of the +forward map `bug-id-map`. + +Note that in JIRA each different `issuetype` can have a different set of +statuses. The bridge doesn't currently support more than one mapping, however. +Also, note that the format of the map is JSON and the git config file syntax +requires doublequotes to be escaped (as in the examples above). + +### Full example + +Here is an example configuration with all optional fields set +``` +[git-bug "bridge.default"] + project = PROJ + credentials-file = .git/jira-credentials.json + target = jira + server = https://jira.example.com + create-issue-defaults = {"issuetype":"10001","customfield_1234":"default"} + create-issue-gitbug-id = "customfield_5678" + bug-open-id = 1 + bug-closed-id = 6 +``` + +## To-Do list + +* [0cf5c71] Assign git-bug to jira field on import +* [8acce9c] Download and cache workflow representation +* [95e3d45] Implement workflow gui +* [c70e22a] Implement additional query filters for import +* [9ecefaa] Create JIRA mock and add REST unit tests +* [67bf520] Create import/export integration tests +* [1121826] Add unit tests for utilites +* [0597088] Use OS keyring for credentials +* [d3e8f79] Don't count on the `Total` value in paginations + + +## Using CURL to poke at your JIRA's REST API + +If you need to lookup the `id` for any `status`es or the `schema` for any +creation metadata, you can use CURL to query the API from the command line. +Here are a couple of examples to get you started. + +### Getting a session token + +``` +curl \ + --data '{"username":"<username>", "password":"<password>"}' \ + --header "Content-Type: application/json" \ + --request POST \ + <serverUrl>/rest/auth/1/session +``` + +**Note**: If you have a json pretty printer installed (`sudo apt install jq`), +pipe the output through through that to make things more readable: + +``` +curl --silent \ + --data '{"username":"<username>", "password":"<password>"}' \ + --header "Content-Type: application/json" \ + --request POST + <serverUrl>/rest/auth/1/session | jq . +``` + +example output: +``` +{ + "session": { + "name": "JSESSIONID", + "value": "{sessionToken}" + }, + "loginInfo": { + "loginCount": 268, + "previousLoginTime": "2019-11-12T08:03:35.300-0800" + } +} +``` + +Make note of the output value. On subsequent invocations of `curl`, append the +following command-line option: + +``` +--cookie "JSESSIONID={sessionToken}" +``` + +Where `{sessionToken}` is the output from the `POST` above. + +### Get a list of issuetype ids + +``` +curl --silent \ + --cookie "JSESSIONID={sessionToken}" \ + --header "Content-Type: application/json" \ + --request GET https://jira.example.com/rest/api/2/issuetype \ + | jq . +``` + +**example output**: +``` + { + "self": "https://jira.example.com/rest/api/2/issuetype/13105", + "id": "13105", + "description": "", + "iconUrl": "https://jira.example.com/secure/viewavatar?size=xsmall&avatarId=10316&avatarType=issuetype", + "name": "Test Plan Links", + "subtask": true, + "avatarId": 10316 + }, + { + "self": "https://jira.example.com/rest/api/2/issuetype/13106", + "id": "13106", + "description": "", + "iconUrl": "https://jira.example.com/secure/viewavatar?size=xsmall&avatarId=10316&avatarType=issuetype", + "name": "Enable Initiatives on the project", + "subtask": true, + "avatarId": 10316 + }, + ... +``` + + +### Get a list of statuses + + +``` +curl --silent \ + --cookie "JSESSIONID={sessionToken}" \ + --header "Content-Type: application/json" \ + --request GET https://jira.example.com/rest/api/2/project/{projectIdOrKey}/statuses \ + | jq . +``` + +**example output:** +``` +[ + { + "self": "https://example.com/rest/api/2/issuetype/3", + "id": "3", + "name": "Task", + "subtask": false, + "statuses": [ + { + "self": "https://example.com/rest/api/2/status/1", + "description": "The issue is open and ready for the assignee to start work on it.", + "iconUrl": "https://example.com/images/icons/statuses/open.png", + "name": "Open", + "id": "1", + "statusCategory": { + "self": "https://example.com/rest/api/2/statuscategory/2", + "id": 2, + "key": "new", + "colorName": "blue-gray", + "name": "To Do" + } + }, +... +``` |