Merge pull request #250 from cheshirekow/cheshirekow-jira

Michael Muré created

Implement jira bridge

Change summary

README.md              |   46 
bridge/bridges.go      |    2 
bridge/core/export.go  |    6 
bridge/core/import.go  |    6 
bridge/jira/client.go  | 1461 ++++++++++++++++++++++++++++++++++++++++++++
bridge/jira/config.go  |  192 +++++
bridge/jira/export.go  |  475 ++++++++++++++
bridge/jira/import.go  |  655 +++++++++++++++++++
bridge/jira/jira.go    |  143 ++++
bug/op_edit_comment.go |   12 
cache/bug_cache.go     |   22 
doc/jira_bridge.md     |  377 +++++++++++
12 files changed, 3,368 insertions(+), 29 deletions(-)

Detailed changes

README.md 🔗

@@ -122,32 +122,32 @@ The web UI interact with the backend through a GraphQL API. The schema is availa
 
 ### Importer implementations
 
-| | Github | Gitlab | Launchpad |
-| --- | --- | --- | --- |
-| **incremental**<br/>(can import more than once) | :heavy_check_mark: | :heavy_check_mark: | :x: |
-| **with resume**<br/>(download only new data) | :heavy_check_mark: | :heavy_check_mark: | :x: |
-| **identities** | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
-| identities update | :x: | :x: | :x: |
-| **bug** | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
-| comments | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
-| comment editions | :heavy_check_mark: | :x: | :x: |
-| labels | :heavy_check_mark: | :heavy_check_mark: | :x: |
-| status | :heavy_check_mark: | :heavy_check_mark: | :x: |
-| title edition | :heavy_check_mark: | :heavy_check_mark: | :x: |
-| **media/files** | :x: | :x: | :x: |
-| **automated test suite** | :heavy_check_mark: | :heavy_check_mark: | :x: |
+| | Github | Gitlab | Launchpad | Jira |
+| --- | --- | --- | --- | --- |
+| **incremental**<br/>(can import more than once) | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: |
+| **with resume**<br/>(download only new data) | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: |
+| **identities** | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| identities update | :x: | :x: | :x: | :heavy_check_mark: |
+| **bug** | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| comments | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| comment editions | :heavy_check_mark: | :x: | :x: | :heavy_check_mark: |
+| labels | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: |
+| status | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: |
+| title edition | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: |
+| **media/files** | :x: | :x: | :x: | :x: |
+| **automated test suite** | :heavy_check_mark: | :heavy_check_mark: | :x: | :x: |
 
 ### Exporter implementations
 
-| | Github | Gitlab | Launchpad |
-| --- | --- | --- | --- |
-| **bug** | :heavy_check_mark: | :heavy_check_mark: | :x: |
-| comments | :heavy_check_mark: | :heavy_check_mark: | :x: |
-| comment editions | :heavy_check_mark: | :heavy_check_mark: | :x: |
-| labels | :heavy_check_mark: | :heavy_check_mark: | :x: |
-| status | :heavy_check_mark: | :heavy_check_mark: | :x: |
-| title edition | :heavy_check_mark: | :heavy_check_mark: | :x: |
-| **automated test suite** | :heavy_check_mark: | :heavy_check_mark: | :x: |
+| | Github | Gitlab | Launchpad | Jira |
+| --- | --- | --- | --- | --- |
+| **bug** | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: |
+| comments | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: |
+| comment editions | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: |
+| labels | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: |
+| status | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: |
+| title edition | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: |
+| **automated test suite** | :heavy_check_mark: | :heavy_check_mark: | :x: | :x: |
 
 #### Bridge usage
 

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

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

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

bridge/jira/client.go 🔗

@@ -0,0 +1,1461 @@
+package jira
+
+import (
+	"bytes"
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/http/cookiejar"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/pkg/errors"
+
+	"github.com/MichaelMure/git-bug/bug"
+)
+
+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
+}
+
+// Time is just a time.Time with a JSON serialization
+type Time struct {
+	time.Time
+}
+
+// UnmarshalJSON parses an RFC3339 date string into a time object
+// borrowed from: https://stackoverflow.com/a/39180230/141023
+func (t *Time) 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)
+	t.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      Time   `json:"created"`
+	Updated      Time   `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 (cp *CommentPage) NextStartAt() int {
+	return cp.StartAt + len(cp.Comments)
+}
+
+// IsLastPage return true if there are no more items beyond this page
+func (cp *CommentPage) IsLastPage() bool {
+	return cp.NextStartAt() >= cp.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     Time        `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 Time            `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 (clp *ChangeLogPage) NextStartAt() int {
+	return clp.StartAt + len(clp.Entries)
+}
+
+// IsLastPage return true if there are no more items beyond this page
+func (clp *ChangeLogPage) IsLastPage() bool {
+	// NOTE(josh): The "isLast" field is returned on JIRA cloud, but not on
+	// JIRA server. If we can distinguish which one we are working with, we can
+	// possibly rely on that instead.
+	return clp.NextStartAt() >= clp.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 (sr *SearchResult) NextStartAt() int {
+	return sr.StartAt + len(sr.Issues)
+}
+
+// IsLastPage return true if there are no more items beyond this page
+func (sr *SearchResult) IsLastPage() bool {
+	return sr.NextStartAt() >= sr.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        Time   `json:"buildDate"`
+	ServerTime       Time   `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 API endpoints
+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(ctx context.Context, serverURL string) *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 endpoint and get a session cookie
+func (client *Client) Login(credType, login, password string) error {
+	switch credType {
+	case "SESSION":
+		return client.RefreshSessionToken(login, password)
+	case "TOKEN":
+		return client.SetTokenCredentials(login, password)
+	default:
+		panic("unknown Jira cred type")
+	}
+}
+
+// RefreshSessionToken formulate the JSON request object from the user
+// credentials and POST it to the /session endpoint and get a session cookie
+func (client *Client) RefreshSessionToken(username, password string) error {
+	params := SessionQuery{
+		Username: username,
+		Password: password,
+	}
+
+	data, err := json.Marshal(params)
+	if err != nil {
+		return err
+	}
+
+	return client.RefreshSessionTokenRaw(data)
+}
+
+// SetTokenCredentials POST credentials to the /session endpoint and get a
+// session cookie
+func (client *Client) SetTokenCredentials(username, password string) error {
+	switch transport := client.Transport.(type) {
+	case *ClientTransport:
+		transport.SetCredentials(username, password)
+	default:
+		return fmt.Errorf("Invalid transport type")
+	}
+	return nil
+}
+
+// RefreshSessionTokenRaw POST credentials to the /session endpoint and get a
+// session cookie
+func (client *Client) RefreshSessionTokenRaw(credentialsJSON []byte) error {
+	postURL := fmt.Sprintf("%s/rest/auth/1/session", client.serverURL)
+
+	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 fetch one page of the changelog for an issue via the
+// /issue/{IssueIdOrKey}/changelog endpoint (for JIRA cloud) or
+// /issue/{IssueIdOrKey} with (fields=*none&expand=changelog)
+// (for JIRA server)
+// 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() (Time, error) {
+	var result Time
+	info, err := client.GetServerInfo()
+	if err != nil {
+		return result, err
+	}
+	return info.ServerTime, nil
+}

bridge/jira/config.go 🔗

@@ -0,0 +1,192 @@
+package jira
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
+	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/input"
+	"github.com/MichaelMure/git-bug/repository"
+)
+
+const moreConfigText = `
+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.`
+
+func (*Jira) ValidParams() map[string]interface{} {
+	return map[string]interface{}{
+		"BaseURL":    nil,
+		"Login":      nil,
+		"CredPrefix": nil,
+		"Project":    nil,
+	}
+}
+
+// Configure sets up the bridge configuration
+func (j *Jira) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) {
+	var err error
+
+	baseURL := params.BaseURL
+	if baseURL == "" {
+		// terminal prompt
+		baseURL, err = input.Prompt("JIRA server URL", "URL", input.Required, input.IsURL)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	project := params.Project
+	if project == "" {
+		project, err = input.Prompt("JIRA project key", "project", input.Required)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	fmt.Println(credTypeText)
+	credTypeInput, err := input.PromptChoice("Authentication mechanism", []string{"SESSION", "TOKEN"})
+	if err != nil {
+		return nil, err
+	}
+	credType := []string{"SESSION", "TOKEN"}[credTypeInput]
+
+	var login string
+	var cred auth.Credential
+
+	switch {
+	case params.CredPrefix != "":
+		cred, err = auth.LoadWithPrefix(repo, params.CredPrefix)
+		if err != nil {
+			return nil, err
+		}
+		l, ok := cred.GetMetadata(auth.MetaKeyLogin)
+		if !ok {
+			return nil, fmt.Errorf("credential doesn't have a login")
+		}
+		login = l
+	default:
+		login := params.Login
+		if login == "" {
+			// TODO: validate username
+			login, err = input.Prompt("JIRA login", "login", input.Required)
+			if err != nil {
+				return nil, err
+			}
+		}
+		cred, err = promptCredOptions(repo, login, baseURL)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	conf := make(core.Configuration)
+	conf[core.ConfigKeyTarget] = target
+	conf[confKeyBaseUrl] = baseURL
+	conf[confKeyProject] = project
+	conf[confKeyCredentialType] = credType
+
+	err = j.ValidateConfig(conf)
+	if err != nil {
+		return nil, err
+	}
+
+	fmt.Printf("Attempting to login with credentials...\n")
+	client, err := buildClient(context.TODO(), baseURL, credType, cred)
+	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, baseURL, login)
+	}
+
+	// don't forget to store the now known valid token
+	if !auth.IdExist(repo, cred.ID()) {
+		err = auth.Store(repo, cred)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	err = core.FinishConfig(repo, metaKeyJiraLogin, login)
+	if err != nil {
+		return nil, err
+	}
+
+	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[confKeyProject]; !ok {
+		return fmt.Errorf("missing %s key", confKeyProject)
+	}
+
+	return nil
+}
+
+func promptCredOptions(repo repository.RepoConfig, login, baseUrl string) (auth.Credential, error) {
+	creds, err := auth.List(repo,
+		auth.WithTarget(target),
+		auth.WithKind(auth.KindToken),
+		auth.WithMeta(auth.MetaKeyLogin, login),
+		auth.WithMeta(auth.MetaKeyBaseURL, baseUrl),
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	cred, index, err := input.PromptCredential(target, "password", creds, []string{
+		"enter my password",
+		"ask my password each time",
+	})
+	switch {
+	case err != nil:
+		return nil, err
+	case cred != nil:
+		return cred, nil
+	case index == 0:
+		password, err := input.PromptPassword("Password", "password", input.Required)
+		if err != nil {
+			return nil, err
+		}
+		lp := auth.NewLoginPassword(target, login, password)
+		lp.SetMetadata(auth.MetaKeyLogin, login)
+		lp.SetMetadata(auth.MetaKeyBaseURL, baseUrl)
+		return lp, nil
+	case index == 1:
+		l := auth.NewLogin(target, login)
+		l.SetMetadata(auth.MetaKeyLogin, login)
+		l.SetMetadata(auth.MetaKeyBaseURL, baseUrl)
+		return l, nil
+	default:
+		panic("missed case")
+	}
+}

bridge/jira/export.go 🔗

@@ -0,0 +1,475 @@
+package jira
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"os"
+	"time"
+
+	"github.com/pkg/errors"
+
+	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
+	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/identity"
+)
+
+var (
+	ErrMissingCredentials = errors.New("missing credentials")
+)
+
+// jiraExporter implement the Exporter interface
+type jiraExporter struct {
+	conf core.Configuration
+
+	// cache identities clients
+	identityClient map[entity.Id]*Client
+
+	// the mapping from git-bug "status" to JIRA "status" id
+	statusMap map[string]string
+
+	// cache identifiers used to speed up exporting operations
+	// cleared for each bug
+	cachedOperationIDs map[entity.Id]string
+
+	// cache labels used to speed up exporting labels events
+	cachedLabels map[string]string
+
+	// store JIRA project information
+	project *Project
+}
+
+// Init .
+func (je *jiraExporter) Init(ctx context.Context, repo *cache.RepoCache, conf core.Configuration) error {
+	je.conf = conf
+	je.identityClient = make(map[entity.Id]*Client)
+	je.cachedOperationIDs = make(map[entity.Id]string)
+	je.cachedLabels = make(map[string]string)
+
+	statusMap, err := getStatusMap(je.conf)
+	if err != nil {
+		return err
+	}
+	je.statusMap = statusMap
+
+	// preload all clients
+	err = je.cacheAllClient(ctx, repo)
+	if err != nil {
+		return err
+	}
+
+	if len(je.identityClient) == 0 {
+		return fmt.Errorf("no credentials for this bridge")
+	}
+
+	var client *Client
+	for _, c := range je.identityClient {
+		client = c
+		break
+	}
+
+	if client == nil {
+		panic("nil client")
+	}
+
+	je.project, err = client.GetProject(je.conf[confKeyProject])
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (je *jiraExporter) cacheAllClient(ctx context.Context, repo *cache.RepoCache) error {
+	creds, err := auth.List(repo,
+		auth.WithTarget(target),
+		auth.WithKind(auth.KindLoginPassword), auth.WithKind(auth.KindLogin),
+		auth.WithMeta(auth.MetaKeyBaseURL, je.conf[confKeyBaseUrl]),
+	)
+	if err != nil {
+		return err
+	}
+
+	for _, cred := range creds {
+		login, ok := cred.GetMetadata(auth.MetaKeyLogin)
+		if !ok {
+			_, _ = fmt.Fprintf(os.Stderr, "credential %s is not tagged with a Jira login\n", cred.ID().Human())
+			continue
+		}
+
+		user, err := repo.ResolveIdentityImmutableMetadata(metaKeyJiraLogin, login)
+		if err == identity.ErrIdentityNotExist {
+			continue
+		}
+		if err != nil {
+			return nil
+		}
+
+		if _, ok := je.identityClient[user.Id()]; !ok {
+			client, err := buildClient(ctx, je.conf[confKeyBaseUrl], je.conf[confKeyCredentialType], creds[0])
+			if err != nil {
+				return err
+			}
+			je.identityClient[user.Id()] = client
+		}
+	}
+
+	return nil
+}
+
+// getClientForIdentity return an API client configured with the credentials
+// of the given identity. If no client were found it will initialize it from
+// the known credentials and cache it for next use.
+func (je *jiraExporter) getClientForIdentity(userId entity.Id) (*Client, error) {
+	client, ok := je.identityClient[userId]
+	if ok {
+		return client, nil
+	}
+
+	return nil, ErrMissingCredentials
+}
+
+// ExportAll export all event made by the current user to Jira
+func (je *jiraExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ExportResult, error) {
+	out := make(chan core.ExportResult)
+
+	go func() {
+		defer close(out)
+
+		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, 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, 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(metaKeyJiraProject)
+	if ok && !stringInSlice(project, []string{je.project.ID, je.project.Key}) {
+		out <- core.NewExportNothing(
+			b.Id(), fmt.Sprintf("issue tagged with project: %s", project))
+		return nil
+	}
+
+	// get jira bug ID
+	jiraID, ok := snapshot.GetCreateMetadata(metaKeyJiraId)
+	if ok {
+		// will be used to mark operation related to a bug as exported
+		bugJiraID = jiraID
+	} else {
+		// check that we have credentials for operation author
+		client, err := je.getClientForIdentity(author.Id())
+		if err != nil {
+			// if bug is not yet exported and we do not have the author's credentials
+			// then there is nothing we can do, so just skip this bug
+			out <- core.NewExportNothing(
+				b.Id(), fmt.Sprintf("missing author credentials 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[confKeyCreateDefaults]
+		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[confKeyCreateGitBug]
+		if hasConf {
+			// If the git configuration also indicates it, we can assign the git-bug
+			// id to a custom field to assist in integrations
+			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
+
+	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(metaKeyJiraId); ok {
+			je.cachedOperationIDs[op.Id()] = id
+			continue
+		}
+
+		opAuthor := op.GetAuthor()
+		client, err := je.getClientForIdentity(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 := je.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{
+		metaKeyJiraId:      jiraID,
+		metaKeyJiraProject: jiraProject,
+	}
+	if !exportTime.IsZero() {
+		newMetadata[metaKeyJiraExportTime] = 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
+}

bridge/jira/import.go 🔗

@@ -0,0 +1,655 @@
+package jira
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"sort"
+	"strings"
+	"time"
+
+	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
+	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/util/text"
+)
+
+const (
+	defaultPageSize = 10
+)
+
+// jiraImporter implement the Importer interface
+type jiraImporter struct {
+	conf core.Configuration
+
+	client *Client
+
+	// send only channel
+	out chan<- core.ImportResult
+}
+
+// Init .
+func (ji *jiraImporter) Init(ctx context.Context, repo *cache.RepoCache, conf core.Configuration) error {
+	ji.conf = conf
+
+	var cred auth.Credential
+
+	// Prioritize LoginPassword credentials to avoid a prompt
+	creds, err := auth.List(repo,
+		auth.WithTarget(target),
+		auth.WithMeta(auth.MetaKeyBaseURL, conf[confKeyBaseUrl]),
+		auth.WithKind(auth.KindLoginPassword),
+	)
+	if err != nil {
+		return err
+	}
+	if len(creds) > 0 {
+		cred = creds[0]
+		goto end
+	}
+
+	creds, err = auth.List(repo,
+		auth.WithTarget(target),
+		auth.WithMeta(auth.MetaKeyBaseURL, conf[confKeyBaseUrl]),
+		auth.WithKind(auth.KindLogin),
+	)
+	if err != nil {
+		return err
+	}
+	if len(creds) > 0 {
+		cred = creds[0]
+	}
+
+end:
+	if cred == nil {
+		return fmt.Errorf("no credential for this bridge")
+	}
+
+	// TODO(josh)[da52062]: Validate token and if it is expired then prompt for
+	// credentials and generate a new one
+	ji.client, err = buildClient(ctx, conf[confKeyBaseUrl], conf[confKeyCredentialType], cred)
+	return err
+}
+
+// ImportAll iterate over all the configured repository issues and ensure the
+// creation of the missing issues / timeline items / edits / label events ...
+func (ji *jiraImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ImportResult, error) {
+	sinceStr := since.Format("2006-01-02 15:04")
+	project := ji.conf[confKeyProject]
+
+	out := make(chan core.ImportResult)
+	ji.out = out
+
+	go func() {
+		defer close(ji.out)
+
+		message, err := ji.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 =
+			ji.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 =
+				ji.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 =
+				ji.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(
+						metaKeyJiraExportTime)
+					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(
+		metaKeyJiraUser, 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,
+		"",
+		map[string]string{
+			metaKeyJiraUser: 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(metaKeyJiraId, 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,
+				metaKeyJiraId:      issue.ID,
+				metaKeyJiraKey:     issue.Key,
+				metaKeyJiraProject: ji.conf[confKeyProject],
+			})
+		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 Time) 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(
+		metaKeyJiraId, 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{
+				metaKeyJiraId: 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(metaKeyJiraId, 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{
+			metaKeyJiraId: 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(metaKeyJiraDerivedId, 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{
+					metaKeyJiraDerivedId: 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{
+					metaKeyJiraDerivedId: 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{
+					metaKeyJiraDerivedId: 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{
+					metaKeyJiraDerivedId: 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(metaKeyJiraDerivedId, 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{
+					metaKeyJiraId:        entry.ID,
+					metaKeyJiraDerivedId: 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{
+							metaKeyJiraId:        entry.ID,
+							metaKeyJiraDerivedId: 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{
+							metaKeyJiraId:        entry.ID,
+							metaKeyJiraDerivedId: 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{
+					metaKeyJiraId:        entry.ID,
+					metaKeyJiraDerivedId: 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{
+					metaKeyJiraId:        entry.ID,
+					metaKeyJiraDerivedId: 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[confKeyIDMap]
+	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[confKeyIDRevMap]
+	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
+}

bridge/jira/jira.go 🔗

@@ -0,0 +1,143 @@
+// Package jira contains the Jira bridge implementation
+package jira
+
+import (
+	"context"
+	"fmt"
+	"sort"
+	"time"
+
+	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
+	"github.com/MichaelMure/git-bug/input"
+)
+
+const (
+	target = "jira"
+
+	metaKeyJiraId         = "jira-id"
+	metaKeyJiraDerivedId  = "jira-derived-id"
+	metaKeyJiraKey        = "jira-key"
+	metaKeyJiraUser       = "jira-user"
+	metaKeyJiraProject    = "jira-project"
+	metaKeyJiraExportTime = "jira-export-time"
+	metaKeyJiraLogin      = "jira-login"
+
+	confKeyBaseUrl        = "base-url"
+	confKeyProject        = "project"
+	confKeyCredentialType = "credentials-type" // "SESSION" or "TOKEN"
+	confKeyIDMap          = "bug-id-map"
+	confKeyIDRevMap       = "bug-id-revmap"
+	// the issue type when exporting a new bug. Default is Story (10001)
+	confKeyCreateDefaults = "create-issue-defaults"
+	// if set, the bridge fill this JIRA field with the `git-bug` id when exporting
+	confKeyCreateGitBug = "create-issue-gitbug-id"
+
+	defaultTimeout = 60 * time.Second
+)
+
+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{}
+}
+
+func buildClient(ctx context.Context, baseURL string, credType string, cred auth.Credential) (*Client, error) {
+	client := NewClient(ctx, baseURL)
+
+	var login, password string
+
+	switch cred := cred.(type) {
+	case *auth.LoginPassword:
+		login = cred.Login
+		password = cred.Password
+	case *auth.Login:
+		login = cred.Login
+		p, err := input.PromptPassword(fmt.Sprintf("Password for %s", login), "password", input.Required)
+		if err != nil {
+			return nil, err
+		}
+		password = p
+	}
+
+	err := client.Login(credType, login, password)
+	if err != nil {
+		return nil, err
+	}
+
+	return client, nil
+}
+
+// stringInSlice returns true if needle is found in haystack
+func stringInSlice(needle string, haystack []string) bool {
+	for _, match := range haystack {
+		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
+}

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)
+}

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 {

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"
+        }
+      },
+...
+```