1package jira
   2
   3import (
   4	"bytes"
   5	"context"
   6	"encoding/base64"
   7	"encoding/json"
   8	"fmt"
   9	"io/ioutil"
  10	"net/http"
  11	"net/http/cookiejar"
  12	"net/url"
  13	"strconv"
  14	"strings"
  15	"time"
  16
  17	"github.com/pkg/errors"
  18
  19	"github.com/MichaelMure/git-bug/entities/bug"
  20)
  21
  22var errDone = errors.New("Iteration Done")
  23var errTransitionNotFound = errors.New("Transition not found")
  24var errTransitionNotAllowed = errors.New("Transition not allowed")
  25
  26// =============================================================================
  27// Extended JSON
  28// =============================================================================
  29
  30const TimeFormat = "2006-01-02T15:04:05.999999999Z0700"
  31
  32// ParseTime parse an RFC3339 string with nanoseconds
  33func ParseTime(timeStr string) (time.Time, error) {
  34	out, err := time.Parse(time.RFC3339Nano, timeStr)
  35	if err != nil {
  36		out, err = time.Parse(TimeFormat, timeStr)
  37	}
  38	return out, err
  39}
  40
  41// Time is just a time.Time with a JSON serialization
  42type Time struct {
  43	time.Time
  44}
  45
  46// UnmarshalJSON parses an RFC3339 date string into a time object
  47// borrowed from: https://stackoverflow.com/a/39180230/141023
  48func (t *Time) UnmarshalJSON(data []byte) (err error) {
  49	str := string(data)
  50
  51	// Get rid of the quotes "" around the value.
  52	// A second option would be to include them in the date format string
  53	// instead, like so below:
  54	//   time.Parse(`"`+time.RFC3339Nano+`"`, s)
  55	str = str[1 : len(str)-1]
  56
  57	timeObj, err := ParseTime(str)
  58	t.Time = timeObj
  59	return
  60}
  61
  62// =============================================================================
  63// JSON Objects
  64// =============================================================================
  65
  66// Session credential cookie name/value pair received after logging in and
  67// required to be sent on all subsequent requests
  68type Session struct {
  69	Name  string `json:"name"`
  70	Value string `json:"value"`
  71}
  72
  73// SessionResponse the JSON object returned from a /session query (login)
  74type SessionResponse struct {
  75	Session Session `json:"session"`
  76}
  77
  78// SessionQuery the JSON object that is POSTed to the /session endpoint
  79// in order to login and get a session cookie
  80type SessionQuery struct {
  81	Username string `json:"username"`
  82	Password string `json:"password"`
  83}
  84
  85// User the JSON object representing a JIRA user
  86// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/user
  87type User struct {
  88	DisplayName  string `json:"displayName"`
  89	EmailAddress string `json:"emailAddress"`
  90	Key          string `json:"key"`
  91	Name         string `json:"name"`
  92}
  93
  94// Comment the JSON object for a single comment item returned in a list of
  95// comments
  96// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getComments
  97type Comment struct {
  98	ID           string `json:"id"`
  99	Body         string `json:"body"`
 100	Author       User   `json:"author"`
 101	UpdateAuthor User   `json:"updateAuthor"`
 102	Created      Time   `json:"created"`
 103	Updated      Time   `json:"updated"`
 104}
 105
 106// CommentPage the JSON object holding a single page of comments returned
 107// either by direct query or within an issue query
 108// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getComments
 109type CommentPage struct {
 110	StartAt    int       `json:"startAt"`
 111	MaxResults int       `json:"maxResults"`
 112	Total      int       `json:"total"`
 113	Comments   []Comment `json:"comments"`
 114}
 115
 116// NextStartAt return the index of the first item on the next page
 117func (cp *CommentPage) NextStartAt() int {
 118	return cp.StartAt + len(cp.Comments)
 119}
 120
 121// IsLastPage return true if there are no more items beyond this page
 122func (cp *CommentPage) IsLastPage() bool {
 123	return cp.NextStartAt() >= cp.Total
 124}
 125
 126// IssueFields the JSON object returned as the "fields" member of an issue.
 127// There are a very large number of fields and many of them are custom. We
 128// only grab a few that we need.
 129// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue
 130type IssueFields struct {
 131	Creator     User        `json:"creator"`
 132	Created     Time        `json:"created"`
 133	Description string      `json:"description"`
 134	Summary     string      `json:"summary"`
 135	Comments    CommentPage `json:"comment"`
 136	Labels      []string    `json:"labels"`
 137}
 138
 139// ChangeLogItem "field-change" data within a changelog entry. A single
 140// changelog entry might effect multiple fields. For example, closing an issue
 141// generally requires a change in "status" and "resolution"
 142// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue
 143type ChangeLogItem struct {
 144	Field      string `json:"field"`
 145	FieldType  string `json:"fieldtype"`
 146	From       string `json:"from"`
 147	FromString string `json:"fromString"`
 148	To         string `json:"to"`
 149	ToString   string `json:"toString"`
 150}
 151
 152// ChangeLogEntry One entry in a changelog
 153// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue
 154type ChangeLogEntry struct {
 155	ID      string          `json:"id"`
 156	Author  User            `json:"author"`
 157	Created Time            `json:"created"`
 158	Items   []ChangeLogItem `json:"items"`
 159}
 160
 161// ChangeLogPage A collection of changes to issue metadata
 162// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue
 163type ChangeLogPage struct {
 164	StartAt    int              `json:"startAt"`
 165	MaxResults int              `json:"maxResults"`
 166	Total      int              `json:"total"`
 167	IsLast     bool             `json:"isLast"` // Cloud-only
 168	Entries    []ChangeLogEntry `json:"histories"`
 169	Values     []ChangeLogEntry `json:"values"`
 170}
 171
 172// NextStartAt return the index of the first item on the next page
 173func (clp *ChangeLogPage) NextStartAt() int {
 174	return clp.StartAt + len(clp.Entries)
 175}
 176
 177// IsLastPage return true if there are no more items beyond this page
 178func (clp *ChangeLogPage) IsLastPage() bool {
 179	// NOTE(josh): The "isLast" field is returned on JIRA cloud, but not on
 180	// JIRA server. If we can distinguish which one we are working with, we can
 181	// possibly rely on that instead.
 182	return clp.NextStartAt() >= clp.Total
 183}
 184
 185// Issue Top-level object for an issue
 186// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue
 187type Issue struct {
 188	ID        string        `json:"id"`
 189	Key       string        `json:"key"`
 190	Fields    IssueFields   `json:"fields"`
 191	ChangeLog ChangeLogPage `json:"changelog"`
 192}
 193
 194// SearchResult The result type from querying the search endpoint
 195// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/search
 196type SearchResult struct {
 197	StartAt    int     `json:"startAt"`
 198	MaxResults int     `json:"maxResults"`
 199	Total      int     `json:"total"`
 200	Issues     []Issue `json:"issues"`
 201}
 202
 203// NextStartAt return the index of the first item on the next page
 204func (sr *SearchResult) NextStartAt() int {
 205	return sr.StartAt + len(sr.Issues)
 206}
 207
 208// IsLastPage return true if there are no more items beyond this page
 209func (sr *SearchResult) IsLastPage() bool {
 210	return sr.NextStartAt() >= sr.Total
 211}
 212
 213// SearchRequest the JSON object POSTed to the /search endpoint
 214type SearchRequest struct {
 215	JQL        string   `json:"jql"`
 216	StartAt    int      `json:"startAt"`
 217	MaxResults int      `json:"maxResults"`
 218	Fields     []string `json:"fields"`
 219}
 220
 221// Project the JSON object representing a project. Note that we don't use all
 222// the fields so we have only implemented a couple.
 223type Project struct {
 224	ID  string `json:"id,omitempty"`
 225	Key string `json:"key,omitempty"`
 226}
 227
 228// IssueType the JSON object representing an issue type (i.e. "bug", "task")
 229// Note that we don't use all the fields so we have only implemented a couple.
 230type IssueType struct {
 231	ID string `json:"id"`
 232}
 233
 234// IssueCreateFields fields that are included in an IssueCreate request
 235type IssueCreateFields struct {
 236	Project     Project   `json:"project"`
 237	Summary     string    `json:"summary"`
 238	Description string    `json:"description"`
 239	IssueType   IssueType `json:"issuetype"`
 240}
 241
 242// IssueCreate the JSON object that is POSTed to the /issue endpoint to create
 243// a new issue
 244type IssueCreate struct {
 245	Fields IssueCreateFields `json:"fields"`
 246}
 247
 248// IssueCreateResult the JSON object returned after issue creation.
 249type IssueCreateResult struct {
 250	ID  string `json:"id"`
 251	Key string `json:"key"`
 252}
 253
 254// CommentCreate the JSOn object that is POSTed to the /comment endpoint to
 255// create a new comment
 256type CommentCreate struct {
 257	Body string `json:"body"`
 258}
 259
 260// StatusCategory the JSON object representing a status category
 261type StatusCategory struct {
 262	ID        int    `json:"id"`
 263	Key       string `json:"key"`
 264	Self      string `json:"self"`
 265	ColorName string `json:"colorName"`
 266	Name      string `json:"name"`
 267}
 268
 269// Status the JSON object representing a status (i.e. "Open", "Closed")
 270type Status struct {
 271	ID             string         `json:"id"`
 272	Name           string         `json:"name"`
 273	Self           string         `json:"self"`
 274	Description    string         `json:"description"`
 275	StatusCategory StatusCategory `json:"statusCategory"`
 276}
 277
 278// Transition the JSON object represenging a transition from one Status to
 279// another Status in a JIRA workflow
 280type Transition struct {
 281	ID   string `json:"id"`
 282	Name string `json:"name"`
 283	To   Status `json:"to"`
 284}
 285
 286// TransitionList the JSON object returned from the /transitions endpoint
 287type TransitionList struct {
 288	Transitions []Transition `json:"transitions"`
 289}
 290
 291// ServerInfo general server information returned by the /serverInfo endpoint.
 292// Notably `ServerTime` will tell you the time on the server.
 293type ServerInfo struct {
 294	BaseURL          string `json:"baseUrl"`
 295	Version          string `json:"version"`
 296	VersionNumbers   []int  `json:"versionNumbers"`
 297	BuildNumber      int    `json:"buildNumber"`
 298	BuildDate        Time   `json:"buildDate"`
 299	ServerTime       Time   `json:"serverTime"`
 300	ScmInfo          string `json:"scmInfo"`
 301	BuildPartnerName string `json:"buildPartnerName"`
 302	ServerTitle      string `json:"serverTitle"`
 303}
 304
 305// =============================================================================
 306// REST Client
 307// =============================================================================
 308
 309// ClientTransport wraps http.RoundTripper by adding a
 310// "Content-Type=application/json" header
 311type ClientTransport struct {
 312	underlyingTransport http.RoundTripper
 313	basicAuthString     string
 314}
 315
 316// RoundTrip overrides the default by adding the content-type header
 317func (ct *ClientTransport) RoundTrip(req *http.Request) (*http.Response, error) {
 318	req.Header.Add("Content-Type", "application/json")
 319	if ct.basicAuthString != "" {
 320		req.Header.Add("Authorization",
 321			fmt.Sprintf("Basic %s", ct.basicAuthString))
 322	}
 323
 324	return ct.underlyingTransport.RoundTrip(req)
 325}
 326
 327func (ct *ClientTransport) SetCredentials(username string, token string) {
 328	credString := fmt.Sprintf("%s:%s", username, token)
 329	ct.basicAuthString = base64.StdEncoding.EncodeToString([]byte(credString))
 330}
 331
 332// Client Thin wrapper around the http.Client providing jira-specific methods
 333// for API endpoints
 334type Client struct {
 335	*http.Client
 336	serverURL string
 337	ctx       context.Context
 338}
 339
 340// NewClient Construct a new client connected to the provided server and
 341// utilizing the given context for asynchronous events
 342func NewClient(ctx context.Context, serverURL string) *Client {
 343	cookiJar, _ := cookiejar.New(nil)
 344	client := &http.Client{
 345		Transport: &ClientTransport{underlyingTransport: http.DefaultTransport},
 346		Jar:       cookiJar,
 347	}
 348
 349	return &Client{client, serverURL, ctx}
 350}
 351
 352// Login POST credentials to the /session endpoint and get a session cookie
 353func (client *Client) Login(credType, login, password string) error {
 354	switch credType {
 355	case "SESSION":
 356		return client.RefreshSessionToken(login, password)
 357	case "TOKEN":
 358		return client.SetTokenCredentials(login, password)
 359	default:
 360		panic("unknown Jira cred type")
 361	}
 362}
 363
 364// RefreshSessionToken formulate the JSON request object from the user
 365// credentials and POST it to the /session endpoint and get a session cookie
 366func (client *Client) RefreshSessionToken(username, password string) error {
 367	params := SessionQuery{
 368		Username: username,
 369		Password: password,
 370	}
 371
 372	data, err := json.Marshal(params)
 373	if err != nil {
 374		return err
 375	}
 376
 377	return client.RefreshSessionTokenRaw(data)
 378}
 379
 380// SetTokenCredentials POST credentials to the /session endpoint and get a
 381// session cookie
 382func (client *Client) SetTokenCredentials(username, password string) error {
 383	switch transport := client.Transport.(type) {
 384	case *ClientTransport:
 385		transport.SetCredentials(username, password)
 386	default:
 387		return fmt.Errorf("Invalid transport type")
 388	}
 389	return nil
 390}
 391
 392// RefreshSessionTokenRaw POST credentials to the /session endpoint and get a
 393// session cookie
 394func (client *Client) RefreshSessionTokenRaw(credentialsJSON []byte) error {
 395	postURL := fmt.Sprintf("%s/rest/auth/1/session", client.serverURL)
 396
 397	req, err := http.NewRequest("POST", postURL, bytes.NewBuffer(credentialsJSON))
 398	if err != nil {
 399		return err
 400	}
 401
 402	urlobj, err := url.Parse(client.serverURL)
 403	if err != nil {
 404		fmt.Printf("Failed to parse %s\n", client.serverURL)
 405	} else {
 406		// Clear out cookies
 407		client.Jar.SetCookies(urlobj, []*http.Cookie{})
 408	}
 409
 410	if client.ctx != nil {
 411		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
 412		defer cancel()
 413		req = req.WithContext(ctx)
 414	}
 415
 416	response, err := client.Do(req)
 417	if err != nil {
 418		return err
 419	}
 420
 421	defer response.Body.Close()
 422
 423	if response.StatusCode != http.StatusOK {
 424		content, _ := ioutil.ReadAll(response.Body)
 425		return fmt.Errorf(
 426			"error creating token %v: %s", response.StatusCode, content)
 427	}
 428
 429	data, _ := ioutil.ReadAll(response.Body)
 430	var aux SessionResponse
 431	err = json.Unmarshal(data, &aux)
 432	if err != nil {
 433		return err
 434	}
 435
 436	var cookies []*http.Cookie
 437	cookie := &http.Cookie{
 438		Name:  aux.Session.Name,
 439		Value: aux.Session.Value,
 440	}
 441	cookies = append(cookies, cookie)
 442	client.Jar.SetCookies(urlobj, cookies)
 443
 444	return nil
 445}
 446
 447// =============================================================================
 448// Endpoint Wrappers
 449// =============================================================================
 450
 451// Search Perform an issue a JQL search on the /search endpoint
 452// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/search
 453func (client *Client) Search(jql string, maxResults int, startAt int) (*SearchResult, error) {
 454	url := fmt.Sprintf("%s/rest/api/2/search", client.serverURL)
 455
 456	requestBody, err := json.Marshal(SearchRequest{
 457		JQL:        jql,
 458		StartAt:    startAt,
 459		MaxResults: maxResults,
 460		Fields: []string{
 461			"comment",
 462			"created",
 463			"creator",
 464			"description",
 465			"labels",
 466			"status",
 467			"summary"}})
 468	if err != nil {
 469		return nil, err
 470	}
 471
 472	request, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody))
 473	if err != nil {
 474		return nil, err
 475	}
 476
 477	if client.ctx != nil {
 478		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
 479		defer cancel()
 480		request = request.WithContext(ctx)
 481	}
 482
 483	response, err := client.Do(request)
 484	if err != nil {
 485		return nil, err
 486	}
 487	defer response.Body.Close()
 488
 489	if response.StatusCode != http.StatusOK {
 490		err := fmt.Errorf(
 491			"HTTP response %d, query was %s, %s", response.StatusCode,
 492			url, requestBody)
 493		return nil, err
 494	}
 495
 496	var message SearchResult
 497
 498	data, _ := ioutil.ReadAll(response.Body)
 499	err = json.Unmarshal(data, &message)
 500	if err != nil {
 501		err := fmt.Errorf("Decoding response %v", err)
 502		return nil, err
 503	}
 504
 505	return &message, nil
 506}
 507
 508// SearchIterator cursor within paginated results from the /search endpoint
 509type SearchIterator struct {
 510	client       *Client
 511	jql          string
 512	searchResult *SearchResult
 513	Err          error
 514
 515	pageSize int
 516	itemIdx  int
 517}
 518
 519// HasError returns true if the iterator is holding an error
 520func (si *SearchIterator) HasError() bool {
 521	if si.Err == errDone {
 522		return false
 523	}
 524	if si.Err == nil {
 525		return false
 526	}
 527	return true
 528}
 529
 530// HasNext returns true if there is another item available in the result set
 531func (si *SearchIterator) HasNext() bool {
 532	return si.Err == nil && si.itemIdx < len(si.searchResult.Issues)
 533}
 534
 535// Next Return the next item in the result set and advance the iterator.
 536// Advancing the iterator may require fetching a new page.
 537func (si *SearchIterator) Next() *Issue {
 538	if si.Err != nil {
 539		return nil
 540	}
 541
 542	issue := si.searchResult.Issues[si.itemIdx]
 543	if si.itemIdx+1 < len(si.searchResult.Issues) {
 544		// We still have an item left in the currently cached page
 545		si.itemIdx++
 546	} else {
 547		if si.searchResult.IsLastPage() {
 548			si.Err = errDone
 549		} else {
 550			// There are still more pages to fetch, so fetch the next page and
 551			// cache it
 552			si.searchResult, si.Err = si.client.Search(
 553				si.jql, si.pageSize, si.searchResult.NextStartAt())
 554			// NOTE(josh): we don't deal with the error now, we just cache it.
 555			// HasNext() will return false and the caller can check the error
 556			// afterward.
 557			si.itemIdx = 0
 558		}
 559	}
 560	return &issue
 561}
 562
 563// IterSearch return an iterator over paginated results for a JQL search
 564func (client *Client) IterSearch(jql string, pageSize int) *SearchIterator {
 565	result, err := client.Search(jql, pageSize, 0)
 566
 567	iter := &SearchIterator{
 568		client:       client,
 569		jql:          jql,
 570		searchResult: result,
 571		Err:          err,
 572		pageSize:     pageSize,
 573		itemIdx:      0,
 574	}
 575
 576	return iter
 577}
 578
 579// GetIssue fetches an issue object via the /issue/{IssueIdOrKey} endpoint
 580// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue
 581func (client *Client) GetIssue(idOrKey string, fields []string, expand []string,
 582	properties []string) (*Issue, error) {
 583
 584	url := fmt.Sprintf("%s/rest/api/2/issue/%s", client.serverURL, idOrKey)
 585
 586	request, err := http.NewRequest("GET", url, nil)
 587	if err != nil {
 588		err := fmt.Errorf("Creating request %v", err)
 589		return nil, err
 590	}
 591
 592	query := request.URL.Query()
 593	if len(fields) > 0 {
 594		query.Add("fields", strings.Join(fields, ","))
 595	}
 596	if len(expand) > 0 {
 597		query.Add("expand", strings.Join(expand, ","))
 598	}
 599	if len(properties) > 0 {
 600		query.Add("properties", strings.Join(properties, ","))
 601	}
 602	request.URL.RawQuery = query.Encode()
 603
 604	if client.ctx != nil {
 605		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
 606		defer cancel()
 607		request = request.WithContext(ctx)
 608	}
 609
 610	response, err := client.Do(request)
 611	if err != nil {
 612		err := fmt.Errorf("Performing request %v", err)
 613		return nil, err
 614	}
 615	defer response.Body.Close()
 616
 617	if response.StatusCode != http.StatusOK {
 618		err := fmt.Errorf(
 619			"HTTP response %d, query was %s", response.StatusCode,
 620			request.URL.String())
 621		return nil, err
 622	}
 623
 624	var issue Issue
 625
 626	data, _ := ioutil.ReadAll(response.Body)
 627	err = json.Unmarshal(data, &issue)
 628	if err != nil {
 629		err := fmt.Errorf("Decoding response %v", err)
 630		return nil, err
 631	}
 632
 633	return &issue, nil
 634}
 635
 636// GetComments returns a page of comments via the issue/{IssueIdOrKey}/comment
 637// endpoint
 638// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getComment
 639func (client *Client) GetComments(idOrKey string, maxResults int, startAt int) (*CommentPage, error) {
 640	url := fmt.Sprintf(
 641		"%s/rest/api/2/issue/%s/comment", client.serverURL, idOrKey)
 642
 643	request, err := http.NewRequest("GET", url, nil)
 644	if err != nil {
 645		err := fmt.Errorf("Creating request %v", err)
 646		return nil, err
 647	}
 648
 649	query := request.URL.Query()
 650	if maxResults > 0 {
 651		query.Add("maxResults", fmt.Sprintf("%d", maxResults))
 652	}
 653	if startAt > 0 {
 654		query.Add("startAt", fmt.Sprintf("%d", startAt))
 655	}
 656	request.URL.RawQuery = query.Encode()
 657
 658	if client.ctx != nil {
 659		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
 660		defer cancel()
 661		request = request.WithContext(ctx)
 662	}
 663
 664	response, err := client.Do(request)
 665	if err != nil {
 666		err := fmt.Errorf("Performing request %v", err)
 667		return nil, err
 668	}
 669	defer response.Body.Close()
 670
 671	if response.StatusCode != http.StatusOK {
 672		err := fmt.Errorf(
 673			"HTTP response %d, query was %s", response.StatusCode,
 674			request.URL.String())
 675		return nil, err
 676	}
 677
 678	var comments CommentPage
 679
 680	data, _ := ioutil.ReadAll(response.Body)
 681	err = json.Unmarshal(data, &comments)
 682	if err != nil {
 683		err := fmt.Errorf("Decoding response %v", err)
 684		return nil, err
 685	}
 686
 687	return &comments, nil
 688}
 689
 690// CommentIterator cursor within paginated results from the /comment endpoint
 691type CommentIterator struct {
 692	client  *Client
 693	idOrKey string
 694	message *CommentPage
 695	Err     error
 696
 697	pageSize int
 698	itemIdx  int
 699}
 700
 701// HasError returns true if the iterator is holding an error
 702func (ci *CommentIterator) HasError() bool {
 703	if ci.Err == errDone {
 704		return false
 705	}
 706	if ci.Err == nil {
 707		return false
 708	}
 709	return true
 710}
 711
 712// HasNext returns true if there is another item available in the result set
 713func (ci *CommentIterator) HasNext() bool {
 714	return ci.Err == nil && ci.itemIdx < len(ci.message.Comments)
 715}
 716
 717// Next Return the next item in the result set and advance the iterator.
 718// Advancing the iterator may require fetching a new page.
 719func (ci *CommentIterator) Next() *Comment {
 720	if ci.Err != nil {
 721		return nil
 722	}
 723
 724	comment := ci.message.Comments[ci.itemIdx]
 725	if ci.itemIdx+1 < len(ci.message.Comments) {
 726		// We still have an item left in the currently cached page
 727		ci.itemIdx++
 728	} else {
 729		if ci.message.IsLastPage() {
 730			ci.Err = errDone
 731		} else {
 732			// There are still more pages to fetch, so fetch the next page and
 733			// cache it
 734			ci.message, ci.Err = ci.client.GetComments(
 735				ci.idOrKey, ci.pageSize, ci.message.NextStartAt())
 736			// NOTE(josh): we don't deal with the error now, we just cache it.
 737			// HasNext() will return false and the caller can check the error
 738			// afterward.
 739			ci.itemIdx = 0
 740		}
 741	}
 742	return &comment
 743}
 744
 745// IterComments returns an iterator over paginated comments within an issue
 746func (client *Client) IterComments(idOrKey string, pageSize int) *CommentIterator {
 747	message, err := client.GetComments(idOrKey, pageSize, 0)
 748
 749	iter := &CommentIterator{
 750		client:   client,
 751		idOrKey:  idOrKey,
 752		message:  message,
 753		Err:      err,
 754		pageSize: pageSize,
 755		itemIdx:  0,
 756	}
 757
 758	return iter
 759}
 760
 761// GetChangeLog fetch one page of the changelog for an issue via the
 762// /issue/{IssueIdOrKey}/changelog endpoint (for JIRA cloud) or
 763// /issue/{IssueIdOrKey} with (fields=*none&expand=changelog)
 764// (for JIRA server)
 765// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue
 766func (client *Client) GetChangeLog(idOrKey string, maxResults int, startAt int) (*ChangeLogPage, error) {
 767	url := fmt.Sprintf(
 768		"%s/rest/api/2/issue/%s/changelog", client.serverURL, idOrKey)
 769
 770	request, err := http.NewRequest("GET", url, nil)
 771	if err != nil {
 772		err := fmt.Errorf("Creating request %v", err)
 773		return nil, err
 774	}
 775
 776	query := request.URL.Query()
 777	if maxResults > 0 {
 778		query.Add("maxResults", fmt.Sprintf("%d", maxResults))
 779	}
 780	if startAt > 0 {
 781		query.Add("startAt", fmt.Sprintf("%d", startAt))
 782	}
 783	request.URL.RawQuery = query.Encode()
 784
 785	if client.ctx != nil {
 786		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
 787		defer cancel()
 788		request = request.WithContext(ctx)
 789	}
 790
 791	response, err := client.Do(request)
 792	if err != nil {
 793		err := fmt.Errorf("Performing request %v", err)
 794		return nil, err
 795	}
 796	defer response.Body.Close()
 797
 798	if response.StatusCode == http.StatusNotFound {
 799		// The issue/{IssueIdOrKey}/changelog endpoint is only available on JIRA cloud
 800		// products, not on JIRA server. In order to get the information we have to
 801		// query the issue and ask for a changelog expansion. Unfortunately this means
 802		// that the changelog is not paginated and we have to fetch the entire thing
 803		// at once. Hopefully things don't break for very long changelogs.
 804		issue, err := client.GetIssue(
 805			idOrKey, []string{"*none"}, []string{"changelog"}, []string{})
 806		if err != nil {
 807			return nil, err
 808		}
 809
 810		return &issue.ChangeLog, nil
 811	}
 812
 813	if response.StatusCode != http.StatusOK {
 814		err := fmt.Errorf(
 815			"HTTP response %d, query was %s", response.StatusCode,
 816			request.URL.String())
 817		return nil, err
 818	}
 819
 820	var changelog ChangeLogPage
 821
 822	data, _ := ioutil.ReadAll(response.Body)
 823	err = json.Unmarshal(data, &changelog)
 824	if err != nil {
 825		err := fmt.Errorf("Decoding response %v", err)
 826		return nil, err
 827	}
 828
 829	// JIRA cloud returns changelog entries in the "values" list, whereas JIRA
 830	// server returns them in the "histories" list when embedded in an issue
 831	// object.
 832	changelog.Entries = changelog.Values
 833	changelog.Values = nil
 834
 835	return &changelog, nil
 836}
 837
 838// ChangeLogIterator cursor within paginated results from the /search endpoint
 839type ChangeLogIterator struct {
 840	client  *Client
 841	idOrKey string
 842	message *ChangeLogPage
 843	Err     error
 844
 845	pageSize int
 846	itemIdx  int
 847}
 848
 849// HasError returns true if the iterator is holding an error
 850func (cli *ChangeLogIterator) HasError() bool {
 851	if cli.Err == errDone {
 852		return false
 853	}
 854	if cli.Err == nil {
 855		return false
 856	}
 857	return true
 858}
 859
 860// HasNext returns true if there is another item available in the result set
 861func (cli *ChangeLogIterator) HasNext() bool {
 862	return cli.Err == nil && cli.itemIdx < len(cli.message.Entries)
 863}
 864
 865// Next Return the next item in the result set and advance the iterator.
 866// Advancing the iterator may require fetching a new page.
 867func (cli *ChangeLogIterator) Next() *ChangeLogEntry {
 868	if cli.Err != nil {
 869		return nil
 870	}
 871
 872	item := cli.message.Entries[cli.itemIdx]
 873	if cli.itemIdx+1 < len(cli.message.Entries) {
 874		// We still have an item left in the currently cached page
 875		cli.itemIdx++
 876	} else {
 877		if cli.message.IsLastPage() {
 878			cli.Err = errDone
 879		} else {
 880			// There are still more pages to fetch, so fetch the next page and
 881			// cache it
 882			cli.message, cli.Err = cli.client.GetChangeLog(
 883				cli.idOrKey, cli.pageSize, cli.message.NextStartAt())
 884			// NOTE(josh): we don't deal with the error now, we just cache it.
 885			// HasNext() will return false and the caller can check the error
 886			// afterward.
 887			cli.itemIdx = 0
 888		}
 889	}
 890	return &item
 891}
 892
 893// IterChangeLog returns an iterator over entries in the changelog for an issue
 894func (client *Client) IterChangeLog(idOrKey string, pageSize int) *ChangeLogIterator {
 895	message, err := client.GetChangeLog(idOrKey, pageSize, 0)
 896
 897	iter := &ChangeLogIterator{
 898		client:   client,
 899		idOrKey:  idOrKey,
 900		message:  message,
 901		Err:      err,
 902		pageSize: pageSize,
 903		itemIdx:  0,
 904	}
 905
 906	return iter
 907}
 908
 909// GetProject returns the project JSON object given its id or key
 910func (client *Client) GetProject(projectIDOrKey string) (*Project, error) {
 911	url := fmt.Sprintf(
 912		"%s/rest/api/2/project/%s", client.serverURL, projectIDOrKey)
 913
 914	request, err := http.NewRequest("GET", url, nil)
 915	if err != nil {
 916		return nil, err
 917	}
 918
 919	if client.ctx != nil {
 920		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
 921		defer cancel()
 922		request = request.WithContext(ctx)
 923	}
 924
 925	response, err := client.Do(request)
 926	if err != nil {
 927		return nil, err
 928	}
 929
 930	defer response.Body.Close()
 931
 932	if response.StatusCode != http.StatusOK {
 933		err := fmt.Errorf(
 934			"HTTP response %d, query was %s", response.StatusCode, url)
 935		return nil, err
 936	}
 937
 938	var project Project
 939
 940	data, _ := ioutil.ReadAll(response.Body)
 941	err = json.Unmarshal(data, &project)
 942	if err != nil {
 943		err := fmt.Errorf("Decoding response %v", err)
 944		return nil, err
 945	}
 946
 947	return &project, nil
 948}
 949
 950// CreateIssue creates a new JIRA issue and returns it
 951func (client *Client) CreateIssue(projectIDOrKey, title, body string,
 952	extra map[string]interface{}) (*IssueCreateResult, error) {
 953
 954	url := fmt.Sprintf("%s/rest/api/2/issue", client.serverURL)
 955
 956	fields := make(map[string]interface{})
 957	fields["summary"] = title
 958	fields["description"] = body
 959	for key, value := range extra {
 960		fields[key] = value
 961	}
 962
 963	// If the project string is an integer than assume it is an ID. Otherwise it
 964	// is a key.
 965	_, err := strconv.Atoi(projectIDOrKey)
 966	if err == nil {
 967		fields["project"] = map[string]string{"id": projectIDOrKey}
 968	} else {
 969		fields["project"] = map[string]string{"key": projectIDOrKey}
 970	}
 971
 972	message := make(map[string]interface{})
 973	message["fields"] = fields
 974
 975	data, err := json.Marshal(message)
 976	if err != nil {
 977		return nil, err
 978	}
 979
 980	request, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
 981	if err != nil {
 982		return nil, err
 983	}
 984
 985	if client.ctx != nil {
 986		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
 987		defer cancel()
 988		request = request.WithContext(ctx)
 989	}
 990
 991	response, err := client.Do(request)
 992	if err != nil {
 993		err := fmt.Errorf("Performing request %v", err)
 994		return nil, err
 995	}
 996	defer response.Body.Close()
 997
 998	if response.StatusCode != http.StatusCreated {
 999		content, _ := ioutil.ReadAll(response.Body)
1000		err := fmt.Errorf(
1001			"HTTP response %d, query was %s\n  data: %s\n  response: %s",
1002			response.StatusCode, request.URL.String(), data, content)
1003		return nil, err
1004	}
1005
1006	var result IssueCreateResult
1007
1008	data, _ = ioutil.ReadAll(response.Body)
1009	err = json.Unmarshal(data, &result)
1010	if err != nil {
1011		err := fmt.Errorf("Decoding response %v", err)
1012		return nil, err
1013	}
1014
1015	return &result, nil
1016}
1017
1018// UpdateIssueTitle changes the "summary" field of a JIRA issue
1019func (client *Client) UpdateIssueTitle(issueKeyOrID, title string) (time.Time, error) {
1020
1021	url := fmt.Sprintf(
1022		"%s/rest/api/2/issue/%s", client.serverURL, issueKeyOrID)
1023	var responseTime time.Time
1024
1025	// NOTE(josh): Since updates are a list of heterogeneous objects let's just
1026	// manually build the JSON text
1027	data, err := json.Marshal(title)
1028	if err != nil {
1029		return responseTime, err
1030	}
1031
1032	var buffer bytes.Buffer
1033	_, _ = fmt.Fprintf(&buffer, `{"update":{"summary":[`)
1034	_, _ = fmt.Fprintf(&buffer, `{"set":%s}`, data)
1035	_, _ = fmt.Fprintf(&buffer, `]}}`)
1036
1037	data = buffer.Bytes()
1038	request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data))
1039	if err != nil {
1040		return responseTime, err
1041	}
1042
1043	response, err := client.Do(request)
1044	if err != nil {
1045		err := fmt.Errorf("Performing request %v", err)
1046		return responseTime, err
1047	}
1048	defer response.Body.Close()
1049
1050	if response.StatusCode != http.StatusNoContent {
1051		content, _ := ioutil.ReadAll(response.Body)
1052		err := fmt.Errorf(
1053			"HTTP response %d, query was %s\n  data: %s\n  response: %s",
1054			response.StatusCode, request.URL.String(), data, content)
1055		return responseTime, err
1056	}
1057
1058	dateHeader, ok := response.Header["Date"]
1059	if !ok || len(dateHeader) != 1 {
1060		// No "Date" header, or empty, or multiple of them. Regardless, we don't
1061		// have a date we can return
1062		return responseTime, nil
1063	}
1064
1065	responseTime, err = http.ParseTime(dateHeader[0])
1066	if err != nil {
1067		return time.Time{}, err
1068	}
1069
1070	return responseTime, nil
1071}
1072
1073// UpdateIssueBody changes the "description" field of a JIRA issue
1074func (client *Client) UpdateIssueBody(issueKeyOrID, body string) (time.Time, error) {
1075
1076	url := fmt.Sprintf(
1077		"%s/rest/api/2/issue/%s", client.serverURL, issueKeyOrID)
1078	var responseTime time.Time
1079	// NOTE(josh): Since updates are a list of heterogeneous objects let's just
1080	// manually build the JSON text
1081	data, err := json.Marshal(body)
1082	if err != nil {
1083		return responseTime, err
1084	}
1085
1086	var buffer bytes.Buffer
1087	_, _ = fmt.Fprintf(&buffer, `{"update":{"description":[`)
1088	_, _ = fmt.Fprintf(&buffer, `{"set":%s}`, data)
1089	_, _ = fmt.Fprintf(&buffer, `]}}`)
1090
1091	data = buffer.Bytes()
1092	request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data))
1093	if err != nil {
1094		return responseTime, err
1095	}
1096
1097	if client.ctx != nil {
1098		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
1099		defer cancel()
1100		request = request.WithContext(ctx)
1101	}
1102
1103	response, err := client.Do(request)
1104	if err != nil {
1105		err := fmt.Errorf("Performing request %v", err)
1106		return responseTime, err
1107	}
1108	defer response.Body.Close()
1109
1110	if response.StatusCode != http.StatusNoContent {
1111		content, _ := ioutil.ReadAll(response.Body)
1112		err := fmt.Errorf(
1113			"HTTP response %d, query was %s\n  data: %s\n  response: %s",
1114			response.StatusCode, request.URL.String(), data, content)
1115		return responseTime, err
1116	}
1117
1118	dateHeader, ok := response.Header["Date"]
1119	if !ok || len(dateHeader) != 1 {
1120		// No "Date" header, or empty, or multiple of them. Regardless, we don't
1121		// have a date we can return
1122		return responseTime, nil
1123	}
1124
1125	responseTime, err = http.ParseTime(dateHeader[0])
1126	if err != nil {
1127		return time.Time{}, err
1128	}
1129
1130	return responseTime, nil
1131}
1132
1133// AddComment adds a new comment to an issue (and returns it).
1134func (client *Client) AddComment(issueKeyOrID, body string) (*Comment, error) {
1135	url := fmt.Sprintf(
1136		"%s/rest/api/2/issue/%s/comment", client.serverURL, issueKeyOrID)
1137
1138	params := CommentCreate{Body: body}
1139	data, err := json.Marshal(params)
1140	if err != nil {
1141		return nil, err
1142	}
1143
1144	request, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
1145	if err != nil {
1146		return nil, err
1147	}
1148
1149	if client.ctx != nil {
1150		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
1151		defer cancel()
1152		request = request.WithContext(ctx)
1153	}
1154
1155	response, err := client.Do(request)
1156	if err != nil {
1157		err := fmt.Errorf("Performing request %v", err)
1158		return nil, err
1159	}
1160	defer response.Body.Close()
1161
1162	if response.StatusCode != http.StatusCreated {
1163		content, _ := ioutil.ReadAll(response.Body)
1164		err := fmt.Errorf(
1165			"HTTP response %d, query was %s\n  data: %s\n  response: %s",
1166			response.StatusCode, request.URL.String(), data, content)
1167		return nil, err
1168	}
1169
1170	var result Comment
1171
1172	data, _ = ioutil.ReadAll(response.Body)
1173	err = json.Unmarshal(data, &result)
1174	if err != nil {
1175		err := fmt.Errorf("Decoding response %v", err)
1176		return nil, err
1177	}
1178
1179	return &result, nil
1180}
1181
1182// UpdateComment changes the text of a comment
1183func (client *Client) UpdateComment(issueKeyOrID, commentID, body string) (
1184	*Comment, error) {
1185	url := fmt.Sprintf(
1186		"%s/rest/api/2/issue/%s/comment/%s", client.serverURL, issueKeyOrID,
1187		commentID)
1188
1189	params := CommentCreate{Body: body}
1190	data, err := json.Marshal(params)
1191	if err != nil {
1192		return nil, err
1193	}
1194
1195	request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data))
1196	if err != nil {
1197		return nil, err
1198	}
1199
1200	if client.ctx != nil {
1201		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
1202		defer cancel()
1203		request = request.WithContext(ctx)
1204	}
1205
1206	response, err := client.Do(request)
1207	if err != nil {
1208		err := fmt.Errorf("Performing request %v", err)
1209		return nil, err
1210	}
1211	defer response.Body.Close()
1212
1213	if response.StatusCode != http.StatusOK {
1214		err := fmt.Errorf(
1215			"HTTP response %d, query was %s", response.StatusCode,
1216			request.URL.String())
1217		return nil, err
1218	}
1219
1220	var result Comment
1221
1222	data, _ = ioutil.ReadAll(response.Body)
1223	err = json.Unmarshal(data, &result)
1224	if err != nil {
1225		err := fmt.Errorf("Decoding response %v", err)
1226		return nil, err
1227	}
1228
1229	return &result, nil
1230}
1231
1232// UpdateLabels changes labels for an issue
1233func (client *Client) UpdateLabels(issueKeyOrID string, added, removed []bug.Label) (time.Time, error) {
1234	url := fmt.Sprintf(
1235		"%s/rest/api/2/issue/%s/", client.serverURL, issueKeyOrID)
1236	var responseTime time.Time
1237
1238	// NOTE(josh): Since updates are a list of heterogeneous objects let's just
1239	// manually build the JSON text
1240	var buffer bytes.Buffer
1241	_, _ = fmt.Fprintf(&buffer, `{"update":{"labels":[`)
1242	first := true
1243	for _, label := range added {
1244		if !first {
1245			_, _ = fmt.Fprintf(&buffer, ",")
1246		}
1247		_, _ = fmt.Fprintf(&buffer, `{"add":"%s"}`, label)
1248		first = false
1249	}
1250	for _, label := range removed {
1251		if !first {
1252			_, _ = fmt.Fprintf(&buffer, ",")
1253		}
1254		_, _ = fmt.Fprintf(&buffer, `{"remove":"%s"}`, label)
1255		first = false
1256	}
1257	_, _ = fmt.Fprintf(&buffer, "]}}")
1258
1259	data := buffer.Bytes()
1260	request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data))
1261	if err != nil {
1262		return responseTime, err
1263	}
1264
1265	if client.ctx != nil {
1266		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
1267		defer cancel()
1268		request = request.WithContext(ctx)
1269	}
1270
1271	response, err := client.Do(request)
1272	if err != nil {
1273		err := fmt.Errorf("Performing request %v", err)
1274		return responseTime, err
1275	}
1276	defer response.Body.Close()
1277
1278	if response.StatusCode != http.StatusNoContent {
1279		content, _ := ioutil.ReadAll(response.Body)
1280		err := fmt.Errorf(
1281			"HTTP response %d, query was %s\n  data: %s\n  response: %s",
1282			response.StatusCode, request.URL.String(), data, content)
1283		return responseTime, err
1284	}
1285
1286	dateHeader, ok := response.Header["Date"]
1287	if !ok || len(dateHeader) != 1 {
1288		// No "Date" header, or empty, or multiple of them. Regardless, we don't
1289		// have a date we can return
1290		return responseTime, nil
1291	}
1292
1293	responseTime, err = http.ParseTime(dateHeader[0])
1294	if err != nil {
1295		return time.Time{}, err
1296	}
1297
1298	return responseTime, nil
1299}
1300
1301// GetTransitions returns a list of available transitions for an issue
1302func (client *Client) GetTransitions(issueKeyOrID string) (*TransitionList, error) {
1303
1304	url := fmt.Sprintf(
1305		"%s/rest/api/2/issue/%s/transitions", client.serverURL, issueKeyOrID)
1306
1307	request, err := http.NewRequest("GET", url, nil)
1308	if err != nil {
1309		err := fmt.Errorf("Creating request %v", err)
1310		return nil, err
1311	}
1312
1313	if client.ctx != nil {
1314		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
1315		defer cancel()
1316		request = request.WithContext(ctx)
1317	}
1318
1319	response, err := client.Do(request)
1320	if err != nil {
1321		err := fmt.Errorf("Performing request %v", err)
1322		return nil, err
1323	}
1324	defer response.Body.Close()
1325
1326	if response.StatusCode != http.StatusOK {
1327		err := fmt.Errorf(
1328			"HTTP response %d, query was %s", response.StatusCode,
1329			request.URL.String())
1330		return nil, err
1331	}
1332
1333	var message TransitionList
1334
1335	data, _ := ioutil.ReadAll(response.Body)
1336	err = json.Unmarshal(data, &message)
1337	if err != nil {
1338		err := fmt.Errorf("Decoding response %v", err)
1339		return nil, err
1340	}
1341
1342	return &message, nil
1343}
1344
1345func getTransitionTo(tlist *TransitionList, desiredStateNameOrID string) *Transition {
1346	for _, transition := range tlist.Transitions {
1347		if transition.To.ID == desiredStateNameOrID {
1348			return &transition
1349		} else if transition.To.Name == desiredStateNameOrID {
1350			return &transition
1351		}
1352	}
1353	return nil
1354}
1355
1356// DoTransition changes the "status" of an issue
1357func (client *Client) DoTransition(issueKeyOrID string, transitionID string) (time.Time, error) {
1358	url := fmt.Sprintf(
1359		"%s/rest/api/2/issue/%s/transitions", client.serverURL, issueKeyOrID)
1360	var responseTime time.Time
1361
1362	// TODO(josh)[767ee72]: Figure out a good way to "configure" the
1363	// open/close state mapping. It would be *great* if we could actually
1364	// *compute* the necessary transitions and prompt for missing metatdata...
1365	// but that is complex
1366	var buffer bytes.Buffer
1367	_, _ = fmt.Fprintf(&buffer,
1368		`{"transition":{"id":"%s"}, "resolution": {"name": "Done"}}`,
1369		transitionID)
1370	request, err := http.NewRequest("POST", url, bytes.NewBuffer(buffer.Bytes()))
1371	if err != nil {
1372		return responseTime, err
1373	}
1374
1375	if client.ctx != nil {
1376		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
1377		defer cancel()
1378		request = request.WithContext(ctx)
1379	}
1380
1381	response, err := client.Do(request)
1382	if err != nil {
1383		err := fmt.Errorf("Performing request %v", err)
1384		return responseTime, err
1385	}
1386	defer response.Body.Close()
1387
1388	if response.StatusCode != http.StatusNoContent {
1389		err := errors.Wrap(errTransitionNotAllowed, fmt.Sprintf(
1390			"HTTP response %d, query was %s", response.StatusCode,
1391			request.URL.String()))
1392		return responseTime, err
1393	}
1394
1395	dateHeader, ok := response.Header["Date"]
1396	if !ok || len(dateHeader) != 1 {
1397		// No "Date" header, or empty, or multiple of them. Regardless, we don't
1398		// have a date we can return
1399		return responseTime, nil
1400	}
1401
1402	responseTime, err = http.ParseTime(dateHeader[0])
1403	if err != nil {
1404		return time.Time{}, err
1405	}
1406
1407	return responseTime, nil
1408}
1409
1410// GetServerInfo Fetch server information from the /serverinfo endpoint
1411// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue
1412func (client *Client) GetServerInfo() (*ServerInfo, error) {
1413	url := fmt.Sprintf("%s/rest/api/2/serverinfo", client.serverURL)
1414
1415	request, err := http.NewRequest("GET", url, nil)
1416	if err != nil {
1417		err := fmt.Errorf("Creating request %v", err)
1418		return nil, err
1419	}
1420
1421	if client.ctx != nil {
1422		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
1423		defer cancel()
1424		request = request.WithContext(ctx)
1425	}
1426
1427	response, err := client.Do(request)
1428	if err != nil {
1429		err := fmt.Errorf("Performing request %v", err)
1430		return nil, err
1431	}
1432	defer response.Body.Close()
1433
1434	if response.StatusCode != http.StatusOK {
1435		err := fmt.Errorf(
1436			"HTTP response %d, query was %s", response.StatusCode,
1437			request.URL.String())
1438		return nil, err
1439	}
1440
1441	var message ServerInfo
1442
1443	data, _ := ioutil.ReadAll(response.Body)
1444	err = json.Unmarshal(data, &message)
1445	if err != nil {
1446		err := fmt.Errorf("Decoding response %v", err)
1447		return nil, err
1448	}
1449
1450	return &message, nil
1451}
1452
1453// GetServerTime returns the current time on the server
1454func (client *Client) GetServerTime() (Time, error) {
1455	var result Time
1456	info, err := client.GetServerInfo()
1457	if err != nil {
1458		return result, err
1459	}
1460	return info.ServerTime, nil
1461}