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