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 (self *ClientTransport) RoundTrip(
 319	req *http.Request) (*http.Response, error) {
 320	req.Header.Add("Content-Type", "application/json")
 321	if self.basicAuthString != "" {
 322		req.Header.Add("Authorization",
 323			fmt.Sprintf("Basic %s", self.basicAuthString))
 324	}
 325
 326	return self.underlyingTransport.RoundTrip(req)
 327}
 328
 329func (self *ClientTransport) SetCredentials(
 330	username string, token string) {
 331	credString := fmt.Sprintf("%s:%s", username, token)
 332	self.basicAuthString = base64.StdEncoding.EncodeToString([]byte(credString))
 333}
 334
 335// Client Thin wrapper around the http.Client providing jira-specific methods
 336// for APIendpoints
 337type Client struct {
 338	*http.Client
 339	serverURL string
 340	ctx       context.Context
 341}
 342
 343// NewClient Construct a new client connected to the provided server and
 344// utilizing the given context for asynchronous events
 345func NewClient(serverURL string, ctx context.Context) *Client {
 346	cookiJar, _ := cookiejar.New(nil)
 347	client := &http.Client{
 348		Transport: &ClientTransport{underlyingTransport: http.DefaultTransport},
 349		Jar:       cookiJar,
 350	}
 351
 352	return &Client{client, serverURL, ctx}
 353}
 354
 355// Login POST credentials to the /session endpoing and get a session cookie
 356func (client *Client) Login(conf core.Configuration) error {
 357	credType := conf[keyCredentialsType]
 358
 359	if conf[keyCredentialsFile] != "" {
 360		content, err := ioutil.ReadFile(conf[keyCredentialsFile])
 361		if err != nil {
 362			return err
 363		}
 364
 365		switch credType {
 366		case "SESSION":
 367			return client.RefreshSessionTokenRaw(content)
 368		case "TOKEN":
 369			var params SessionQuery
 370			err := json.Unmarshal(content, &params)
 371			if err != nil {
 372				return err
 373			}
 374			return client.SetTokenCredentials(params.Username, params.Password)
 375		}
 376		return fmt.Errorf("Unexpected credType: %s", credType)
 377	}
 378
 379	username := conf[keyUsername]
 380	if username == "" {
 381		return fmt.Errorf(
 382			"Invalid configuration lacks both a username and credentials sidecar " +
 383				"path. At least one is required.")
 384	}
 385
 386	password := conf[keyPassword]
 387	if password == "" {
 388		var err error
 389		password, err = input.PromptPassword()
 390		if err != nil {
 391			return err
 392		}
 393	}
 394
 395	switch credType {
 396	case "SESSION":
 397		return client.RefreshSessionToken(username, password)
 398	case "TOKEN":
 399		return client.SetTokenCredentials(username, password)
 400	}
 401	return fmt.Errorf("Unexpected credType: %s", credType)
 402}
 403
 404// RefreshSessionToken formulate the JSON request object from the user
 405// credentials and POST it to the /session endpoing and get a session cookie
 406func (client *Client) RefreshSessionToken(username, password string) error {
 407	params := SessionQuery{
 408		Username: username,
 409		Password: password,
 410	}
 411
 412	data, err := json.Marshal(params)
 413	if err != nil {
 414		return err
 415	}
 416
 417	return client.RefreshSessionTokenRaw(data)
 418}
 419
 420// SetTokenCredentials POST credentials to the /session endpoing and get a
 421// session cookie
 422func (client *Client) SetTokenCredentials(username, password string) error {
 423	switch transport := client.Transport.(type) {
 424	case *ClientTransport:
 425		transport.SetCredentials(username, password)
 426	default:
 427		return fmt.Errorf("Invalid transport type")
 428	}
 429	return nil
 430}
 431
 432// RefreshSessionTokenRaw POST credentials to the /session endpoing and get a
 433// session cookie
 434func (client *Client) RefreshSessionTokenRaw(credentialsJSON []byte) error {
 435	postURL := fmt.Sprintf("%s/rest/auth/1/session", client.serverURL)
 436
 437	req, err := http.NewRequest("POST", postURL, bytes.NewBuffer(credentialsJSON))
 438	if err != nil {
 439		return err
 440	}
 441
 442	urlobj, err := url.Parse(client.serverURL)
 443	if err != nil {
 444		fmt.Printf("Failed to parse %s\n", client.serverURL)
 445	} else {
 446		// Clear out cookies
 447		client.Jar.SetCookies(urlobj, []*http.Cookie{})
 448	}
 449
 450	if client.ctx != nil {
 451		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
 452		defer cancel()
 453		req = req.WithContext(ctx)
 454	}
 455
 456	response, err := client.Do(req)
 457	if err != nil {
 458		return err
 459	}
 460
 461	defer response.Body.Close()
 462
 463	if response.StatusCode != http.StatusOK {
 464		content, _ := ioutil.ReadAll(response.Body)
 465		return fmt.Errorf(
 466			"error creating token %v: %s", response.StatusCode, content)
 467	}
 468
 469	data, _ := ioutil.ReadAll(response.Body)
 470	var aux SessionResponse
 471	err = json.Unmarshal(data, &aux)
 472	if err != nil {
 473		return err
 474	}
 475
 476	var cookies []*http.Cookie
 477	cookie := &http.Cookie{
 478		Name:  aux.Session.Name,
 479		Value: aux.Session.Value,
 480	}
 481	cookies = append(cookies, cookie)
 482	client.Jar.SetCookies(urlobj, cookies)
 483
 484	return nil
 485}
 486
 487// =============================================================================
 488// Endpoint Wrappers
 489// =============================================================================
 490
 491// Search Perform an issue a JQL search on the /search endpoint
 492// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/search
 493func (client *Client) Search(jql string, maxResults int, startAt int) (
 494	*SearchResult, error) {
 495	url := fmt.Sprintf("%s/rest/api/2/search", client.serverURL)
 496
 497	requestBody, err := json.Marshal(SearchRequest{
 498		JQL:        jql,
 499		StartAt:    startAt,
 500		MaxResults: maxResults,
 501		Fields: []string{
 502			"comment",
 503			"created",
 504			"creator",
 505			"description",
 506			"labels",
 507			"status",
 508			"summary"}})
 509	if err != nil {
 510		return nil, err
 511	}
 512
 513	request, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody))
 514	if err != nil {
 515		return nil, err
 516	}
 517
 518	if client.ctx != nil {
 519		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
 520		defer cancel()
 521		request = request.WithContext(ctx)
 522	}
 523
 524	response, err := client.Do(request)
 525	if err != nil {
 526		return nil, err
 527	}
 528	defer response.Body.Close()
 529
 530	if response.StatusCode != http.StatusOK {
 531		err := fmt.Errorf(
 532			"HTTP response %d, query was %s, %s", response.StatusCode,
 533			url, requestBody)
 534		return nil, err
 535	}
 536
 537	var message SearchResult
 538
 539	data, _ := ioutil.ReadAll(response.Body)
 540	err = json.Unmarshal(data, &message)
 541	if err != nil {
 542		err := fmt.Errorf("Decoding response %v", err)
 543		return nil, err
 544	}
 545
 546	return &message, nil
 547}
 548
 549// SearchIterator cursor within paginated results from the /search endpoint
 550type SearchIterator struct {
 551	client       *Client
 552	jql          string
 553	searchResult *SearchResult
 554	Err          error
 555
 556	pageSize int
 557	itemIdx  int
 558}
 559
 560// HasError returns true if the iterator is holding an error
 561func (self *SearchIterator) HasError() bool {
 562	if self.Err == errDone {
 563		return false
 564	}
 565	if self.Err == nil {
 566		return false
 567	}
 568	return true
 569}
 570
 571// HasNext returns true if there is another item available in the result set
 572func (self *SearchIterator) HasNext() bool {
 573	return self.Err == nil && self.itemIdx < len(self.searchResult.Issues)
 574}
 575
 576// Next Return the next item in the result set and advance the iterator.
 577// Advancing the iterator may require fetching a new page.
 578func (self *SearchIterator) Next() *Issue {
 579	if self.Err != nil {
 580		return nil
 581	}
 582
 583	issue := self.searchResult.Issues[self.itemIdx]
 584	if self.itemIdx+1 < len(self.searchResult.Issues) {
 585		// We still have an item left in the currently cached page
 586		self.itemIdx++
 587	} else {
 588		if self.searchResult.IsLastPage() {
 589			self.Err = errDone
 590		} else {
 591			// There are still more pages to fetch, so fetch the next page and
 592			// cache it
 593			self.searchResult, self.Err = self.client.Search(
 594				self.jql, self.pageSize, self.searchResult.NextStartAt())
 595			// NOTE(josh): we don't deal with the error now, we just cache it.
 596			// HasNext() will return false and the caller can check the error
 597			// afterward.
 598			self.itemIdx = 0
 599		}
 600	}
 601	return &issue
 602}
 603
 604// IterSearch return an iterator over paginated results for a JQL search
 605func (client *Client) IterSearch(
 606	jql string, pageSize int) *SearchIterator {
 607	result, err := client.Search(jql, pageSize, 0)
 608
 609	iter := &SearchIterator{
 610		client:       client,
 611		jql:          jql,
 612		searchResult: result,
 613		Err:          err,
 614		pageSize:     pageSize,
 615		itemIdx:      0,
 616	}
 617
 618	return iter
 619}
 620
 621// GetIssue fetches an issue object via the /issue/{IssueIdOrKey} endpoint
 622// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue
 623func (client *Client) GetIssue(
 624	idOrKey string, fields []string, expand []string,
 625	properties []string) (*Issue, error) {
 626	url := fmt.Sprintf("%s/rest/api/2/issue/%s", client.serverURL, idOrKey)
 627
 628	request, err := http.NewRequest("GET", url, nil)
 629	if err != nil {
 630		err := fmt.Errorf("Creating request %v", err)
 631		return nil, err
 632	}
 633
 634	query := request.URL.Query()
 635	if len(fields) > 0 {
 636		query.Add("fields", strings.Join(fields, ","))
 637	}
 638	if len(expand) > 0 {
 639		query.Add("expand", strings.Join(expand, ","))
 640	}
 641	if len(properties) > 0 {
 642		query.Add("properties", strings.Join(properties, ","))
 643	}
 644	request.URL.RawQuery = query.Encode()
 645
 646	if client.ctx != nil {
 647		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
 648		defer cancel()
 649		request = request.WithContext(ctx)
 650	}
 651
 652	response, err := client.Do(request)
 653	if err != nil {
 654		err := fmt.Errorf("Performing request %v", err)
 655		return nil, err
 656	}
 657	defer response.Body.Close()
 658
 659	if response.StatusCode != http.StatusOK {
 660		err := fmt.Errorf(
 661			"HTTP response %d, query was %s", response.StatusCode,
 662			request.URL.String())
 663		return nil, err
 664	}
 665
 666	var issue Issue
 667
 668	data, _ := ioutil.ReadAll(response.Body)
 669	err = json.Unmarshal(data, &issue)
 670	if err != nil {
 671		err := fmt.Errorf("Decoding response %v", err)
 672		return nil, err
 673	}
 674
 675	return &issue, nil
 676}
 677
 678// GetComments returns a page of comments via the issue/{IssueIdOrKey}/comment
 679// endpoint
 680// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getComment
 681func (client *Client) GetComments(
 682	idOrKey string, maxResults int, startAt int) (*CommentPage, error) {
 683	url := fmt.Sprintf(
 684		"%s/rest/api/2/issue/%s/comment", client.serverURL, idOrKey)
 685
 686	request, err := http.NewRequest("GET", url, nil)
 687	if err != nil {
 688		err := fmt.Errorf("Creating request %v", err)
 689		return nil, err
 690	}
 691
 692	query := request.URL.Query()
 693	if maxResults > 0 {
 694		query.Add("maxResults", fmt.Sprintf("%d", maxResults))
 695	}
 696	if startAt > 0 {
 697		query.Add("startAt", fmt.Sprintf("%d", startAt))
 698	}
 699	request.URL.RawQuery = query.Encode()
 700
 701	if client.ctx != nil {
 702		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
 703		defer cancel()
 704		request = request.WithContext(ctx)
 705	}
 706
 707	response, err := client.Do(request)
 708	if err != nil {
 709		err := fmt.Errorf("Performing request %v", err)
 710		return nil, err
 711	}
 712	defer response.Body.Close()
 713
 714	if response.StatusCode != http.StatusOK {
 715		err := fmt.Errorf(
 716			"HTTP response %d, query was %s", response.StatusCode,
 717			request.URL.String())
 718		return nil, err
 719	}
 720
 721	var comments CommentPage
 722
 723	data, _ := ioutil.ReadAll(response.Body)
 724	err = json.Unmarshal(data, &comments)
 725	if err != nil {
 726		err := fmt.Errorf("Decoding response %v", err)
 727		return nil, err
 728	}
 729
 730	return &comments, nil
 731}
 732
 733// CommentIterator cursor within paginated results from the /comment endpoint
 734type CommentIterator struct {
 735	client  *Client
 736	idOrKey string
 737	message *CommentPage
 738	Err     error
 739
 740	pageSize int
 741	itemIdx  int
 742}
 743
 744// HasError returns true if the iterator is holding an error
 745func (self *CommentIterator) HasError() bool {
 746	if self.Err == errDone {
 747		return false
 748	}
 749	if self.Err == nil {
 750		return false
 751	}
 752	return true
 753}
 754
 755// HasNext returns true if there is another item available in the result set
 756func (self *CommentIterator) HasNext() bool {
 757	return self.Err == nil && self.itemIdx < len(self.message.Comments)
 758}
 759
 760// Next Return the next item in the result set and advance the iterator.
 761// Advancing the iterator may require fetching a new page.
 762func (self *CommentIterator) Next() *Comment {
 763	if self.Err != nil {
 764		return nil
 765	}
 766
 767	comment := self.message.Comments[self.itemIdx]
 768	if self.itemIdx+1 < len(self.message.Comments) {
 769		// We still have an item left in the currently cached page
 770		self.itemIdx++
 771	} else {
 772		if self.message.IsLastPage() {
 773			self.Err = errDone
 774		} else {
 775			// There are still more pages to fetch, so fetch the next page and
 776			// cache it
 777			self.message, self.Err = self.client.GetComments(
 778				self.idOrKey, self.pageSize, self.message.NextStartAt())
 779			// NOTE(josh): we don't deal with the error now, we just cache it.
 780			// HasNext() will return false and the caller can check the error
 781			// afterward.
 782			self.itemIdx = 0
 783		}
 784	}
 785	return &comment
 786}
 787
 788// IterComments returns an iterator over paginated comments within an issue
 789func (client *Client) IterComments(
 790	idOrKey string, pageSize int) *CommentIterator {
 791	message, err := client.GetComments(idOrKey, pageSize, 0)
 792
 793	iter := &CommentIterator{
 794		client:   client,
 795		idOrKey:  idOrKey,
 796		message:  message,
 797		Err:      err,
 798		pageSize: pageSize,
 799		itemIdx:  0,
 800	}
 801
 802	return iter
 803}
 804
 805// GetChangeLog fetchs one page of the changelog for an issue via the
 806// /issue/{IssueIdOrKey}/changelog endpoint (for JIRA cloud) or
 807// /issue/{IssueIdOrKey} with (fields=*none&expand=changelog)
 808// (for JIRA server)
 809// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue
 810func (client *Client) GetChangeLog(
 811	idOrKey string, maxResults int, startAt int) (*ChangeLogPage, error) {
 812	url := fmt.Sprintf(
 813		"%s/rest/api/2/issue/%s/changelog", client.serverURL, idOrKey)
 814
 815	request, err := http.NewRequest("GET", url, nil)
 816	if err != nil {
 817		err := fmt.Errorf("Creating request %v", err)
 818		return nil, err
 819	}
 820
 821	query := request.URL.Query()
 822	if maxResults > 0 {
 823		query.Add("maxResults", fmt.Sprintf("%d", maxResults))
 824	}
 825	if startAt > 0 {
 826		query.Add("startAt", fmt.Sprintf("%d", startAt))
 827	}
 828	request.URL.RawQuery = query.Encode()
 829
 830	if client.ctx != nil {
 831		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
 832		defer cancel()
 833		request = request.WithContext(ctx)
 834	}
 835
 836	response, err := client.Do(request)
 837	if err != nil {
 838		err := fmt.Errorf("Performing request %v", err)
 839		return nil, err
 840	}
 841	defer response.Body.Close()
 842
 843	if response.StatusCode == http.StatusNotFound {
 844		// The issue/{IssueIdOrKey}/changelog endpoint is only available on JIRA cloud
 845		// products, not on JIRA server. In order to get the information we have to
 846		// query the issue and ask for a changelog expansion. Unfortunately this means
 847		// that the changelog is not paginated and we have to fetch the entire thing
 848		// at once. Hopefully things don't break for very long changelogs.
 849		issue, err := client.GetIssue(
 850			idOrKey, []string{"*none"}, []string{"changelog"}, []string{})
 851		if err != nil {
 852			return nil, err
 853		}
 854
 855		return &issue.ChangeLog, nil
 856	}
 857
 858	if response.StatusCode != http.StatusOK {
 859		err := fmt.Errorf(
 860			"HTTP response %d, query was %s", response.StatusCode,
 861			request.URL.String())
 862		return nil, err
 863	}
 864
 865	var changelog ChangeLogPage
 866
 867	data, _ := ioutil.ReadAll(response.Body)
 868	err = json.Unmarshal(data, &changelog)
 869	if err != nil {
 870		err := fmt.Errorf("Decoding response %v", err)
 871		return nil, err
 872	}
 873
 874	// JIRA cloud returns changelog entries in the "values" list, whereas JIRA
 875	// server returns them in the "histories" list when embedded in an issue
 876	// object.
 877	changelog.Entries = changelog.Values
 878	changelog.Values = nil
 879
 880	return &changelog, nil
 881}
 882
 883// ChangeLogIterator cursor within paginated results from the /search endpoint
 884type ChangeLogIterator struct {
 885	client  *Client
 886	idOrKey string
 887	message *ChangeLogPage
 888	Err     error
 889
 890	pageSize int
 891	itemIdx  int
 892}
 893
 894// HasError returns true if the iterator is holding an error
 895func (self *ChangeLogIterator) HasError() bool {
 896	if self.Err == errDone {
 897		return false
 898	}
 899	if self.Err == nil {
 900		return false
 901	}
 902	return true
 903}
 904
 905// HasNext returns true if there is another item available in the result set
 906func (self *ChangeLogIterator) HasNext() bool {
 907	return self.Err == nil && self.itemIdx < len(self.message.Entries)
 908}
 909
 910// Next Return the next item in the result set and advance the iterator.
 911// Advancing the iterator may require fetching a new page.
 912func (self *ChangeLogIterator) Next() *ChangeLogEntry {
 913	if self.Err != nil {
 914		return nil
 915	}
 916
 917	item := self.message.Entries[self.itemIdx]
 918	if self.itemIdx+1 < len(self.message.Entries) {
 919		// We still have an item left in the currently cached page
 920		self.itemIdx++
 921	} else {
 922		if self.message.IsLastPage() {
 923			self.Err = errDone
 924		} else {
 925			// There are still more pages to fetch, so fetch the next page and
 926			// cache it
 927			self.message, self.Err = self.client.GetChangeLog(
 928				self.idOrKey, self.pageSize, self.message.NextStartAt())
 929			// NOTE(josh): we don't deal with the error now, we just cache it.
 930			// HasNext() will return false and the caller can check the error
 931			// afterward.
 932			self.itemIdx = 0
 933		}
 934	}
 935	return &item
 936}
 937
 938// IterChangeLog returns an iterator over entries in the changelog for an issue
 939func (client *Client) IterChangeLog(
 940	idOrKey string, pageSize int) *ChangeLogIterator {
 941	message, err := client.GetChangeLog(idOrKey, pageSize, 0)
 942
 943	iter := &ChangeLogIterator{
 944		client:   client,
 945		idOrKey:  idOrKey,
 946		message:  message,
 947		Err:      err,
 948		pageSize: pageSize,
 949		itemIdx:  0,
 950	}
 951
 952	return iter
 953}
 954
 955// GetProject returns the project JSON object given its id or key
 956func (client *Client) GetProject(projectIDOrKey string) (*Project, error) {
 957	url := fmt.Sprintf(
 958		"%s/rest/api/2/project/%s", client.serverURL, projectIDOrKey)
 959
 960	request, err := http.NewRequest("GET", url, nil)
 961	if err != nil {
 962		return nil, err
 963	}
 964
 965	if client.ctx != nil {
 966		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
 967		defer cancel()
 968		request = request.WithContext(ctx)
 969	}
 970
 971	response, err := client.Do(request)
 972	if err != nil {
 973		return nil, err
 974	}
 975
 976	defer response.Body.Close()
 977
 978	if response.StatusCode != http.StatusOK {
 979		err := fmt.Errorf(
 980			"HTTP response %d, query was %s", response.StatusCode, url)
 981		return nil, err
 982	}
 983
 984	var project Project
 985
 986	data, _ := ioutil.ReadAll(response.Body)
 987	err = json.Unmarshal(data, &project)
 988	if err != nil {
 989		err := fmt.Errorf("Decoding response %v", err)
 990		return nil, err
 991	}
 992
 993	return &project, nil
 994}
 995
 996// CreateIssue creates a new JIRA issue and returns it
 997func (client *Client) CreateIssue(
 998	projectIDOrKey, title, body string, extra map[string]interface{}) (
 999	*IssueCreateResult, error) {
1000
1001	url := fmt.Sprintf("%s/rest/api/2/issue", client.serverURL)
1002
1003	fields := make(map[string]interface{})
1004	fields["summary"] = title
1005	fields["description"] = body
1006	for key, value := range extra {
1007		fields[key] = value
1008	}
1009
1010	// If the project string is an integer than assume it is an ID. Otherwise it
1011	// is a key.
1012	_, err := strconv.Atoi(projectIDOrKey)
1013	if err == nil {
1014		fields["project"] = map[string]string{"id": projectIDOrKey}
1015	} else {
1016		fields["project"] = map[string]string{"key": projectIDOrKey}
1017	}
1018
1019	message := make(map[string]interface{})
1020	message["fields"] = fields
1021
1022	data, err := json.Marshal(message)
1023	if err != nil {
1024		return nil, err
1025	}
1026
1027	request, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
1028	if err != nil {
1029		return nil, err
1030	}
1031
1032	if client.ctx != nil {
1033		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
1034		defer cancel()
1035		request = request.WithContext(ctx)
1036	}
1037
1038	response, err := client.Do(request)
1039	if err != nil {
1040		err := fmt.Errorf("Performing request %v", err)
1041		return nil, err
1042	}
1043	defer response.Body.Close()
1044
1045	if response.StatusCode != http.StatusCreated {
1046		content, _ := ioutil.ReadAll(response.Body)
1047		err := fmt.Errorf(
1048			"HTTP response %d, query was %s\n  data: %s\n  response: %s",
1049			response.StatusCode, request.URL.String(), data, content)
1050		return nil, err
1051	}
1052
1053	var result IssueCreateResult
1054
1055	data, _ = ioutil.ReadAll(response.Body)
1056	err = json.Unmarshal(data, &result)
1057	if err != nil {
1058		err := fmt.Errorf("Decoding response %v", err)
1059		return nil, err
1060	}
1061
1062	return &result, nil
1063}
1064
1065// UpdateIssueTitle changes the "summary" field of a JIRA issue
1066func (client *Client) UpdateIssueTitle(
1067	issueKeyOrID, title string) (time.Time, error) {
1068
1069	url := fmt.Sprintf(
1070		"%s/rest/api/2/issue/%s", client.serverURL, issueKeyOrID)
1071	var responseTime time.Time
1072
1073	// NOTE(josh): Since updates are a list of heterogeneous objects let's just
1074	// manually build the JSON text
1075	data, err := json.Marshal(title)
1076	if err != nil {
1077		return responseTime, err
1078	}
1079
1080	var buffer bytes.Buffer
1081	fmt.Fprintf(&buffer, `{"update":{"summary":[`)
1082	fmt.Fprintf(&buffer, `{"set":%s}`, data)
1083	fmt.Fprintf(&buffer, `]}}`)
1084
1085	data = buffer.Bytes()
1086	request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data))
1087	if err != nil {
1088		return responseTime, err
1089	}
1090
1091	response, err := client.Do(request)
1092	if err != nil {
1093		err := fmt.Errorf("Performing request %v", err)
1094		return responseTime, err
1095	}
1096	defer response.Body.Close()
1097
1098	if response.StatusCode != http.StatusNoContent {
1099		content, _ := ioutil.ReadAll(response.Body)
1100		err := fmt.Errorf(
1101			"HTTP response %d, query was %s\n  data: %s\n  response: %s",
1102			response.StatusCode, request.URL.String(), data, content)
1103		return responseTime, err
1104	}
1105
1106	dateHeader, ok := response.Header["Date"]
1107	if !ok || len(dateHeader) != 1 {
1108		// No "Date" header, or empty, or multiple of them. Regardless, we don't
1109		// have a date we can return
1110		return responseTime, nil
1111	}
1112
1113	responseTime, err = http.ParseTime(dateHeader[0])
1114	if err != nil {
1115		return time.Time{}, err
1116	}
1117
1118	return responseTime, nil
1119}
1120
1121// UpdateIssueBody changes the "description" field of a JIRA issue
1122func (client *Client) UpdateIssueBody(
1123	issueKeyOrID, body string) (time.Time, error) {
1124
1125	url := fmt.Sprintf(
1126		"%s/rest/api/2/issue/%s", client.serverURL, issueKeyOrID)
1127	var responseTime time.Time
1128	// NOTE(josh): Since updates are a list of heterogeneous objects let's just
1129	// manually build the JSON text
1130	data, err := json.Marshal(body)
1131	if err != nil {
1132		return responseTime, err
1133	}
1134
1135	var buffer bytes.Buffer
1136	fmt.Fprintf(&buffer, `{"update":{"description":[`)
1137	fmt.Fprintf(&buffer, `{"set":%s}`, data)
1138	fmt.Fprintf(&buffer, `]}}`)
1139
1140	data = buffer.Bytes()
1141	request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data))
1142	if err != nil {
1143		return responseTime, err
1144	}
1145
1146	if client.ctx != nil {
1147		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
1148		defer cancel()
1149		request = request.WithContext(ctx)
1150	}
1151
1152	response, err := client.Do(request)
1153	if err != nil {
1154		err := fmt.Errorf("Performing request %v", err)
1155		return responseTime, err
1156	}
1157	defer response.Body.Close()
1158
1159	if response.StatusCode != http.StatusNoContent {
1160		content, _ := ioutil.ReadAll(response.Body)
1161		err := fmt.Errorf(
1162			"HTTP response %d, query was %s\n  data: %s\n  response: %s",
1163			response.StatusCode, request.URL.String(), data, content)
1164		return responseTime, err
1165	}
1166
1167	dateHeader, ok := response.Header["Date"]
1168	if !ok || len(dateHeader) != 1 {
1169		// No "Date" header, or empty, or multiple of them. Regardless, we don't
1170		// have a date we can return
1171		return responseTime, nil
1172	}
1173
1174	responseTime, err = http.ParseTime(dateHeader[0])
1175	if err != nil {
1176		return time.Time{}, err
1177	}
1178
1179	return responseTime, nil
1180}
1181
1182// AddComment adds a new comment to an issue (and returns it).
1183func (client *Client) AddComment(issueKeyOrID, body string) (*Comment, error) {
1184	url := fmt.Sprintf(
1185		"%s/rest/api/2/issue/%s/comment", client.serverURL, issueKeyOrID)
1186
1187	params := CommentCreate{Body: body}
1188	data, err := json.Marshal(params)
1189	if err != nil {
1190		return nil, err
1191	}
1192
1193	request, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
1194	if err != nil {
1195		return nil, err
1196	}
1197
1198	if client.ctx != nil {
1199		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
1200		defer cancel()
1201		request = request.WithContext(ctx)
1202	}
1203
1204	response, err := client.Do(request)
1205	if err != nil {
1206		err := fmt.Errorf("Performing request %v", err)
1207		return nil, err
1208	}
1209	defer response.Body.Close()
1210
1211	if response.StatusCode != http.StatusCreated {
1212		content, _ := ioutil.ReadAll(response.Body)
1213		err := fmt.Errorf(
1214			"HTTP response %d, query was %s\n  data: %s\n  response: %s",
1215			response.StatusCode, request.URL.String(), data, content)
1216		return nil, err
1217	}
1218
1219	var result Comment
1220
1221	data, _ = ioutil.ReadAll(response.Body)
1222	err = json.Unmarshal(data, &result)
1223	if err != nil {
1224		err := fmt.Errorf("Decoding response %v", err)
1225		return nil, err
1226	}
1227
1228	return &result, nil
1229}
1230
1231// UpdateComment changes the text of a comment
1232func (client *Client) UpdateComment(issueKeyOrID, commentID, body string) (
1233	*Comment, error) {
1234	url := fmt.Sprintf(
1235		"%s/rest/api/2/issue/%s/comment/%s", client.serverURL, issueKeyOrID,
1236		commentID)
1237
1238	params := CommentCreate{Body: body}
1239	data, err := json.Marshal(params)
1240	if err != nil {
1241		return nil, err
1242	}
1243
1244	request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data))
1245	if err != nil {
1246		return nil, err
1247	}
1248
1249	if client.ctx != nil {
1250		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
1251		defer cancel()
1252		request = request.WithContext(ctx)
1253	}
1254
1255	response, err := client.Do(request)
1256	if err != nil {
1257		err := fmt.Errorf("Performing request %v", err)
1258		return nil, err
1259	}
1260	defer response.Body.Close()
1261
1262	if response.StatusCode != http.StatusOK {
1263		err := fmt.Errorf(
1264			"HTTP response %d, query was %s", response.StatusCode,
1265			request.URL.String())
1266		return nil, err
1267	}
1268
1269	var result Comment
1270
1271	data, _ = ioutil.ReadAll(response.Body)
1272	err = json.Unmarshal(data, &result)
1273	if err != nil {
1274		err := fmt.Errorf("Decoding response %v", err)
1275		return nil, err
1276	}
1277
1278	return &result, nil
1279}
1280
1281// UpdateLabels changes labels for an issue
1282func (client *Client) UpdateLabels(
1283	issueKeyOrID string, added, removed []bug.Label) (time.Time, error) {
1284	url := fmt.Sprintf(
1285		"%s/rest/api/2/issue/%s/", client.serverURL, issueKeyOrID)
1286	var responseTime time.Time
1287
1288	// NOTE(josh): Since updates are a list of heterogeneous objects let's just
1289	// manually build the JSON text
1290	var buffer bytes.Buffer
1291	fmt.Fprintf(&buffer, `{"update":{"labels":[`)
1292	first := true
1293	for _, label := range added {
1294		if !first {
1295			fmt.Fprintf(&buffer, ",")
1296		}
1297		fmt.Fprintf(&buffer, `{"add":"%s"}`, label)
1298		first = false
1299	}
1300	for _, label := range removed {
1301		if !first {
1302			fmt.Fprintf(&buffer, ",")
1303		}
1304		fmt.Fprintf(&buffer, `{"remove":"%s"}`, label)
1305		first = false
1306	}
1307	fmt.Fprintf(&buffer, "]}}")
1308
1309	data := buffer.Bytes()
1310	request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data))
1311	if err != nil {
1312		return responseTime, err
1313	}
1314
1315	if client.ctx != nil {
1316		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
1317		defer cancel()
1318		request = request.WithContext(ctx)
1319	}
1320
1321	response, err := client.Do(request)
1322	if err != nil {
1323		err := fmt.Errorf("Performing request %v", err)
1324		return responseTime, err
1325	}
1326	defer response.Body.Close()
1327
1328	if response.StatusCode != http.StatusNoContent {
1329		content, _ := ioutil.ReadAll(response.Body)
1330		err := fmt.Errorf(
1331			"HTTP response %d, query was %s\n  data: %s\n  response: %s",
1332			response.StatusCode, request.URL.String(), data, content)
1333		return responseTime, err
1334	}
1335
1336	dateHeader, ok := response.Header["Date"]
1337	if !ok || len(dateHeader) != 1 {
1338		// No "Date" header, or empty, or multiple of them. Regardless, we don't
1339		// have a date we can return
1340		return responseTime, nil
1341	}
1342
1343	responseTime, err = http.ParseTime(dateHeader[0])
1344	if err != nil {
1345		return time.Time{}, err
1346	}
1347
1348	return responseTime, nil
1349}
1350
1351// GetTransitions returns a list of available transitions for an issue
1352func (client *Client) GetTransitions(issueKeyOrID string) (
1353	*TransitionList, error) {
1354
1355	url := fmt.Sprintf(
1356		"%s/rest/api/2/issue/%s/transitions", client.serverURL, issueKeyOrID)
1357
1358	request, err := http.NewRequest("GET", url, nil)
1359	if err != nil {
1360		err := fmt.Errorf("Creating request %v", err)
1361		return nil, err
1362	}
1363
1364	if client.ctx != nil {
1365		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
1366		defer cancel()
1367		request = request.WithContext(ctx)
1368	}
1369
1370	response, err := client.Do(request)
1371	if err != nil {
1372		err := fmt.Errorf("Performing request %v", err)
1373		return nil, err
1374	}
1375	defer response.Body.Close()
1376
1377	if response.StatusCode != http.StatusOK {
1378		err := fmt.Errorf(
1379			"HTTP response %d, query was %s", response.StatusCode,
1380			request.URL.String())
1381		return nil, err
1382	}
1383
1384	var message TransitionList
1385
1386	data, _ := ioutil.ReadAll(response.Body)
1387	err = json.Unmarshal(data, &message)
1388	if err != nil {
1389		err := fmt.Errorf("Decoding response %v", err)
1390		return nil, err
1391	}
1392
1393	return &message, nil
1394}
1395
1396func getTransitionTo(
1397	tlist *TransitionList, desiredStateNameOrID string) *Transition {
1398	for _, transition := range tlist.Transitions {
1399		if transition.To.ID == desiredStateNameOrID {
1400			return &transition
1401		} else if transition.To.Name == desiredStateNameOrID {
1402			return &transition
1403		}
1404	}
1405	return nil
1406}
1407
1408// DoTransition changes the "status" of an issue
1409func (client *Client) DoTransition(
1410	issueKeyOrID string, transitionID string) (time.Time, error) {
1411	url := fmt.Sprintf(
1412		"%s/rest/api/2/issue/%s/transitions", client.serverURL, issueKeyOrID)
1413	var responseTime time.Time
1414
1415	// TODO(josh)[767ee72]: Figure out a good way to "configure" the
1416	// open/close state mapping. It would be *great* if we could actually
1417	// *compute* the necessary transitions and prompt for missing metatdata...
1418	// but that is complex
1419	var buffer bytes.Buffer
1420	fmt.Fprintf(&buffer,
1421		`{"transition":{"id":"%s"}, "resolution": {"name": "Done"}}`,
1422		transitionID)
1423	request, err := http.NewRequest("POST", url, bytes.NewBuffer(buffer.Bytes()))
1424	if err != nil {
1425		return responseTime, err
1426	}
1427
1428	if client.ctx != nil {
1429		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
1430		defer cancel()
1431		request = request.WithContext(ctx)
1432	}
1433
1434	response, err := client.Do(request)
1435	if err != nil {
1436		err := fmt.Errorf("Performing request %v", err)
1437		return responseTime, err
1438	}
1439	defer response.Body.Close()
1440
1441	if response.StatusCode != http.StatusNoContent {
1442		err := errors.Wrap(errTransitionNotAllowed, fmt.Sprintf(
1443			"HTTP response %d, query was %s", response.StatusCode,
1444			request.URL.String()))
1445		return responseTime, err
1446	}
1447
1448	dateHeader, ok := response.Header["Date"]
1449	if !ok || len(dateHeader) != 1 {
1450		// No "Date" header, or empty, or multiple of them. Regardless, we don't
1451		// have a date we can return
1452		return responseTime, nil
1453	}
1454
1455	responseTime, err = http.ParseTime(dateHeader[0])
1456	if err != nil {
1457		return time.Time{}, err
1458	}
1459
1460	return responseTime, nil
1461}
1462
1463// GetServerInfo Fetch server information from the /serverinfo endpoint
1464// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue
1465func (client *Client) GetServerInfo() (*ServerInfo, error) {
1466	url := fmt.Sprintf("%s/rest/api/2/serverinfo", client.serverURL)
1467
1468	request, err := http.NewRequest("GET", url, nil)
1469	if err != nil {
1470		err := fmt.Errorf("Creating request %v", err)
1471		return nil, err
1472	}
1473
1474	if client.ctx != nil {
1475		ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout)
1476		defer cancel()
1477		request = request.WithContext(ctx)
1478	}
1479
1480	response, err := client.Do(request)
1481	if err != nil {
1482		err := fmt.Errorf("Performing request %v", err)
1483		return nil, err
1484	}
1485	defer response.Body.Close()
1486
1487	if response.StatusCode != http.StatusOK {
1488		err := fmt.Errorf(
1489			"HTTP response %d, query was %s", response.StatusCode,
1490			request.URL.String())
1491		return nil, err
1492	}
1493
1494	var message ServerInfo
1495
1496	data, _ := ioutil.ReadAll(response.Body)
1497	err = json.Unmarshal(data, &message)
1498	if err != nil {
1499		err := fmt.Errorf("Decoding response %v", err)
1500		return nil, err
1501	}
1502
1503	return &message, nil
1504}
1505
1506// GetServerTime returns the current time on the server
1507func (client *Client) GetServerTime() (MyTime, error) {
1508	var result MyTime
1509	info, err := client.GetServerInfo()
1510	if err != nil {
1511		return result, err
1512	}
1513	return info.ServerTime, nil
1514}