client.go

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