diff options
Diffstat (limited to 'bridge/jira/client.go')
-rw-r--r-- | bridge/jira/client.go | 1499 |
1 files changed, 1499 insertions, 0 deletions
diff --git a/bridge/jira/client.go b/bridge/jira/client.go new file mode 100644 index 00000000..15098a3c --- /dev/null +++ b/bridge/jira/client.go @@ -0,0 +1,1499 @@ +package jira + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/cookiejar" + "net/url" + "strconv" + "strings" + "time" + + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/bug" + "github.com/MichaelMure/git-bug/input" + "github.com/pkg/errors" +) + +var errDone = errors.New("Iteration Done") +var errTransitionNotFound = errors.New("Transition not found") +var errTransitionNotAllowed = errors.New("Transition not allowed") + +// ============================================================================= +// Extended JSON +// ============================================================================= + +const TimeFormat = "2006-01-02T15:04:05.999999999Z0700" + +// ParseTime parse an RFC3339 string with nanoseconds +func ParseTime(timeStr string) (time.Time, error) { + out, err := time.Parse(time.RFC3339Nano, timeStr) + if err != nil { + out, err = time.Parse(TimeFormat, timeStr) + } + return out, err +} + +// MyTime is just a time.Time with a JSON serialization +type MyTime struct { + time.Time +} + +// UnmarshalJSON parses an RFC3339 date string into a time object +// borrowed from: https://stackoverflow.com/a/39180230/141023 +func (self *MyTime) UnmarshalJSON(data []byte) (err error) { + str := string(data) + + // Get rid of the quotes "" around the value. + // A second option would be to include them in the date format string + // instead, like so below: + // time.Parse(`"`+time.RFC3339Nano+`"`, s) + str = str[1 : len(str)-1] + + timeObj, err := ParseTime(str) + self.Time = timeObj + return +} + +// ============================================================================= +// JSON Objects +// ============================================================================= + +// Session credential cookie name/value pair received after logging in and +// required to be sent on all subsequent requests +type Session struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// SessionResponse the JSON object returned from a /session query (login) +type SessionResponse struct { + Session Session `json:"session"` +} + +// SessionQuery the JSON object that is POSTed to the /session endpoint +// in order to login and get a session cookie +type SessionQuery struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// User the JSON object representing a JIRA user +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/user +type User struct { + DisplayName string `json:"displayName"` + EmailAddress string `json:"emailAddress"` + Key string `json:"key"` + Name string `json:"name"` +} + +// Comment the JSON object for a single comment item returned in a list of +// comments +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getComments +type Comment struct { + ID string `json:"id"` + Body string `json:"body"` + Author User `json:"author"` + UpdateAuthor User `json:"updateAuthor"` + Created MyTime `json:"created"` + Updated MyTime `json:"updated"` +} + +// CommentPage the JSON object holding a single page of comments returned +// either by direct query or within an issue query +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getComments +type CommentPage struct { + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` + Comments []Comment `json:"comments"` +} + +// NextStartAt return the index of the first item on the next page +func (self *CommentPage) NextStartAt() int { + return self.StartAt + len(self.Comments) +} + +// IsLastPage return true if there are no more items beyond this page +func (self *CommentPage) IsLastPage() bool { + return self.NextStartAt() >= self.Total +} + +// IssueFields the JSON object returned as the "fields" member of an issue. +// There are a very large number of fields and many of them are custom. We +// only grab a few that we need. +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue +type IssueFields struct { + Creator User `json:"creator"` + Created MyTime `json:"created"` + Description string `json:"description"` + Summary string `json:"summary"` + Comments CommentPage `json:"comment"` + Labels []string `json:"labels"` +} + +// ChangeLogItem "field-change" data within a changelog entry. A single +// changelog entry might effect multiple fields. For example, closing an issue +// generally requires a change in "status" and "resolution" +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue +type ChangeLogItem struct { + Field string `json:"field"` + FieldType string `json:"fieldtype"` + From string `json:"from"` + FromString string `json:"fromString"` + To string `json:"to"` + ToString string `json:"toString"` +} + +// ChangeLogEntry One entry in a changelog +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue +type ChangeLogEntry struct { + ID string `json:"id"` + Author User `json:"author"` + Created MyTime `json:"created"` + Items []ChangeLogItem `json:"items"` +} + +// ChangeLogPage A collection of changes to issue metadata +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue +type ChangeLogPage struct { + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` + IsLast bool `json:"isLast"` // Cloud-only + Entries []ChangeLogEntry `json:"histories"` + Values []ChangeLogEntry `json:"values"` +} + +// NextStartAt return the index of the first item on the next page +func (self *ChangeLogPage) NextStartAt() int { + return self.StartAt + len(self.Entries) +} + +// IsLastPage return true if there are no more items beyond this page +func (self *ChangeLogPage) IsLastPage() bool { + // NOTE(josh): The "isLast" field is returned on JIRA cloud, but not on + // JIRA server. If we can distinguish which one we are working with, we can + // possibly rely on that instead. + return self.NextStartAt() >= self.Total +} + +// Issue Top-level object for an issue +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue +type Issue struct { + ID string `json:"id"` + Key string `json:"key"` + Fields IssueFields `json:"fields"` + ChangeLog ChangeLogPage `json:"changelog"` +} + +// SearchResult The result type from querying the search endpoint +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/search +type SearchResult struct { + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` + Issues []Issue `json:"issues"` +} + +// NextStartAt return the index of the first item on the next page +func (self *SearchResult) NextStartAt() int { + return self.StartAt + len(self.Issues) +} + +// IsLastPage return true if there are no more items beyond this page +func (self *SearchResult) IsLastPage() bool { + return self.NextStartAt() >= self.Total +} + +// SearchRequest the JSON object POSTed to the /search endpoint +type SearchRequest struct { + JQL string `json:"jql"` + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Fields []string `json:"fields"` +} + +// Project the JSON object representing a project. Note that we don't use all +// the fields so we have only implemented a couple. +type Project struct { + ID string `json:"id,omitempty"` + Key string `json:"key,omitempty"` +} + +// IssueType the JSON object representing an issue type (i.e. "bug", "task") +// Note that we don't use all the fields so we have only implemented a couple. +type IssueType struct { + ID string `json:"id"` +} + +// IssueCreateFields fields that are included in an IssueCreate request +type IssueCreateFields struct { + Project Project `json:"project"` + Summary string `json:"summary"` + Description string `json:"description"` + IssueType IssueType `json:"issuetype"` +} + +// IssueCreate the JSON object that is POSTed to the /issue endpoint to create +// a new issue +type IssueCreate struct { + Fields IssueCreateFields `json:"fields"` +} + +// IssueCreateResult the JSON object returned after issue creation. +type IssueCreateResult struct { + ID string `json:"id"` + Key string `json:"key"` +} + +// CommentCreate the JSOn object that is POSTed to the /comment endpoint to +// create a new comment +type CommentCreate struct { + Body string `json:"body"` +} + +// StatusCategory the JSON object representing a status category +type StatusCategory struct { + ID int `json:"id"` + Key string `json:"key"` + Self string `json:"self"` + ColorName string `json:"colorName"` + Name string `json:"name"` +} + +// Status the JSON object representing a status (i.e. "Open", "Closed") +type Status struct { + ID string `json:"id"` + Name string `json:"name"` + Self string `json:"self"` + Description string `json:"description"` + StatusCategory StatusCategory `json:"statusCategory"` +} + +// Transition the JSON object represenging a transition from one Status to +// another Status in a JIRA workflow +type Transition struct { + ID string `json:"id"` + Name string `json:"name"` + To Status `json:"to"` +} + +// TransitionList the JSON object returned from the /transitions endpoint +type TransitionList struct { + Transitions []Transition `json:"transitions"` +} + +// ServerInfo general server information returned by the /serverInfo endpoint. +// Notably `ServerTime` will tell you the time on the server. +type ServerInfo struct { + BaseURL string `json:"baseUrl"` + Version string `json:"version"` + VersionNumbers []int `json:"versionNumbers"` + BuildNumber int `json:"buildNumber"` + BuildDate MyTime `json:"buildDate"` + ServerTime MyTime `json:"serverTime"` + ScmInfo string `json:"scmInfo"` + BuildPartnerName string `json:"buildPartnerName"` + ServerTitle string `json:"serverTitle"` +} + +// ============================================================================= +// REST Client +// ============================================================================= + +// ClientTransport wraps http.RoundTripper by adding a +// "Content-Type=application/json" header +type ClientTransport struct { + underlyingTransport http.RoundTripper + basicAuthString string +} + +// RoundTrip overrides the default by adding the content-type header +func (ct *ClientTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Add("Content-Type", "application/json") + if ct.basicAuthString != "" { + req.Header.Add("Authorization", + fmt.Sprintf("Basic %s", ct.basicAuthString)) + } + + return ct.underlyingTransport.RoundTrip(req) +} + +func (ct *ClientTransport) SetCredentials(username string, token string) { + credString := fmt.Sprintf("%s:%s", username, token) + ct.basicAuthString = base64.StdEncoding.EncodeToString([]byte(credString)) +} + +// Client Thin wrapper around the http.Client providing jira-specific methods +// for APIendpoints +type Client struct { + *http.Client + serverURL string + ctx context.Context +} + +// NewClient Construct a new client connected to the provided server and +// utilizing the given context for asynchronous events +func NewClient(serverURL string, ctx context.Context) *Client { + cookiJar, _ := cookiejar.New(nil) + client := &http.Client{ + Transport: &ClientTransport{underlyingTransport: http.DefaultTransport}, + Jar: cookiJar, + } + + return &Client{client, serverURL, ctx} +} + +// Login POST credentials to the /session endpoing and get a session cookie +func (client *Client) Login(conf core.Configuration) error { + credType := conf[keyCredentialsType] + + if conf[keyCredentialsFile] != "" { + content, err := ioutil.ReadFile(conf[keyCredentialsFile]) + if err != nil { + return err + } + + switch credType { + case "SESSION": + return client.RefreshSessionTokenRaw(content) + case "TOKEN": + var params SessionQuery + err := json.Unmarshal(content, ¶ms) + if err != nil { + return err + } + return client.SetTokenCredentials(params.Username, params.Password) + } + return fmt.Errorf("Unexpected credType: %s", credType) + } + + username := conf[keyUsername] + if username == "" { + return fmt.Errorf( + "Invalid configuration lacks both a username and credentials sidecar " + + "path. At least one is required.") + } + + password := conf[keyPassword] + if password == "" { + var err error + password, err = input.PromptPassword("Password", "password", input.Required) + if err != nil { + return err + } + } + + switch credType { + case "SESSION": + return client.RefreshSessionToken(username, password) + case "TOKEN": + return client.SetTokenCredentials(username, password) + } + return fmt.Errorf("Unexpected credType: %s", credType) +} + +// RefreshSessionToken formulate the JSON request object from the user +// credentials and POST it to the /session endpoing and get a session cookie +func (client *Client) RefreshSessionToken(username, password string) error { + params := SessionQuery{ + Username: username, + Password: password, + } + + data, err := json.Marshal(params) + if err != nil { + return err + } + + return client.RefreshSessionTokenRaw(data) +} + +// SetTokenCredentials POST credentials to the /session endpoing and get a +// session cookie +func (client *Client) SetTokenCredentials(username, password string) error { + switch transport := client.Transport.(type) { + case *ClientTransport: + transport.SetCredentials(username, password) + default: + return fmt.Errorf("Invalid transport type") + } + return nil +} + +// RefreshSessionTokenRaw POST credentials to the /session endpoing and get a +// session cookie +func (client *Client) RefreshSessionTokenRaw(credentialsJSON []byte) error { + postURL := fmt.Sprintf("%s/rest/auth/1/session", client.serverURL) + + req, err := http.NewRequest("POST", postURL, bytes.NewBuffer(credentialsJSON)) + if err != nil { + return err + } + + urlobj, err := url.Parse(client.serverURL) + if err != nil { + fmt.Printf("Failed to parse %s\n", client.serverURL) + } else { + // Clear out cookies + client.Jar.SetCookies(urlobj, []*http.Cookie{}) + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + req = req.WithContext(ctx) + } + + response, err := client.Do(req) + if err != nil { + return err + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + content, _ := ioutil.ReadAll(response.Body) + return fmt.Errorf( + "error creating token %v: %s", response.StatusCode, content) + } + + data, _ := ioutil.ReadAll(response.Body) + var aux SessionResponse + err = json.Unmarshal(data, &aux) + if err != nil { + return err + } + + var cookies []*http.Cookie + cookie := &http.Cookie{ + Name: aux.Session.Name, + Value: aux.Session.Value, + } + cookies = append(cookies, cookie) + client.Jar.SetCookies(urlobj, cookies) + + return nil +} + +// ============================================================================= +// Endpoint Wrappers +// ============================================================================= + +// Search Perform an issue a JQL search on the /search endpoint +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/search +func (client *Client) Search(jql string, maxResults int, startAt int) (*SearchResult, error) { + url := fmt.Sprintf("%s/rest/api/2/search", client.serverURL) + + requestBody, err := json.Marshal(SearchRequest{ + JQL: jql, + StartAt: startAt, + MaxResults: maxResults, + Fields: []string{ + "comment", + "created", + "creator", + "description", + "labels", + "status", + "summary"}}) + if err != nil { + return nil, err + } + + request, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody)) + if err != nil { + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s, %s", response.StatusCode, + url, requestBody) + return nil, err + } + + var message SearchResult + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &message) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &message, nil +} + +// SearchIterator cursor within paginated results from the /search endpoint +type SearchIterator struct { + client *Client + jql string + searchResult *SearchResult + Err error + + pageSize int + itemIdx int +} + +// HasError returns true if the iterator is holding an error +func (si *SearchIterator) HasError() bool { + if si.Err == errDone { + return false + } + if si.Err == nil { + return false + } + return true +} + +// HasNext returns true if there is another item available in the result set +func (si *SearchIterator) HasNext() bool { + return si.Err == nil && si.itemIdx < len(si.searchResult.Issues) +} + +// Next Return the next item in the result set and advance the iterator. +// Advancing the iterator may require fetching a new page. +func (si *SearchIterator) Next() *Issue { + if si.Err != nil { + return nil + } + + issue := si.searchResult.Issues[si.itemIdx] + if si.itemIdx+1 < len(si.searchResult.Issues) { + // We still have an item left in the currently cached page + si.itemIdx++ + } else { + if si.searchResult.IsLastPage() { + si.Err = errDone + } else { + // There are still more pages to fetch, so fetch the next page and + // cache it + si.searchResult, si.Err = si.client.Search( + si.jql, si.pageSize, si.searchResult.NextStartAt()) + // NOTE(josh): we don't deal with the error now, we just cache it. + // HasNext() will return false and the caller can check the error + // afterward. + si.itemIdx = 0 + } + } + return &issue +} + +// IterSearch return an iterator over paginated results for a JQL search +func (client *Client) IterSearch(jql string, pageSize int) *SearchIterator { + result, err := client.Search(jql, pageSize, 0) + + iter := &SearchIterator{ + client: client, + jql: jql, + searchResult: result, + Err: err, + pageSize: pageSize, + itemIdx: 0, + } + + return iter +} + +// GetIssue fetches an issue object via the /issue/{IssueIdOrKey} endpoint +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue +func (client *Client) GetIssue(idOrKey string, fields []string, expand []string, + properties []string) (*Issue, error) { + + url := fmt.Sprintf("%s/rest/api/2/issue/%s", client.serverURL, idOrKey) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + err := fmt.Errorf("Creating request %v", err) + return nil, err + } + + query := request.URL.Query() + if len(fields) > 0 { + query.Add("fields", strings.Join(fields, ",")) + } + if len(expand) > 0 { + query.Add("expand", strings.Join(expand, ",")) + } + if len(properties) > 0 { + query.Add("properties", strings.Join(properties, ",")) + } + request.URL.RawQuery = query.Encode() + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String()) + return nil, err + } + + var issue Issue + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &issue) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &issue, nil +} + +// GetComments returns a page of comments via the issue/{IssueIdOrKey}/comment +// endpoint +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getComment +func (client *Client) GetComments(idOrKey string, maxResults int, startAt int) (*CommentPage, error) { + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/comment", client.serverURL, idOrKey) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + err := fmt.Errorf("Creating request %v", err) + return nil, err + } + + query := request.URL.Query() + if maxResults > 0 { + query.Add("maxResults", fmt.Sprintf("%d", maxResults)) + } + if startAt > 0 { + query.Add("startAt", fmt.Sprintf("%d", startAt)) + } + request.URL.RawQuery = query.Encode() + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String()) + return nil, err + } + + var comments CommentPage + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &comments) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &comments, nil +} + +// CommentIterator cursor within paginated results from the /comment endpoint +type CommentIterator struct { + client *Client + idOrKey string + message *CommentPage + Err error + + pageSize int + itemIdx int +} + +// HasError returns true if the iterator is holding an error +func (ci *CommentIterator) HasError() bool { + if ci.Err == errDone { + return false + } + if ci.Err == nil { + return false + } + return true +} + +// HasNext returns true if there is another item available in the result set +func (ci *CommentIterator) HasNext() bool { + return ci.Err == nil && ci.itemIdx < len(ci.message.Comments) +} + +// Next Return the next item in the result set and advance the iterator. +// Advancing the iterator may require fetching a new page. +func (ci *CommentIterator) Next() *Comment { + if ci.Err != nil { + return nil + } + + comment := ci.message.Comments[ci.itemIdx] + if ci.itemIdx+1 < len(ci.message.Comments) { + // We still have an item left in the currently cached page + ci.itemIdx++ + } else { + if ci.message.IsLastPage() { + ci.Err = errDone + } else { + // There are still more pages to fetch, so fetch the next page and + // cache it + ci.message, ci.Err = ci.client.GetComments( + ci.idOrKey, ci.pageSize, ci.message.NextStartAt()) + // NOTE(josh): we don't deal with the error now, we just cache it. + // HasNext() will return false and the caller can check the error + // afterward. + ci.itemIdx = 0 + } + } + return &comment +} + +// IterComments returns an iterator over paginated comments within an issue +func (client *Client) IterComments(idOrKey string, pageSize int) *CommentIterator { + message, err := client.GetComments(idOrKey, pageSize, 0) + + iter := &CommentIterator{ + client: client, + idOrKey: idOrKey, + message: message, + Err: err, + pageSize: pageSize, + itemIdx: 0, + } + + return iter +} + +// GetChangeLog fetchs one page of the changelog for an issue via the +// /issue/{IssueIdOrKey}/changelog endpoint (for JIRA cloud) or +// /issue/{IssueIdOrKey} with (fields=*none&expand=changelog) +// (for JIRA server) +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue +func (client *Client) GetChangeLog(idOrKey string, maxResults int, startAt int) (*ChangeLogPage, error) { + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/changelog", client.serverURL, idOrKey) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + err := fmt.Errorf("Creating request %v", err) + return nil, err + } + + query := request.URL.Query() + if maxResults > 0 { + query.Add("maxResults", fmt.Sprintf("%d", maxResults)) + } + if startAt > 0 { + query.Add("startAt", fmt.Sprintf("%d", startAt)) + } + request.URL.RawQuery = query.Encode() + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode == http.StatusNotFound { + // The issue/{IssueIdOrKey}/changelog endpoint is only available on JIRA cloud + // products, not on JIRA server. In order to get the information we have to + // query the issue and ask for a changelog expansion. Unfortunately this means + // that the changelog is not paginated and we have to fetch the entire thing + // at once. Hopefully things don't break for very long changelogs. + issue, err := client.GetIssue( + idOrKey, []string{"*none"}, []string{"changelog"}, []string{}) + if err != nil { + return nil, err + } + + return &issue.ChangeLog, nil + } + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String()) + return nil, err + } + + var changelog ChangeLogPage + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &changelog) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + // JIRA cloud returns changelog entries in the "values" list, whereas JIRA + // server returns them in the "histories" list when embedded in an issue + // object. + changelog.Entries = changelog.Values + changelog.Values = nil + + return &changelog, nil +} + +// ChangeLogIterator cursor within paginated results from the /search endpoint +type ChangeLogIterator struct { + client *Client + idOrKey string + message *ChangeLogPage + Err error + + pageSize int + itemIdx int +} + +// HasError returns true if the iterator is holding an error +func (cli *ChangeLogIterator) HasError() bool { + if cli.Err == errDone { + return false + } + if cli.Err == nil { + return false + } + return true +} + +// HasNext returns true if there is another item available in the result set +func (cli *ChangeLogIterator) HasNext() bool { + return cli.Err == nil && cli.itemIdx < len(cli.message.Entries) +} + +// Next Return the next item in the result set and advance the iterator. +// Advancing the iterator may require fetching a new page. +func (cli *ChangeLogIterator) Next() *ChangeLogEntry { + if cli.Err != nil { + return nil + } + + item := cli.message.Entries[cli.itemIdx] + if cli.itemIdx+1 < len(cli.message.Entries) { + // We still have an item left in the currently cached page + cli.itemIdx++ + } else { + if cli.message.IsLastPage() { + cli.Err = errDone + } else { + // There are still more pages to fetch, so fetch the next page and + // cache it + cli.message, cli.Err = cli.client.GetChangeLog( + cli.idOrKey, cli.pageSize, cli.message.NextStartAt()) + // NOTE(josh): we don't deal with the error now, we just cache it. + // HasNext() will return false and the caller can check the error + // afterward. + cli.itemIdx = 0 + } + } + return &item +} + +// IterChangeLog returns an iterator over entries in the changelog for an issue +func (client *Client) IterChangeLog(idOrKey string, pageSize int) *ChangeLogIterator { + message, err := client.GetChangeLog(idOrKey, pageSize, 0) + + iter := &ChangeLogIterator{ + client: client, + idOrKey: idOrKey, + message: message, + Err: err, + pageSize: pageSize, + itemIdx: 0, + } + + return iter +} + +// GetProject returns the project JSON object given its id or key +func (client *Client) GetProject(projectIDOrKey string) (*Project, error) { + url := fmt.Sprintf( + "%s/rest/api/2/project/%s", client.serverURL, projectIDOrKey) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + return nil, err + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, url) + return nil, err + } + + var project Project + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &project) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &project, nil +} + +// CreateIssue creates a new JIRA issue and returns it +func (client *Client) CreateIssue(projectIDOrKey, title, body string, + extra map[string]interface{}) (*IssueCreateResult, error) { + + url := fmt.Sprintf("%s/rest/api/2/issue", client.serverURL) + + fields := make(map[string]interface{}) + fields["summary"] = title + fields["description"] = body + for key, value := range extra { + fields[key] = value + } + + // If the project string is an integer than assume it is an ID. Otherwise it + // is a key. + _, err := strconv.Atoi(projectIDOrKey) + if err == nil { + fields["project"] = map[string]string{"id": projectIDOrKey} + } else { + fields["project"] = map[string]string{"key": projectIDOrKey} + } + + message := make(map[string]interface{}) + message["fields"] = fields + + data, err := json.Marshal(message) + if err != nil { + return nil, err + } + + request, err := http.NewRequest("POST", url, bytes.NewBuffer(data)) + if err != nil { + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusCreated { + content, _ := ioutil.ReadAll(response.Body) + err := fmt.Errorf( + "HTTP response %d, query was %s\n data: %s\n response: %s", + response.StatusCode, request.URL.String(), data, content) + return nil, err + } + + var result IssueCreateResult + + data, _ = ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &result) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &result, nil +} + +// UpdateIssueTitle changes the "summary" field of a JIRA issue +func (client *Client) UpdateIssueTitle(issueKeyOrID, title string) (time.Time, error) { + + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s", client.serverURL, issueKeyOrID) + var responseTime time.Time + + // NOTE(josh): Since updates are a list of heterogeneous objects let's just + // manually build the JSON text + data, err := json.Marshal(title) + if err != nil { + return responseTime, err + } + + var buffer bytes.Buffer + _, _ = fmt.Fprintf(&buffer, `{"update":{"summary":[`) + _, _ = fmt.Fprintf(&buffer, `{"set":%s}`, data) + _, _ = fmt.Fprintf(&buffer, `]}}`) + + data = buffer.Bytes() + request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data)) + if err != nil { + return responseTime, err + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return responseTime, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + content, _ := ioutil.ReadAll(response.Body) + err := fmt.Errorf( + "HTTP response %d, query was %s\n data: %s\n response: %s", + response.StatusCode, request.URL.String(), data, content) + return responseTime, err + } + + dateHeader, ok := response.Header["Date"] + if !ok || len(dateHeader) != 1 { + // No "Date" header, or empty, or multiple of them. Regardless, we don't + // have a date we can return + return responseTime, nil + } + + responseTime, err = http.ParseTime(dateHeader[0]) + if err != nil { + return time.Time{}, err + } + + return responseTime, nil +} + +// UpdateIssueBody changes the "description" field of a JIRA issue +func (client *Client) UpdateIssueBody(issueKeyOrID, body string) (time.Time, error) { + + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s", client.serverURL, issueKeyOrID) + var responseTime time.Time + // NOTE(josh): Since updates are a list of heterogeneous objects let's just + // manually build the JSON text + data, err := json.Marshal(body) + if err != nil { + return responseTime, err + } + + var buffer bytes.Buffer + _, _ = fmt.Fprintf(&buffer, `{"update":{"description":[`) + _, _ = fmt.Fprintf(&buffer, `{"set":%s}`, data) + _, _ = fmt.Fprintf(&buffer, `]}}`) + + data = buffer.Bytes() + request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data)) + if err != nil { + return responseTime, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return responseTime, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + content, _ := ioutil.ReadAll(response.Body) + err := fmt.Errorf( + "HTTP response %d, query was %s\n data: %s\n response: %s", + response.StatusCode, request.URL.String(), data, content) + return responseTime, err + } + + dateHeader, ok := response.Header["Date"] + if !ok || len(dateHeader) != 1 { + // No "Date" header, or empty, or multiple of them. Regardless, we don't + // have a date we can return + return responseTime, nil + } + + responseTime, err = http.ParseTime(dateHeader[0]) + if err != nil { + return time.Time{}, err + } + + return responseTime, nil +} + +// AddComment adds a new comment to an issue (and returns it). +func (client *Client) AddComment(issueKeyOrID, body string) (*Comment, error) { + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/comment", client.serverURL, issueKeyOrID) + + params := CommentCreate{Body: body} + data, err := json.Marshal(params) + if err != nil { + return nil, err + } + + request, err := http.NewRequest("POST", url, bytes.NewBuffer(data)) + if err != nil { + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusCreated { + content, _ := ioutil.ReadAll(response.Body) + err := fmt.Errorf( + "HTTP response %d, query was %s\n data: %s\n response: %s", + response.StatusCode, request.URL.String(), data, content) + return nil, err + } + + var result Comment + + data, _ = ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &result) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &result, nil +} + +// UpdateComment changes the text of a comment +func (client *Client) UpdateComment(issueKeyOrID, commentID, body string) ( + *Comment, error) { + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/comment/%s", client.serverURL, issueKeyOrID, + commentID) + + params := CommentCreate{Body: body} + data, err := json.Marshal(params) + if err != nil { + return nil, err + } + + request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data)) + if err != nil { + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String()) + return nil, err + } + + var result Comment + + data, _ = ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &result) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &result, nil +} + +// UpdateLabels changes labels for an issue +func (client *Client) UpdateLabels(issueKeyOrID string, added, removed []bug.Label) (time.Time, error) { + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/", client.serverURL, issueKeyOrID) + var responseTime time.Time + + // NOTE(josh): Since updates are a list of heterogeneous objects let's just + // manually build the JSON text + var buffer bytes.Buffer + _, _ = fmt.Fprintf(&buffer, `{"update":{"labels":[`) + first := true + for _, label := range added { + if !first { + _, _ = fmt.Fprintf(&buffer, ",") + } + _, _ = fmt.Fprintf(&buffer, `{"add":"%s"}`, label) + first = false + } + for _, label := range removed { + if !first { + _, _ = fmt.Fprintf(&buffer, ",") + } + _, _ = fmt.Fprintf(&buffer, `{"remove":"%s"}`, label) + first = false + } + _, _ = fmt.Fprintf(&buffer, "]}}") + + data := buffer.Bytes() + request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data)) + if err != nil { + return responseTime, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return responseTime, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + content, _ := ioutil.ReadAll(response.Body) + err := fmt.Errorf( + "HTTP response %d, query was %s\n data: %s\n response: %s", + response.StatusCode, request.URL.String(), data, content) + return responseTime, err + } + + dateHeader, ok := response.Header["Date"] + if !ok || len(dateHeader) != 1 { + // No "Date" header, or empty, or multiple of them. Regardless, we don't + // have a date we can return + return responseTime, nil + } + + responseTime, err = http.ParseTime(dateHeader[0]) + if err != nil { + return time.Time{}, err + } + + return responseTime, nil +} + +// GetTransitions returns a list of available transitions for an issue +func (client *Client) GetTransitions(issueKeyOrID string) (*TransitionList, error) { + + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/transitions", client.serverURL, issueKeyOrID) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + err := fmt.Errorf("Creating request %v", err) + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String()) + return nil, err + } + + var message TransitionList + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &message) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &message, nil +} + +func getTransitionTo(tlist *TransitionList, desiredStateNameOrID string) *Transition { + for _, transition := range tlist.Transitions { + if transition.To.ID == desiredStateNameOrID { + return &transition + } else if transition.To.Name == desiredStateNameOrID { + return &transition + } + } + return nil +} + +// DoTransition changes the "status" of an issue +func (client *Client) DoTransition(issueKeyOrID string, transitionID string) (time.Time, error) { + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/transitions", client.serverURL, issueKeyOrID) + var responseTime time.Time + + // TODO(josh)[767ee72]: Figure out a good way to "configure" the + // open/close state mapping. It would be *great* if we could actually + // *compute* the necessary transitions and prompt for missing metatdata... + // but that is complex + var buffer bytes.Buffer + _, _ = fmt.Fprintf(&buffer, + `{"transition":{"id":"%s"}, "resolution": {"name": "Done"}}`, + transitionID) + request, err := http.NewRequest("POST", url, bytes.NewBuffer(buffer.Bytes())) + if err != nil { + return responseTime, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return responseTime, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + err := errors.Wrap(errTransitionNotAllowed, fmt.Sprintf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String())) + return responseTime, err + } + + dateHeader, ok := response.Header["Date"] + if !ok || len(dateHeader) != 1 { + // No "Date" header, or empty, or multiple of them. Regardless, we don't + // have a date we can return + return responseTime, nil + } + + responseTime, err = http.ParseTime(dateHeader[0]) + if err != nil { + return time.Time{}, err + } + + return responseTime, nil +} + +// GetServerInfo Fetch server information from the /serverinfo endpoint +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue +func (client *Client) GetServerInfo() (*ServerInfo, error) { + url := fmt.Sprintf("%s/rest/api/2/serverinfo", client.serverURL) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + err := fmt.Errorf("Creating request %v", err) + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String()) + return nil, err + } + + var message ServerInfo + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &message) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &message, nil +} + +// GetServerTime returns the current time on the server +func (client *Client) GetServerTime() (MyTime, error) { + var result MyTime + info, err := client.GetServerInfo() + if err != nil { + return result, err + } + return info.ServerTime, nil +} |