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