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