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