mirror of
https://github.com/MichaelMure/git-bug.git
synced 2024-12-14 17:51:44 +03:00
1462 lines
40 KiB
Go
1462 lines
40 KiB
Go
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/entities/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
|
|
}
|