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