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