aboutsummaryrefslogtreecommitdiffstats
path: root/bridge
diff options
context:
space:
mode:
Diffstat (limited to 'bridge')
-rw-r--r--bridge/bridges.go2
-rw-r--r--bridge/core/export.go6
-rw-r--r--bridge/core/import.go6
-rw-r--r--bridge/jira/client.go1514
-rw-r--r--bridge/jira/config.go232
-rw-r--r--bridge/jira/export.go456
-rw-r--r--bridge/jira/import.go646
-rw-r--r--bridge/jira/jira.go108
8 files changed, 2964 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..6ec1c9dd
--- /dev/null
+++ b/bridge/jira/client.go
@@ -0,0 +1,1514 @@
+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 (self *ClientTransport) RoundTrip(
+ req *http.Request) (*http.Response, error) {
+ req.Header.Add("Content-Type", "application/json")
+ if self.basicAuthString != "" {
+ req.Header.Add("Authorization",
+ fmt.Sprintf("Basic %s", self.basicAuthString))
+ }
+
+ return self.underlyingTransport.RoundTrip(req)
+}
+
+func (self *ClientTransport) SetCredentials(
+ username string, token string) {
+ credString := fmt.Sprintf("%s:%s", username, token)
+ self.basicAuthString = base64.StdEncoding.EncodeToString([]byte(credString))
+}
+
+// Client Thin wrapper around the http.Client providing jira-specific methods
+// for APIendpoints
+type Client struct {
+ *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, &params)
+ 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 (self *SearchIterator) HasError() bool {
+ if self.Err == errDone {
+ return false
+ }
+ if self.Err == nil {
+ return false
+ }
+ return true
+}
+
+// HasNext returns true if there is another item available in the result set
+func (self *SearchIterator) HasNext() bool {
+ return self.Err == nil && self.itemIdx < len(self.searchResult.Issues)
+}
+
+// Next Return the next item in the result set and advance the iterator.
+// Advancing the iterator may require fetching a new page.
+func (self *SearchIterator) Next() *Issue {
+ if self.Err != nil {
+ return nil
+ }
+
+ issue := self.searchResult.Issues[self.itemIdx]
+ if self.itemIdx+1 < len(self.searchResult.Issues) {
+ // We still have an item left in the currently cached page
+ self.itemIdx++
+ } else {
+ if self.searchResult.IsLastPage() {
+ self.Err = errDone
+ } else {
+ // There are still more pages to fetch, so fetch the next page and
+ // cache it
+ self.searchResult, self.Err = self.client.Search(
+ self.jql, self.pageSize, self.searchResult.NextStartAt())
+ // NOTE(josh): we don't deal with the error now, we just cache it.
+ // HasNext() will return false and the caller can check the error
+ // afterward.
+ self.itemIdx = 0
+ }
+ }
+ return &issue
+}
+
+// IterSearch return an iterator over paginated results for a JQL search
+func (client *Client) IterSearch(
+ jql string, pageSize int) *SearchIterator {
+ result, err := client.Search(jql, pageSize, 0)
+
+ iter := &SearchIterator{
+ client: client,
+ jql: jql,
+ searchResult: result,
+ Err: err,
+ pageSize: pageSize,
+ itemIdx: 0,
+ }
+
+ return iter
+}
+
+// GetIssue fetches an issue object via the /issue/{IssueIdOrKey} endpoint
+// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue
+func (client *Client) GetIssue(
+ idOrKey string, fields []string, expand []string,
+ properties []string) (*Issue, error) {
+ url := fmt.Sprintf("%s/rest/api/2/issue/%s", client.serverURL, idOrKey)
+
+ request, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ err := fmt.Errorf("Creating request %v", err)
+ return nil, err
+ }
+
+ query := request.URL.Query()
+ if len(fields) > 0 {
+ query.Add("fields", strings.Join(fields, ","))
+ }
+ if len(expand) > 0 {
+ query.Add("expand", strings.Join(expand, ","))
+ }
+ if len(properties) > 0 {
+ query.Add("properties", strings.Join(properties, ","))
+ }
+ request.URL.RawQuery = query.Encode()
+
+ if client.ctx != nil {
+ ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
+ defer cancel()
+ request = request.WithContext(ctx)
+ }
+
+ response, err := client.Do(request)
+ if err != nil {
+ err := fmt.Errorf("Performing request %v", err)
+ return nil, err
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode != http.StatusOK {
+ err := fmt.Errorf(
+ "HTTP response %d, query was %s", response.StatusCode,
+ request.URL.String())
+ return nil, err
+ }
+
+ var issue Issue
+
+ data, _ := ioutil.ReadAll(response.Body)
+ err = json.Unmarshal(data, &issue)
+ if err != nil {
+ err := fmt.Errorf("Decoding response %v", err)
+ return nil, err
+ }
+
+ return &issue, nil
+}
+
+// GetComments returns a page of comments via the issue/{IssueIdOrKey}/comment
+// endpoint
+// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getComment
+func (client *Client) GetComments(
+ idOrKey string, maxResults int, startAt int) (*CommentPage, error) {
+ url := fmt.Sprintf(
+ "%s/rest/api/2/issue/%s/comment", client.serverURL, idOrKey)
+
+ request, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ err := fmt.Errorf("Creating request %v", err)
+ return nil, err
+ }
+
+ query := request.URL.Query()
+ if maxResults > 0 {
+ query.Add("maxResults", fmt.Sprintf("%d", maxResults))
+ }
+ if startAt > 0 {
+ query.Add("startAt", fmt.Sprintf("%d", startAt))
+ }
+ request.URL.RawQuery = query.Encode()
+
+ if client.ctx != nil {
+ ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
+ defer cancel()
+ request = request.WithContext(ctx)
+ }
+
+ response, err := client.Do(request)
+ if err != nil {
+ err := fmt.Errorf("Performing request %v", err)
+ return nil, err
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode != http.StatusOK {
+ err := fmt.Errorf(
+ "HTTP response %d, query was %s", response.StatusCode,
+ request.URL.String())
+ return nil, err
+ }
+
+ var comments CommentPage
+
+ data, _ := ioutil.ReadAll(response.Body)
+ err = json.Unmarshal(data, &comments)
+ if err != nil {
+ err := fmt.Errorf("Decoding response %v", err)
+ return nil, err
+ }
+
+ return &comments, nil
+}
+
+// CommentIterator cursor within paginated results from the /comment endpoint
+type CommentIterator struct {
+ client *Client
+ idOrKey string
+ message *CommentPage
+ Err error
+
+ pageSize int
+ itemIdx int
+}
+
+// HasError returns true if the iterator is holding an error
+func (self *CommentIterator) HasError() bool {
+ if self.Err == errDone {
+ return false
+ }
+ if self.Err == nil {
+ return false
+ }
+ return true
+}
+
+// HasNext returns true if there is another item available in the result set
+func (self *CommentIterator) HasNext() bool {
+ return self.Err == nil && self.itemIdx < len(self.message.Comments)
+}
+
+// Next Return the next item in the result set and advance the iterator.
+// Advancing the iterator may require fetching a new page.
+func (self *CommentIterator) Next() *Comment {
+ if self.Err != nil {
+ return nil
+ }
+
+ comment := self.message.Comments[self.itemIdx]
+ if self.itemIdx+1 < len(self.message.Comments) {
+ // We still have an item left in the currently cached page
+ self.itemIdx++
+ } else {
+ if self.message.IsLastPage() {
+ self.Err = errDone
+ } else {
+ // There are still more pages to fetch, so fetch the next page and
+ // cache it
+ self.message, self.Err = self.client.GetComments(
+ self.idOrKey, self.pageSize, self.message.NextStartAt())
+ // NOTE(josh): we don't deal with the error now, we just cache it.
+ // HasNext() will return false and the caller can check the error
+ // afterward.
+ self.itemIdx = 0
+ }
+ }
+ return &comment
+}
+
+// IterComments returns an iterator over paginated comments within an issue
+func (client *Client) IterComments(
+ idOrKey string, pageSize int) *CommentIterator {
+ message, err := client.GetComments(idOrKey, pageSize, 0)
+
+ iter := &CommentIterator{
+ client: client,
+ idOrKey: idOrKey,
+ message: message,
+ Err: err,
+ pageSize: pageSize,
+ itemIdx: 0,
+ }
+
+ return iter
+}
+
+// GetChangeLog fetchs one page of the changelog for an issue via the
+// /issue/{IssueIdOrKey}/changelog endpoint (for JIRA cloud) or
+// /issue/{IssueIdOrKey} with (fields=*none&expand=changelog)
+// (for JIRA server)
+// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue
+func (client *Client) GetChangeLog(
+ idOrKey string, maxResults int, startAt int) (*ChangeLogPage, error) {
+ url := fmt.Sprintf(
+ "%s/rest/api/2/issue/%s/changelog", client.serverURL, idOrKey)
+
+ request, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ err := fmt.Errorf("Creating request %v", err)
+ return nil, err
+ }
+
+ query := request.URL.Query()
+ if maxResults > 0 {
+ query.Add("maxResults", fmt.Sprintf("%d", maxResults))
+ }
+ if startAt > 0 {
+ query.Add("startAt", fmt.Sprintf("%d", startAt))
+ }
+ request.URL.RawQuery = query.Encode()
+
+ if client.ctx != nil {
+ ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
+ defer cancel()
+ request = request.WithContext(ctx)
+ }
+
+ response, err := client.Do(request)
+ if err != nil {
+ err := fmt.Errorf("Performing request %v", err)
+ return nil, err
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode == http.StatusNotFound {
+ // The issue/{IssueIdOrKey}/changelog endpoint is only available on JIRA cloud
+ // products, not on JIRA server. In order to get the information we have to
+ // query the issue and ask for a changelog expansion. Unfortunately this means
+ // that the changelog is not paginated and we have to fetch the entire thing
+ // at once. Hopefully things don't break for very long changelogs.
+ issue, err := client.GetIssue(
+ idOrKey, []string{"*none"}, []string{"changelog"}, []string{})
+ if err != nil {
+ return nil, err
+ }
+
+ return &issue.ChangeLog, nil
+ }
+
+ if response.StatusCode != http.StatusOK {
+ err := fmt.Errorf(
+ "HTTP response %d, query was %s", response.StatusCode,
+ request.URL.String())
+ return nil, err
+ }
+
+ var changelog ChangeLogPage
+
+ data, _ := ioutil.ReadAll(response.Body)
+ err = json.Unmarshal(data, &changelog)
+ if err != nil {
+ err := fmt.Errorf("Decoding response %v", err)
+ return nil, err
+ }
+
+ // 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 (self *ChangeLogIterator) HasError() bool {
+ if self.Err == errDone {
+ return false
+ }
+ if self.Err == nil {
+ return false
+ }
+ return true
+}
+
+// HasNext returns true if there is another item available in the result set
+func (self *ChangeLogIterator) HasNext() bool {
+ return self.Err == nil && self.itemIdx < len(self.message.Entries)
+}
+
+// Next Return the next item in the result set and advance the iterator.
+// Advancing the iterator may require fetching a new page.
+func (self *ChangeLogIterator) Next() *ChangeLogEntry {
+ if self.Err != nil {
+ return nil
+ }
+
+ item := self.message.Entries[self.itemIdx]
+ if self.itemIdx+1 < len(self.message.Entries) {
+ // We still have an item left in the currently cached page
+ self.itemIdx++
+ } else {
+ if self.message.IsLastPage() {
+ self.Err = errDone
+ } else {
+ // There are still more pages to fetch, so fetch the next page and
+ // cache it
+ self.message, self.Err = self.client.GetChangeLog(
+ self.idOrKey, self.pageSize, self.message.NextStartAt())
+ // NOTE(josh): we don't deal with the error now, we just cache it.
+ // HasNext() will return false and the caller can check the error
+ // afterward.
+ self.itemIdx = 0
+ }
+ }
+ return &item
+}
+
+// IterChangeLog returns an iterator over entries in the changelog for an issue
+func (client *Client) IterChangeLog(
+ idOrKey string, pageSize int) *ChangeLogIterator {
+ message, err := client.GetChangeLog(idOrKey, pageSize, 0)
+
+ iter := &ChangeLogIterator{
+ client: client,
+ idOrKey: idOrKey,
+ message: message,
+ Err: err,
+ pageSize: pageSize,
+ itemIdx: 0,
+ }
+
+ return iter
+}
+
+// GetProject returns the project JSON object given its id or key
+func (client *Client) GetProject(projectIDOrKey string) (*Project, error) {
+ url := fmt.Sprintf(
+ "%s/rest/api/2/project/%s", client.serverURL, projectIDOrKey)
+
+ request, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ if client.ctx != nil {
+ ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
+ defer cancel()
+ request = request.WithContext(ctx)
+ }
+
+ response, err := client.Do(request)
+ if err != nil {
+ return nil, err
+ }
+
+ defer response.Body.Close()
+
+ if response.StatusCode != http.StatusOK {
+ err := fmt.Errorf(
+ "HTTP response %d, query was %s", response.StatusCode, url)
+ return nil, err
+ }
+
+ var project Project
+
+ data, _ := ioutil.ReadAll(response.Body)
+ err = json.Unmarshal(data, &project)
+ if err != nil {
+ err := fmt.Errorf("Decoding response %v", err)
+ return nil, err
+ }
+
+ return &project, nil
+}
+
+// CreateIssue creates a new JIRA issue and returns it
+func (client *Client) CreateIssue(
+ projectIDOrKey, title, body string, extra map[string]interface{}) (
+ *IssueCreateResult, error) {
+
+ url := fmt.Sprintf("%s/rest/api/2/issue", client.serverURL)
+
+ fields := make(map[string]interface{})
+ fields["summary"] = title
+ fields["description"] = body
+ for key, value := range extra {
+ fields[key] = value
+ }
+
+ // If the project string is an integer than assume it is an ID. Otherwise it
+ // is a key.
+ _, err := strconv.Atoi(projectIDOrKey)
+ if err == nil {
+ fields["project"] = map[string]string{"id": projectIDOrKey}
+ } else {
+ fields["project"] = map[string]string{"key": projectIDOrKey}
+ }
+
+ message := make(map[string]interface{})
+ message["fields"] = fields
+
+ data, err := json.Marshal(message)
+ if err != nil {
+ return nil, err
+ }
+
+ request, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
+ if err != nil {
+ return nil, err
+ }
+
+ if client.ctx != nil {
+ ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
+ defer cancel()
+ request = request.WithContext(ctx)
+ }
+
+ response, err := client.Do(request)
+ if err != nil {
+ err := fmt.Errorf("Performing request %v", err)
+ return nil, err
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode != http.StatusCreated {
+ content, _ := ioutil.ReadAll(response.Body)
+ err := fmt.Errorf(
+ "HTTP response %d, query was %s\n data: %s\n response: %s",
+ response.StatusCode, request.URL.String(), data, content)
+ return nil, err
+ }
+
+ var result IssueCreateResult
+
+ data, _ = ioutil.ReadAll(response.Body)
+ err = json.Unmarshal(data, &result)
+ if err != nil {
+ err := fmt.Errorf("Decoding response %v", err)
+ return nil, err
+ }
+
+ return &result, nil
+}
+
+// UpdateIssueTitle changes the "summary" field of a JIRA issue
+func (client *Client) UpdateIssueTitle(
+ issueKeyOrID, title string) (time.Time, error) {
+
+ url := fmt.Sprintf(
+ "%s/rest/api/2/issue/%s", client.serverURL, issueKeyOrID)
+ var responseTime time.Time
+
+ // NOTE(josh): Since updates are a list of heterogeneous objects let's just
+ // manually build the JSON text
+ data, err := json.Marshal(title)
+ if err != nil {
+ return responseTime, err
+ }
+
+ var buffer bytes.Buffer
+ fmt.Fprintf(&buffer, `{"update":{"summary":[`)
+ fmt.Fprintf(&buffer, `{"set":%s}`, data)
+ fmt.Fprintf(&buffer, `]}}`)
+
+ data = buffer.Bytes()
+ request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data))
+ if err != nil {
+ return responseTime, err
+ }
+
+ response, err := client.Do(request)
+ if err != nil {
+ err := fmt.Errorf("Performing request %v", err)
+ return responseTime, err
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode != http.StatusNoContent {
+ content, _ := ioutil.ReadAll(response.Body)
+ err := fmt.Errorf(
+ "HTTP response %d, query was %s\n data: %s\n response: %s",
+ response.StatusCode, request.URL.String(), data, content)
+ return responseTime, err
+ }
+
+ dateHeader, ok := response.Header["Date"]
+ if !ok || len(dateHeader) != 1 {
+ // No "Date" header, or empty, or multiple of them. Regardless, we don't
+ // have a date we can return
+ return responseTime, nil
+ }
+
+ responseTime, err = http.ParseTime(dateHeader[0])
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ return responseTime, nil
+}
+
+// UpdateIssueBody changes the "description" field of a JIRA issue
+func (client *Client) UpdateIssueBody(
+ issueKeyOrID, body string) (time.Time, error) {
+
+ url := fmt.Sprintf(
+ "%s/rest/api/2/issue/%s", client.serverURL, issueKeyOrID)
+ var responseTime time.Time
+ // NOTE(josh): Since updates are a list of heterogeneous objects let's just
+ // manually build the JSON text
+ data, err := json.Marshal(body)
+ if err != nil {
+ return responseTime, err
+ }
+
+ var buffer bytes.Buffer
+ fmt.Fprintf(&buffer, `{"update":{"description":[`)
+ fmt.Fprintf(&buffer, `{"set":%s}`, data)
+ fmt.Fprintf(&buffer, `]}}`)
+
+ data = buffer.Bytes()
+ request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data))
+ if err != nil {
+ return responseTime, err
+ }
+
+ if client.ctx != nil {
+ ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
+ defer cancel()
+ request = request.WithContext(ctx)
+ }
+
+ response, err := client.Do(request)
+ if err != nil {
+ err := fmt.Errorf("Performing request %v", err)
+ return responseTime, err
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode != http.StatusNoContent {
+ content, _ := ioutil.ReadAll(response.Body)
+ err := fmt.Errorf(
+ "HTTP response %d, query was %s\n data: %s\n response: %s",
+ response.StatusCode, request.URL.String(), data, content)
+ return responseTime, err
+ }
+
+ dateHeader, ok := response.Header["Date"]
+ if !ok || len(dateHeader) != 1 {
+ // No "Date" header, or empty, or multiple of them. Regardless, we don't
+ // have a date we can return
+ return responseTime, nil
+ }
+
+ responseTime, err = http.ParseTime(dateHeader[0])
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ return responseTime, nil
+}
+
+// AddComment adds a new comment to an issue (and returns it).
+func (client *Client) AddComment(issueKeyOrID, body string) (*Comment, error) {
+ url := fmt.Sprintf(
+ "%s/rest/api/2/issue/%s/comment", client.serverURL, issueKeyOrID)
+
+ params := CommentCreate{Body: body}
+ data, err := json.Marshal(params)
+ if err != nil {
+ return nil, err
+ }
+
+ request, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
+ if err != nil {
+ return nil, err
+ }
+
+ if client.ctx != nil {
+ ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
+ defer cancel()
+ request = request.WithContext(ctx)
+ }
+
+ response, err := client.Do(request)
+ if err != nil {
+ err := fmt.Errorf("Performing request %v", err)
+ return nil, err
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode != http.StatusCreated {
+ content, _ := ioutil.ReadAll(response.Body)
+ err := fmt.Errorf(
+ "HTTP response %d, query was %s\n data: %s\n response: %s",
+ response.StatusCode, request.URL.String(), data, content)
+ return nil, err
+ }
+
+ var result Comment
+
+ data, _ = ioutil.ReadAll(response.Body)
+ err = json.Unmarshal(data, &result)
+ if err != nil {
+ err := fmt.Errorf("Decoding response %v", err)
+ return nil, err
+ }
+
+ return &result, nil
+}
+
+// UpdateComment changes the text of a comment
+func (client *Client) UpdateComment(issueKeyOrID, commentID, body string) (
+ *Comment, error) {
+ url := fmt.Sprintf(
+ "%s/rest/api/2/issue/%s/comment/%s", client.serverURL, issueKeyOrID,
+ commentID)
+
+ params := CommentCreate{Body: body}
+ data, err := json.Marshal(params)
+ if err != nil {
+ return nil, err
+ }
+
+ request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data))
+ if err != nil {
+ return nil, err
+ }
+
+ if client.ctx != nil {
+ ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
+ defer cancel()
+ request = request.WithContext(ctx)
+ }
+
+ response, err := client.Do(request)
+ if err != nil {
+ err := fmt.Errorf("Performing request %v", err)
+ return nil, err
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode != http.StatusOK {
+ err := fmt.Errorf(
+ "HTTP response %d, query was %s", response.StatusCode,
+ request.URL.String())
+ return nil, err
+ }
+
+ var result Comment
+
+ data, _ = ioutil.ReadAll(response.Body)
+ err = json.Unmarshal(data, &result)
+ if err != nil {
+ err := fmt.Errorf("Decoding response %v", err)
+ return nil, err
+ }
+
+ return &result, nil
+}
+
+// UpdateLabels changes labels for an issue
+func (client *Client) UpdateLabels(
+ issueKeyOrID string, added, removed []bug.Label) (time.Time, error) {
+ url := fmt.Sprintf(
+ "%s/rest/api/2/issue/%s/", client.serverURL, issueKeyOrID)
+ var responseTime time.Time
+
+ // NOTE(josh): Since updates are a list of heterogeneous objects let's just
+ // manually build the JSON text
+ var buffer bytes.Buffer
+ fmt.Fprintf(&buffer, `{"update":{"labels":[`)
+ first := true
+ for _, label := range added {
+ if !first {
+ fmt.Fprintf(&buffer, ",")
+ }
+ fmt.Fprintf(&buffer, `{"add":"%s"}`, label)
+ first = false
+ }
+ for _, label := range removed {
+ if !first {
+ fmt.Fprintf(&buffer, ",")
+ }
+ fmt.Fprintf(&buffer, `{"remove":"%s"}`, label)
+ first = false
+ }
+ fmt.Fprintf(&buffer, "]}}")
+
+ data := buffer.Bytes()
+ request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data))
+ if err != nil {
+ return responseTime, err
+ }
+
+ if client.ctx != nil {
+ ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
+ defer cancel()
+ request = request.WithContext(ctx)
+ }
+
+ response, err := client.Do(request)
+ if err != nil {
+ err := fmt.Errorf("Performing request %v", err)
+ return responseTime, err
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode != http.StatusNoContent {
+ content, _ := ioutil.ReadAll(response.Body)
+ err := fmt.Errorf(
+ "HTTP response %d, query was %s\n data: %s\n response: %s",
+ response.StatusCode, request.URL.String(), data, content)
+ return responseTime, err
+ }
+
+ dateHeader, ok := response.Header["Date"]
+ if !ok || len(dateHeader) != 1 {
+ // No "Date" header, or empty, or multiple of them. Regardless, we don't
+ // have a date we can return
+ return responseTime, nil
+ }
+
+ responseTime, err = http.ParseTime(dateHeader[0])
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ return responseTime, nil
+}
+
+// GetTransitions returns a list of available transitions for an issue
+func (client *Client) GetTransitions(issueKeyOrID string) (
+ *TransitionList, error) {
+
+ url := fmt.Sprintf(
+ "%s/rest/api/2/issue/%s/transitions", client.serverURL, issueKeyOrID)
+
+ request, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ err := fmt.Errorf("Creating request %v", err)
+ return nil, err
+ }
+
+ if client.ctx != nil {
+ ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
+ defer cancel()
+ request = request.WithContext(ctx)
+ }
+
+ response, err := client.Do(request)
+ if err != nil {
+ err := fmt.Errorf("Performing request %v", err)
+ return nil, err
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode != http.StatusOK {
+ err := fmt.Errorf(
+ "HTTP response %d, query was %s", response.StatusCode,
+ request.URL.String())
+ return nil, err
+ }
+
+ var message TransitionList
+
+ data, _ := ioutil.ReadAll(response.Body)
+ err = json.Unmarshal(data, &message)
+ if err != nil {
+ err := fmt.Errorf("Decoding response %v", err)
+ return nil, err
+ }
+
+ return &message, nil
+}
+
+func getTransitionTo(
+ tlist *TransitionList, desiredStateNameOrID string) *Transition {
+ for _, transition := range tlist.Transitions {
+ if transition.To.ID == desiredStateNameOrID {
+ return &transition
+ } else if transition.To.Name == desiredStateNameOrID {
+ return &transition
+ }
+ }
+ return nil
+}
+
+// DoTransition changes the "status" of an issue
+func (client *Client) DoTransition(
+ issueKeyOrID string, transitionID string) (time.Time, error) {
+ url := fmt.Sprintf(
+ "%s/rest/api/2/issue/%s/transitions", client.serverURL, issueKeyOrID)
+ var responseTime time.Time
+
+ // TODO(josh)[767ee72]: Figure out a good way to "configure" the
+ // open/close state mapping. It would be *great* if we could actually
+ // *compute* the necessary transitions and prompt for missing metatdata...
+ // but that is complex
+ var buffer bytes.Buffer
+ fmt.Fprintf(&buffer,
+ `{"transition":{"id":"%s"}, "resolution": {"name": "Done"}}`,
+ transitionID)
+ request, err := http.NewRequest("POST", url, bytes.NewBuffer(buffer.Bytes()))
+ if 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..406bed31
--- /dev/null
+++ b/bridge/jira/config.go
@@ -0,0 +1,232 @@
+package jira
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "os"
+ "strconv"
+ "strings"
+
+ "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.
+
+Which authentication mechanism should this bridge use?
+[1]: SESSION
+[2]: TOKEN
+`
+const credentialsText = `
+How would you like to store your JIRA login credentials?
+[1]: sidecar JSON file: Your credentials will be stored in a JSON sidecar next
+ to your git config. Note that it will contain your JIRA password in clear
+ text.
+[2]: git-config: Your credentials will be stored in the git config. Note that
+ it will contain your JIRA password in clear text.
+[3]: username in config, askpass: Your username will be stored in the git
+ config. We will ask you for your password each time you execute the bridge.
+`
+
+// Configure sets up the bridge configuration
+func (g *Jira) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) {
+ conf := make(core.Configuration)
+ var err error
+ var url string
+ var project string
+ var credentialsFile string
+ var username string
+ var password string
+ var serverURL string
+
+ // if params.Token != "" || params.TokenStdin {
+ // return nil, fmt.Errorf(
+ // "JIRA session tokens are extremely short lived. We don't store them " +
+ // "in the configuration, so they are not valid for this bridge.")
+ // }
+
+ if params.Owner != "" {
+ return nil, fmt.Errorf("owner doesn't make sense for jira")
+ }
+
+ serverURL = params.URL
+ if url == "" {
+ // terminal prompt
+ serverURL, err = prompt("JIRA server URL", "URL")
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ project = params.Project
+ if project == "" {
+ project, err = prompt("JIRA project key", "project")
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ credType, err := promptOptions(credTypeText, 1, 2)
+ if err != nil {
+ return nil, err
+ }
+
+ choice, err := promptOptions(credentialsText, 1, 3)
+ if err != nil {
+ return nil, err
+ }
+
+ if choice == 1 {
+ credentialsFile, err = prompt("Credentials file path", "path")
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ username, err = prompt("JIRA username", "username")
+ if err != nil {
+ return nil, err
+ }
+
+ password, err = input.PromptPassword("Password", "password", input.Required)
+ if err != nil {
+ return nil, err
+ }
+
+ jsonData, err := json.Marshal(
+ &SessionQuery{Username: username, Password: password})
+ if err != nil {
+ return nil, err
+ }
+
+ conf[core.ConfigKeyTarget] = target
+ conf[keyServer] = serverURL
+ conf[keyProject] = project
+
+ switch credType {
+ case 1:
+ conf[keyCredentialsType] = "SESSION"
+ case 2:
+ conf[keyCredentialsType] = "TOKEN"
+ }
+
+ switch choice {
+ case 1:
+ conf[keyCredentialsFile] = credentialsFile
+ err = ioutil.WriteFile(credentialsFile, jsonData, 0644)
+ if err != nil {
+ return nil, errors.Wrap(
+ err, fmt.Sprintf("Unable to write credentials to %s", credentialsFile))
+ }
+ 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
+}
+
+func promptOptions(description string, minVal, maxVal int) (int, error) {
+ fmt.Print(description)
+ for {
+ fmt.Print("Select option: ")
+
+ line, err := bufio.NewReader(os.Stdin).ReadString('\n')
+ fmt.Println()
+ if err != nil {
+ return -1, err
+ }
+
+ line = strings.TrimRight(line, "\n")
+
+ index, err := strconv.Atoi(line)
+ if err != nil {
+ fmt.Println("invalid input")
+ continue
+ }
+ if index < minVal || index > maxVal {
+ fmt.Println("invalid choice")
+ continue
+ }
+
+ return index, nil
+ }
+}
+
+func prompt(description, name string) (string, error) {
+ for {
+ fmt.Printf("%s: ", description)
+
+ line, err := bufio.NewReader(os.Stdin).ReadString('\n')
+ if err != nil {
+ return "", err
+ }
+
+ line = strings.TrimRight(line, "\n")
+ if line == "" {
+ fmt.Printf("%s is empty\n", name)
+ continue
+ }
+
+ return line, nil
+ }
+}
diff --git a/bridge/jira/export.go b/bridge/jira/export.go
new file mode 100644
index 00000000..f2b9d507
--- /dev/null
+++ b/bridge/jira/export.go
@@ -0,0 +1,456 @@
+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 (self *jiraExporter) Init(repo *cache.RepoCache,
+ conf core.Configuration) error {
+ self.conf = conf
+ self.identityClient = make(map[entity.Id]*Client)
+ self.cachedOperationIDs = make(map[entity.Id]string)
+ self.cachedLabels = make(map[string]string)
+ return nil
+}
+
+// getIdentityClient return an API client configured with the credentials
+// of the given identity. If no client were found it will initialize it from
+// the known credentials map and cache it for next use
+func (self *jiraExporter) getIdentityClient(
+ ctx context.Context, id entity.Id) (*Client, error) {
+ client, ok := self.identityClient[id]
+ if ok {
+ return client, nil
+ }
+
+ client = NewClient(self.conf[keyServer], ctx)
+
+ // NOTE: as a future enhancement, the bridge would ideally be able to generate
+ // a separate session token for each user that we have stored credentials
+ // for. However we currently only support a single user.
+ if id != self.userIdentity {
+ return nil, ErrMissingCredentials
+ }
+ err := client.Login(self.conf)
+ if err != nil {
+ return nil, err
+ }
+
+ self.identityClient[id] = client
+ return client, nil
+}
+
+// ExportAll export all event made by the current user to Jira
+func (self *jiraExporter) ExportAll(
+ ctx context.Context, repo *cache.RepoCache, since time.Time) (
+ <-chan core.ExportResult, error) {
+
+ out := make(chan core.ExportResult)
+
+ user, err := repo.GetUserIdentity()
+ if err != nil {
+ return nil, err
+ }
+
+ // NOTE: this is currently only need to mock the credentials database in
+ // getIdentityClient.
+ self.userIdentity = user.Id()
+ client, err := self.getIdentityClient(ctx, user.Id())
+ if err != nil {
+ return nil, err
+ }
+
+ self.project, err = client.GetProject(self.conf[keyProject])
+ if err != nil {
+ return nil, err
+ }
+
+ go func() {
+ defer close(out)
+
+ var allIdentitiesIds []entity.Id
+ for id := range self.identityClient {
+ allIdentitiesIds = append(allIdentitiesIds, id)
+ }
+
+ allBugsIds := repo.AllBugsIds()
+
+ for _, id := range allBugsIds {
+ b, err := repo.ResolveBug(id)
+ if err != nil {
+ out <- core.NewExportError(errors.Wrap(err, "can't load bug"), id)
+ return
+ }
+
+ select {
+
+ case <-ctx.Done():
+ // stop iterating if context cancel function is called
+ return
+
+ default:
+ snapshot := b.Snapshot()
+
+ // ignore issues whose last modification date is before the query date
+ // TODO: compare the Lamport time instead of using the unix time
+ if snapshot.CreatedAt.Before(since) {
+ out <- core.NewExportNothing(b.Id(), "bug created before the since date")
+ continue
+ }
+
+ if snapshot.HasAnyActor(allIdentitiesIds...) {
+ // try to export the bug and it associated events
+ err := self.exportBug(ctx, b, since, out)
+ if err != nil {
+ out <- core.NewExportError(errors.Wrap(err, "can't export bug"), id)
+ return
+ }
+ } else {
+ out <- core.NewExportNothing(id, "not an actor")
+ }
+ }
+ }
+ }()
+
+ return out, nil
+}
+
+// exportBug publish bugs and related events
+func (self *jiraExporter) exportBug(
+ ctx context.Context, b *cache.BugCache, since time.Time,
+ out chan<- core.ExportResult) 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{self.project.ID, self.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 := self.getIdentityClient(ctx, author.Id())
+ if err != nil {
+ // if bug is not yet exported and we do not have the author's credentials
+ // then there is nothing we can do, so just skip this bug
+ out <- core.NewExportNothing(
+ b.Id(), fmt.Sprintf("missing author token for user %.8s",
+ author.Id().String()))
+ return err
+ }
+
+ // Load any custom fields required to create an issue from the git
+ // config file.
+ fields := make(map[string]interface{})
+ defaultFields, hasConf := self.conf[keyCreateDefaults]
+ if hasConf {
+ 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 := self.conf[keyCreateGitBug]
+ if hasConf {
+ // If the git configuration also indicates it, we can assign the git-bug
+ // id to a custom field to assist in integrations
+ fields[bugIDField] = b.Id().String()
+ }
+
+ // create bug
+ result, err := client.CreateIssue(
+ self.project.ID, createOp.Title, createOp.Message, fields)
+ if err != nil {
+ err := errors.Wrap(err, "exporting jira issue")
+ out <- core.NewExportError(err, b.Id())
+ return err
+ }
+
+ id := result.ID
+ out <- core.NewExportBug(b.Id())
+ // mark bug creation operation as exported
+ err = markOperationAsExported(
+ b, createOp.Id(), id, self.project.Key, time.Time{})
+ if err != nil {
+ err := errors.Wrap(err, "marking operation as exported")
+ out <- core.NewExportError(err, b.Id())
+ return 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
+ self.cachedOperationIDs[createOp.Id()] = bugJiraID
+
+ // lookup the mapping from git-bug "status" to JIRA "status" id
+ statusMap, err := getStatusMap(self.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 {
+ self.cachedOperationIDs[op.Id()] = id
+ continue
+ }
+
+ opAuthor := op.GetAuthor()
+ client, err := self.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
+ self.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 := self.cachedOperationIDs[opr.Target]
+ if !ok {
+ // Since an edit has to come after the creation, we expect we would
+ // have cached the creation id.
+ panic("unexpected error: comment id not found")
+ }
+ comment, err := client.UpdateComment(bugJiraID, commentID, opr.Message)
+ if err != nil {
+ err := errors.Wrap(err, "editing comment")
+ out <- core.NewExportError(err, b.Id())
+ return 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, self.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..bc1bf428
--- /dev/null
+++ b/bridge/jira/import.go
@@ -0,0 +1,646 @@
+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 (gi *jiraImporter) Init(repo *cache.RepoCache,
+ conf core.Configuration) error {
+ gi.conf = conf
+ return nil
+}
+
+// ImportAll iterate over all the configured repository issues and ensure the
+// creation of the missing issues / timeline items / edits / label events ...
+func (self *jiraImporter) ImportAll(
+ ctx context.Context, repo *cache.RepoCache, since time.Time) (
+ <-chan core.ImportResult, error) {
+
+ sinceStr := since.Format("2006-01-02 15:04")
+ serverURL := self.conf[keyServer]
+ project := self.conf[keyProject]
+ // TODO(josh)[da52062]: Validate token and if it is expired then prompt for
+ // credentials and generate a new one
+ out := make(chan core.ImportResult)
+ self.out = out
+
+ go func() {
+ defer close(self.out)
+
+ client := NewClient(serverURL, ctx)
+ err := client.Login(self.conf)
+ if err != nil {
+ out <- core.NewImportError(err, "")
+ return
+ }
+
+ message, err := client.Search(
+ fmt.Sprintf("project=%s AND updatedDate>\"%s\"", project, sinceStr), 0, 0)
+ if err != nil {
+ out <- core.NewImportError(err, "")
+ return
+ }
+
+ fmt.Printf("So far so good. Have %d issues to import\n", message.Total)
+
+ jql := fmt.Sprintf("project=%s AND updatedDate>\"%s\"", project, sinceStr)
+ var searchIter *SearchIterator
+ for searchIter =
+ client.IterSearch(jql, defaultPageSize); searchIter.HasNext(); {
+ issue := searchIter.Next()
+ bug, err := self.ensureIssue(repo, *issue)
+ if err != nil {
+ err := fmt.Errorf("issue creation: %v", err)
+ out <- core.NewImportError(err, "")
+ return
+ }
+
+ var commentIter *CommentIterator
+ for commentIter =
+ client.IterComments(issue.ID, defaultPageSize); commentIter.HasNext(); {
+ comment := commentIter.Next()
+ err := self.ensureComment(repo, bug, *comment)
+ if err != nil {
+ out <- core.NewImportError(err, "")
+ }
+ }
+ if commentIter.HasError() {
+ out <- core.NewImportError(commentIter.Err, "")
+ }
+
+ snapshot := bug.Snapshot()
+ opIdx := 0
+
+ var changelogIter *ChangeLogIterator
+ for changelogIter =
+ client.IterChangeLog(issue.ID, defaultPageSize); changelogIter.HasNext(); {
+ changelogEntry := changelogIter.Next()
+
+ // Advance the operation iterator up to the first operation which has
+ // an export date not before the changelog entry date. If the changelog
+ // entry was created in response to an exported operation, then this
+ // will be that operation.
+ var exportTime time.Time
+ for ; opIdx < len(snapshot.Operations); opIdx++ {
+ exportTimeStr, hasTime := snapshot.Operations[opIdx].GetMetadata(
+ keyJiraExportTime)
+ if !hasTime {
+ continue
+ }
+ exportTime, err = http.ParseTime(exportTimeStr)
+ if err != nil {
+ continue
+ }
+ if !exportTime.Before(changelogEntry.Created.Time) {
+ break
+ }
+ }
+ if opIdx < len(snapshot.Operations) {
+ err = self.ensureChange(
+ repo, bug, *changelogEntry, snapshot.Operations[opIdx])
+ } else {
+ err = self.ensureChange(repo, bug, *changelogEntry, nil)
+ }
+ if err != nil {
+ out <- core.NewImportError(err, "")
+ }
+
+ }
+ if changelogIter.HasError() {
+ out <- core.NewImportError(changelogIter.Err, "")
+ }
+
+ if !bug.NeedCommit() {
+ out <- core.NewImportNothing(bug.Id(), "no imported operation")
+ } else if err := bug.Commit(); err != nil {
+ err = fmt.Errorf("bug commit: %v", err)
+ out <- core.NewImportError(err, "")
+ return
+ }
+ }
+ if searchIter.HasError() {
+ out <- core.NewImportError(searchIter.Err, "")
+ }
+ }()
+
+ return out, nil
+}
+
+// Create a bug.Person from a JIRA user
+func (self *jiraImporter) ensurePerson(
+ repo *cache.RepoCache, user User) (*cache.IdentityCache, error) {
+
+ // Look first in the cache
+ i, err := repo.ResolveIdentityImmutableMetadata(
+ keyJiraUser, string(user.Key))
+ if err == nil {
+ return i, nil
+ }
+ if _, ok := err.(entity.ErrMultipleMatch); ok {
+ return nil, err
+ }
+
+ i, err = repo.NewIdentityRaw(
+ user.DisplayName,
+ user.EmailAddress,
+ user.Key,
+ map[string]string{
+ keyJiraUser: string(user.Key),
+ },
+ )
+
+ if err != nil {
+ return nil, err
+ }
+
+ self.out <- core.NewImportIdentity(i.Id())
+ return i, nil
+}
+
+// Create a bug.Bug based from a JIRA issue
+func (self *jiraImporter) ensureIssue(
+ repo *cache.RepoCache, issue Issue) (*cache.BugCache, error) {
+ author, err := self.ensurePerson(repo, issue.Fields.Creator)
+ if err != nil {
+ return nil, err
+ }
+
+ 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: self.conf[keyProject],
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ self.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 (self *jiraImporter) ensureComment(repo *cache.RepoCache,
+ b *cache.BugCache, item Comment) error {
+ // ensure person
+ author, err := self.ensurePerson(repo, item.Author)
+ if err != nil {
+ return err
+ }
+
+ targetOpID, err := b.ResolveOperationWithMetadata(
+ keyJiraID, item.ID)
+ if err != nil && 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
+ }
+
+ self.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 := self.ensurePerson(repo, item.UpdateAuthor)
+ if err != nil {
+ return err
+ }
+
+ // comment edition
+ cleanText, err := text.Cleanup(string(item.Body))
+ if err != nil {
+ return err
+ }
+ op, err := b.EditCommentRaw(
+ editor,
+ item.Updated.Unix(),
+ targetOpID,
+ cleanText,
+ map[string]string{
+ keyJiraID: derivedID,
+ },
+ )
+
+ if err != nil {
+ return err
+ }
+
+ self.out <- core.NewImportCommentEdition(op.Id())
+
+ return nil
+}
+
+// Return a unique string derived from a unique jira id and an index into the
+// data referred to by that jira id.
+func getIndexDerivedID(jiraID string, idx int) string {
+ return fmt.Sprintf("%s-%d", jiraID, idx)
+}
+
+func labelSetsMatch(jiraSet []string, gitbugSet []bug.Label) bool {
+ if len(jiraSet) != len(gitbugSet) {
+ return false
+ }
+
+ sort.Strings(jiraSet)
+ gitbugStrSet := make([]string, len(gitbugSet))
+ for idx, label := range gitbugSet {
+ gitbugStrSet[idx] = label.String()
+ }
+ sort.Strings(gitbugStrSet)
+
+ for idx, value := range jiraSet {
+ if value != gitbugStrSet[idx] {
+ return false
+ }
+ }
+
+ return true
+}
+
+// Create a bug.Operation (or a series of operations) from a JIRA changelog
+// entry
+func (self *jiraImporter) ensureChange(
+ repo *cache.RepoCache, b *cache.BugCache, entry ChangeLogEntry,
+ potentialOp bug.Operation) error {
+
+ // If we have an operation which is already mapped to the entire changelog
+ // entry then that means this changelog entry was induced by an export
+ // operation and we've already done the match, so we skip this one
+ _, err := b.ResolveOperationWithMetadata(keyJiraOperationID, entry.ID)
+ if err == nil {
+ return nil
+ } else if err != cache.ErrNoMatchingOp {
+ return err
+ }
+
+ // In general, multiple fields may be changed in changelog entry on
+ // JIRA. For example, when an issue is closed both its "status" and its
+ // "resolution" are updated within a single changelog entry.
+ // I don't thing git-bug has a single operation to modify an arbitrary
+ // number of fields in one go, so we break up the single JIRA changelog
+ // entry into individual field updates.
+ author, err := self.ensurePerson(repo, entry.Author)
+ if err != nil {
+ return err
+ }
+
+ if len(entry.Items) < 1 {
+ return fmt.Errorf("Received changelog entry with no item! (%s)", entry.ID)
+ }
+
+ statusMap, err := getStatusMapReverse(self.conf)
+ if err != nil {
+ return err
+ }
+
+ // NOTE(josh): first do an initial scan and see if any of the changed items
+ // matches the current potential operation. If it does, then we know that this
+ // 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
+ }
+
+ self.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
+ }
+ self.out <- core.NewImportStatusChange(op.Id())
+
+ case bug.ClosedStatus.String():
+ op, err := b.CloseRaw(
+ author,
+ entry.Created.Unix(),
+ map[string]string{
+ keyJiraID: entry.ID,
+ keyJiraOperationID: derivedID,
+ },
+ )
+ if err != nil {
+ return err
+ }
+ self.out <- core.NewImportStatusChange(op.Id())
+ }
+ } else {
+ self.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
+ }
+
+ self.out <- core.NewImportTitleEdition(op.Id())
+
+ case "description":
+ // NOTE(josh): JIRA calls it "description", which sounds more like the
+ // title but it's actually the body
+ op, err := b.EditCreateCommentRaw(
+ author,
+ entry.Created.Unix(),
+ string(item.ToString),
+ map[string]string{
+ keyJiraID: entry.ID,
+ keyJiraOperationID: derivedID,
+ },
+ )
+ if err != nil {
+ return err
+ }
+
+ self.out <- core.NewImportCommentEdition(op.Id())
+
+ default:
+ self.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
+}