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