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