client.go

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