package jira import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/http/cookiejar" "net/url" "strconv" "strings" "time" "github.com/pkg/errors" "github.com/git-bug/git-bug/entities/bug" ) var errDone = errors.New("Iteration Done") var errTransitionNotFound = errors.New("Transition not found") var errTransitionNotAllowed = errors.New("Transition not allowed") // ============================================================================= // Extended JSON // ============================================================================= const TimeFormat = "2006-01-02T15:04:05.999999999Z0700" // ParseTime parse an RFC3339 string with nanoseconds func ParseTime(timeStr string) (time.Time, error) { out, err := time.Parse(time.RFC3339Nano, timeStr) if err != nil { out, err = time.Parse(TimeFormat, timeStr) } return out, err } // Time is just a time.Time with a JSON serialization type Time struct { time.Time } // UnmarshalJSON parses an RFC3339 date string into a time object // borrowed from: https://stackoverflow.com/a/39180230/141023 func (t *Time) UnmarshalJSON(data []byte) (err error) { str := string(data) // Get rid of the quotes "" around the value. // A second option would be to include them in the date format string // instead, like so below: // time.Parse(`"`+time.RFC3339Nano+`"`, s) str = str[1 : len(str)-1] timeObj, err := ParseTime(str) t.Time = timeObj return } // ============================================================================= // JSON Objects // ============================================================================= // Session credential cookie name/value pair received after logging in and // required to be sent on all subsequent requests type Session struct { Name string `json:"name"` Value string `json:"value"` } // SessionResponse the JSON object returned from a /session query (login) type SessionResponse struct { Session Session `json:"session"` } // SessionQuery the JSON object that is POSTed to the /session endpoint // in order to login and get a session cookie type SessionQuery struct { Username string `json:"username"` Password string `json:"password"` } // User the JSON object representing a JIRA user // https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/user type User struct { DisplayName string `json:"displayName"` EmailAddress string `json:"emailAddress"` Key string `json:"key"` Name string `json:"name"` } // Comment the JSON object for a single comment item returned in a list of // comments // https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getComments type Comment struct { ID string `json:"id"` Body string `json:"body"` Author User `json:"author"` UpdateAuthor User `json:"updateAuthor"` Created Time `json:"created"` Updated Time `json:"updated"` } // CommentPage the JSON object holding a single page of comments returned // either by direct query or within an issue query // https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getComments type CommentPage struct { StartAt int `json:"startAt"` MaxResults int `json:"maxResults"` Total int `json:"total"` Comments []Comment `json:"comments"` } // NextStartAt return the index of the first item on the next page func (cp *CommentPage) NextStartAt() int { return cp.StartAt + len(cp.Comments) } // IsLastPage return true if there are no more items beyond this page func (cp *CommentPage) IsLastPage() bool { return cp.NextStartAt() >= cp.Total } // IssueFields the JSON object returned as the "fields" member of an issue. // There are a very large number of fields and many of them are custom. We // only grab a few that we need. // https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue type IssueFields struct { Creator User `json:"creator"` Created Time `json:"created"` Description string `json:"description"` Summary string `json:"summary"` Comments CommentPage `json:"comment"` Labels []string `json:"labels"` } // ChangeLogItem "field-change" data within a changelog entry. A single // changelog entry might effect multiple fields. For example, closing an issue // generally requires a change in "status" and "resolution" // https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue type ChangeLogItem struct { Field string `json:"field"` FieldType string `json:"fieldtype"` From string `json:"from"` FromString string `json:"fromString"` To string `json:"to"` ToString string `json:"toString"` } // ChangeLogEntry One entry in a changelog // https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue type ChangeLogEntry struct { ID string `json:"id"` Author User `json:"author"` Created Time `json:"created"` Items []ChangeLogItem `json:"items"` } // ChangeLogPage A collection of changes to issue metadata // https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue type ChangeLogPage struct { StartAt int `json:"startAt"` MaxResults int `json:"maxResults"` Total int `json:"total"` IsLast bool `json:"isLast"` // Cloud-only Entries []ChangeLogEntry `json:"histories"` Values []ChangeLogEntry `json:"values"` } // NextStartAt return the index of the first item on the next page func (clp *ChangeLogPage) NextStartAt() int { return clp.StartAt + len(clp.Entries) } // IsLastPage return true if there are no more items beyond this page func (clp *ChangeLogPage) IsLastPage() bool { // NOTE(josh): The "isLast" field is returned on JIRA cloud, but not on // JIRA server. If we can distinguish which one we are working with, we can // possibly rely on that instead. return clp.NextStartAt() >= clp.Total } // Issue Top-level object for an issue // https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue type Issue struct { ID string `json:"id"` Key string `json:"key"` Fields IssueFields `json:"fields"` ChangeLog ChangeLogPage `json:"changelog"` } // SearchResult The result type from querying the search endpoint // https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/search type SearchResult struct { StartAt int `json:"startAt"` MaxResults int `json:"maxResults"` Total int `json:"total"` Issues []Issue `json:"issues"` } // NextStartAt return the index of the first item on the next page func (sr *SearchResult) NextStartAt() int { return sr.StartAt + len(sr.Issues) } // IsLastPage return true if there are no more items beyond this page func (sr *SearchResult) IsLastPage() bool { return sr.NextStartAt() >= sr.Total } // SearchRequest the JSON object POSTed to the /search endpoint type SearchRequest struct { JQL string `json:"jql"` StartAt int `json:"startAt"` MaxResults int `json:"maxResults"` Fields []string `json:"fields"` } // Project the JSON object representing a project. Note that we don't use all // the fields so we have only implemented a couple. type Project struct { ID string `json:"id,omitempty"` Key string `json:"key,omitempty"` } // IssueType the JSON object representing an issue type (i.e. "bug", "task") // Note that we don't use all the fields so we have only implemented a couple. type IssueType struct { ID string `json:"id"` } // IssueCreateFields fields that are included in an IssueCreate request type IssueCreateFields struct { Project Project `json:"project"` Summary string `json:"summary"` Description string `json:"description"` IssueType IssueType `json:"issuetype"` } // IssueCreate the JSON object that is POSTed to the /issue endpoint to create // a new issue type IssueCreate struct { Fields IssueCreateFields `json:"fields"` } // IssueCreateResult the JSON object returned after issue creation. type IssueCreateResult struct { ID string `json:"id"` Key string `json:"key"` } // CommentCreate the JSOn object that is POSTed to the /comment endpoint to // create a new comment type CommentCreate struct { Body string `json:"body"` } // StatusCategory the JSON object representing a status category type StatusCategory struct { ID int `json:"id"` Key string `json:"key"` Self string `json:"self"` ColorName string `json:"colorName"` Name string `json:"name"` } // Status the JSON object representing a status (i.e. "Open", "Closed") type Status struct { ID string `json:"id"` Name string `json:"name"` Self string `json:"self"` Description string `json:"description"` StatusCategory StatusCategory `json:"statusCategory"` } // Transition the JSON object represenging a transition from one Status to // another Status in a JIRA workflow type Transition struct { ID string `json:"id"` Name string `json:"name"` To Status `json:"to"` } // TransitionList the JSON object returned from the /transitions endpoint type TransitionList struct { Transitions []Transition `json:"transitions"` } // ServerInfo general server information returned by the /serverInfo endpoint. // Notably `ServerTime` will tell you the time on the server. type ServerInfo struct { BaseURL string `json:"baseUrl"` Version string `json:"version"` VersionNumbers []int `json:"versionNumbers"` BuildNumber int `json:"buildNumber"` BuildDate Time `json:"buildDate"` ServerTime Time `json:"serverTime"` ScmInfo string `json:"scmInfo"` BuildPartnerName string `json:"buildPartnerName"` ServerTitle string `json:"serverTitle"` } // ============================================================================= // REST Client // ============================================================================= // ClientTransport wraps http.RoundTripper by adding a // "Content-Type=application/json" header type ClientTransport struct { underlyingTransport http.RoundTripper basicAuthString string } // RoundTrip overrides the default by adding the content-type header func (ct *ClientTransport) RoundTrip(req *http.Request) (*http.Response, error) { req.Header.Add("Content-Type", "application/json") if ct.basicAuthString != "" { req.Header.Add("Authorization", fmt.Sprintf("Basic %s", ct.basicAuthString)) } return ct.underlyingTransport.RoundTrip(req) } func (ct *ClientTransport) SetCredentials(username string, token string) { credString := fmt.Sprintf("%s:%s", username, token) ct.basicAuthString = base64.StdEncoding.EncodeToString([]byte(credString)) } // Client Thin wrapper around the http.Client providing jira-specific methods // for API endpoints type Client struct { *http.Client serverURL string ctx context.Context } // NewClient Construct a new client connected to the provided server and // utilizing the given context for asynchronous events func NewClient(ctx context.Context, serverURL string) *Client { cookiJar, _ := cookiejar.New(nil) client := &http.Client{ Transport: &ClientTransport{underlyingTransport: http.DefaultTransport}, Jar: cookiJar, } return &Client{client, serverURL, ctx} } // Login POST credentials to the /session endpoint and get a session cookie func (client *Client) Login(credType, login, password string) error { switch credType { case "SESSION": return client.RefreshSessionToken(login, password) case "TOKEN": return client.SetTokenCredentials(login, password) default: panic("unknown Jira cred type") } } // RefreshSessionToken formulate the JSON request object from the user // credentials and POST it to the /session endpoint and get a session cookie func (client *Client) RefreshSessionToken(username, password string) error { params := SessionQuery{ Username: username, Password: password, } data, err := json.Marshal(params) if err != nil { return err } return client.RefreshSessionTokenRaw(data) } // SetTokenCredentials POST credentials to the /session endpoint and get a // session cookie func (client *Client) SetTokenCredentials(username, password string) error { switch transport := client.Transport.(type) { case *ClientTransport: transport.SetCredentials(username, password) default: return fmt.Errorf("Invalid transport type") } return nil } // RefreshSessionTokenRaw POST credentials to the /session endpoint and get a // session cookie func (client *Client) RefreshSessionTokenRaw(credentialsJSON []byte) error { postURL := fmt.Sprintf("%s/rest/auth/1/session", client.serverURL) req, err := http.NewRequest("POST", postURL, bytes.NewBuffer(credentialsJSON)) if err != nil { return err } urlobj, err := url.Parse(client.serverURL) if err != nil { fmt.Printf("Failed to parse %s\n", client.serverURL) } else { // Clear out cookies client.Jar.SetCookies(urlobj, []*http.Cookie{}) } if client.ctx != nil { ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() req = req.WithContext(ctx) } response, err := client.Do(req) if err != nil { return err } defer response.Body.Close() if response.StatusCode != http.StatusOK { content, _ := io.ReadAll(response.Body) return fmt.Errorf( "error creating token %v: %s", response.StatusCode, content) } data, _ := io.ReadAll(response.Body) var aux SessionResponse err = json.Unmarshal(data, &aux) if err != nil { return err } var cookies []*http.Cookie cookie := &http.Cookie{ Name: aux.Session.Name, Value: aux.Session.Value, } cookies = append(cookies, cookie) client.Jar.SetCookies(urlobj, cookies) return nil } // ============================================================================= // Endpoint Wrappers // ============================================================================= // Search Perform an issue a JQL search on the /search endpoint // https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/search func (client *Client) Search(jql string, maxResults int, startAt int) (*SearchResult, error) { url := fmt.Sprintf("%s/rest/api/2/search", client.serverURL) requestBody, err := json.Marshal(SearchRequest{ JQL: jql, StartAt: startAt, MaxResults: maxResults, Fields: []string{ "comment", "created", "creator", "description", "labels", "status", "summary"}}) if err != nil { return nil, err } request, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody)) if err != nil { return nil, err } if client.ctx != nil { ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } response, err := client.Do(request) if err != nil { return nil, err } defer response.Body.Close() if response.StatusCode != http.StatusOK { err := fmt.Errorf( "HTTP response %d, query was %s, %s", response.StatusCode, url, requestBody) return nil, err } var message SearchResult data, _ := io.ReadAll(response.Body) err = json.Unmarshal(data, &message) if err != nil { err := fmt.Errorf("Decoding response %v", err) return nil, err } return &message, nil } // SearchIterator cursor within paginated results from the /search endpoint type SearchIterator struct { client *Client jql string searchResult *SearchResult Err error pageSize int itemIdx int } // HasError returns true if the iterator is holding an error func (si *SearchIterator) HasError() bool { if si.Err == errDone { return false } if si.Err == nil { return false } return true } // HasNext returns true if there is another item available in the result set func (si *SearchIterator) HasNext() bool { return si.Err == nil && si.itemIdx < len(si.searchResult.Issues) } // Next Return the next item in the result set and advance the iterator. // Advancing the iterator may require fetching a new page. func (si *SearchIterator) Next() *Issue { if si.Err != nil { return nil } issue := si.searchResult.Issues[si.itemIdx] if si.itemIdx+1 < len(si.searchResult.Issues) { // We still have an item left in the currently cached page si.itemIdx++ } else { if si.searchResult.IsLastPage() { si.Err = errDone } else { // There are still more pages to fetch, so fetch the next page and // cache it si.searchResult, si.Err = si.client.Search( si.jql, si.pageSize, si.searchResult.NextStartAt()) // NOTE(josh): we don't deal with the error now, we just cache it. // HasNext() will return false and the caller can check the error // afterward. si.itemIdx = 0 } } return &issue } // IterSearch return an iterator over paginated results for a JQL search func (client *Client) IterSearch(jql string, pageSize int) *SearchIterator { result, err := client.Search(jql, pageSize, 0) iter := &SearchIterator{ client: client, jql: jql, searchResult: result, Err: err, pageSize: pageSize, itemIdx: 0, } return iter } // GetIssue fetches an issue object via the /issue/{IssueIdOrKey} endpoint // https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue func (client *Client) GetIssue(idOrKey string, fields []string, expand []string, properties []string) (*Issue, error) { url := fmt.Sprintf("%s/rest/api/2/issue/%s", client.serverURL, idOrKey) request, err := http.NewRequest("GET", url, nil) if err != nil { err := fmt.Errorf("Creating request %v", err) return nil, err } query := request.URL.Query() if len(fields) > 0 { query.Add("fields", strings.Join(fields, ",")) } if len(expand) > 0 { query.Add("expand", strings.Join(expand, ",")) } if len(properties) > 0 { query.Add("properties", strings.Join(properties, ",")) } request.URL.RawQuery = query.Encode() if client.ctx != nil { ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } response, err := client.Do(request) if err != nil { err := fmt.Errorf("Performing request %v", err) return nil, err } defer response.Body.Close() if response.StatusCode != http.StatusOK { err := fmt.Errorf( "HTTP response %d, query was %s", response.StatusCode, request.URL.String()) return nil, err } var issue Issue data, _ := io.ReadAll(response.Body) err = json.Unmarshal(data, &issue) if err != nil { err := fmt.Errorf("Decoding response %v", err) return nil, err } return &issue, nil } // GetComments returns a page of comments via the issue/{IssueIdOrKey}/comment // endpoint // https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getComment func (client *Client) GetComments(idOrKey string, maxResults int, startAt int) (*CommentPage, error) { url := fmt.Sprintf( "%s/rest/api/2/issue/%s/comment", client.serverURL, idOrKey) request, err := http.NewRequest("GET", url, nil) if err != nil { err := fmt.Errorf("Creating request %v", err) return nil, err } query := request.URL.Query() if maxResults > 0 { query.Add("maxResults", fmt.Sprintf("%d", maxResults)) } if startAt > 0 { query.Add("startAt", fmt.Sprintf("%d", startAt)) } request.URL.RawQuery = query.Encode() if client.ctx != nil { ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } response, err := client.Do(request) if err != nil { err := fmt.Errorf("Performing request %v", err) return nil, err } defer response.Body.Close() if response.StatusCode != http.StatusOK { err := fmt.Errorf( "HTTP response %d, query was %s", response.StatusCode, request.URL.String()) return nil, err } var comments CommentPage data, _ := io.ReadAll(response.Body) err = json.Unmarshal(data, &comments) if err != nil { err := fmt.Errorf("Decoding response %v", err) return nil, err } return &comments, nil } // CommentIterator cursor within paginated results from the /comment endpoint type CommentIterator struct { client *Client idOrKey string message *CommentPage Err error pageSize int itemIdx int } // HasError returns true if the iterator is holding an error func (ci *CommentIterator) HasError() bool { if ci.Err == errDone { return false } if ci.Err == nil { return false } return true } // HasNext returns true if there is another item available in the result set func (ci *CommentIterator) HasNext() bool { return ci.Err == nil && ci.itemIdx < len(ci.message.Comments) } // Next Return the next item in the result set and advance the iterator. // Advancing the iterator may require fetching a new page. func (ci *CommentIterator) Next() *Comment { if ci.Err != nil { return nil } comment := ci.message.Comments[ci.itemIdx] if ci.itemIdx+1 < len(ci.message.Comments) { // We still have an item left in the currently cached page ci.itemIdx++ } else { if ci.message.IsLastPage() { ci.Err = errDone } else { // There are still more pages to fetch, so fetch the next page and // cache it ci.message, ci.Err = ci.client.GetComments( ci.idOrKey, ci.pageSize, ci.message.NextStartAt()) // NOTE(josh): we don't deal with the error now, we just cache it. // HasNext() will return false and the caller can check the error // afterward. ci.itemIdx = 0 } } return &comment } // IterComments returns an iterator over paginated comments within an issue func (client *Client) IterComments(idOrKey string, pageSize int) *CommentIterator { message, err := client.GetComments(idOrKey, pageSize, 0) iter := &CommentIterator{ client: client, idOrKey: idOrKey, message: message, Err: err, pageSize: pageSize, itemIdx: 0, } return iter } // GetChangeLog fetch one page of the changelog for an issue via the // /issue/{IssueIdOrKey}/changelog endpoint (for JIRA cloud) or // /issue/{IssueIdOrKey} with (fields=*none&expand=changelog) // (for JIRA server) // https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue func (client *Client) GetChangeLog(idOrKey string, maxResults int, startAt int) (*ChangeLogPage, error) { url := fmt.Sprintf( "%s/rest/api/2/issue/%s/changelog", client.serverURL, idOrKey) request, err := http.NewRequest("GET", url, nil) if err != nil { err := fmt.Errorf("Creating request %v", err) return nil, err } query := request.URL.Query() if maxResults > 0 { query.Add("maxResults", fmt.Sprintf("%d", maxResults)) } if startAt > 0 { query.Add("startAt", fmt.Sprintf("%d", startAt)) } request.URL.RawQuery = query.Encode() if client.ctx != nil { ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } response, err := client.Do(request) if err != nil { err := fmt.Errorf("Performing request %v", err) return nil, err } defer response.Body.Close() if response.StatusCode == http.StatusNotFound { // The issue/{IssueIdOrKey}/changelog endpoint is only available on JIRA cloud // products, not on JIRA server. In order to get the information we have to // query the issue and ask for a changelog expansion. Unfortunately this means // that the changelog is not paginated and we have to fetch the entire thing // at once. Hopefully things don't break for very long changelogs. issue, err := client.GetIssue( idOrKey, []string{"*none"}, []string{"changelog"}, []string{}) if err != nil { return nil, err } return &issue.ChangeLog, nil } if response.StatusCode != http.StatusOK { err := fmt.Errorf( "HTTP response %d, query was %s", response.StatusCode, request.URL.String()) return nil, err } var changelog ChangeLogPage data, _ := io.ReadAll(response.Body) err = json.Unmarshal(data, &changelog) if err != nil { err := fmt.Errorf("Decoding response %v", err) return nil, err } // JIRA cloud returns changelog entries in the "values" list, whereas JIRA // server returns them in the "histories" list when embedded in an issue // object. changelog.Entries = changelog.Values changelog.Values = nil return &changelog, nil } // ChangeLogIterator cursor within paginated results from the /search endpoint type ChangeLogIterator struct { client *Client idOrKey string message *ChangeLogPage Err error pageSize int itemIdx int } // HasError returns true if the iterator is holding an error func (cli *ChangeLogIterator) HasError() bool { if cli.Err == errDone { return false } if cli.Err == nil { return false } return true } // HasNext returns true if there is another item available in the result set func (cli *ChangeLogIterator) HasNext() bool { return cli.Err == nil && cli.itemIdx < len(cli.message.Entries) } // Next Return the next item in the result set and advance the iterator. // Advancing the iterator may require fetching a new page. func (cli *ChangeLogIterator) Next() *ChangeLogEntry { if cli.Err != nil { return nil } item := cli.message.Entries[cli.itemIdx] if cli.itemIdx+1 < len(cli.message.Entries) { // We still have an item left in the currently cached page cli.itemIdx++ } else { if cli.message.IsLastPage() { cli.Err = errDone } else { // There are still more pages to fetch, so fetch the next page and // cache it cli.message, cli.Err = cli.client.GetChangeLog( cli.idOrKey, cli.pageSize, cli.message.NextStartAt()) // NOTE(josh): we don't deal with the error now, we just cache it. // HasNext() will return false and the caller can check the error // afterward. cli.itemIdx = 0 } } return &item } // IterChangeLog returns an iterator over entries in the changelog for an issue func (client *Client) IterChangeLog(idOrKey string, pageSize int) *ChangeLogIterator { message, err := client.GetChangeLog(idOrKey, pageSize, 0) iter := &ChangeLogIterator{ client: client, idOrKey: idOrKey, message: message, Err: err, pageSize: pageSize, itemIdx: 0, } return iter } // GetProject returns the project JSON object given its id or key func (client *Client) GetProject(projectIDOrKey string) (*Project, error) { url := fmt.Sprintf( "%s/rest/api/2/project/%s", client.serverURL, projectIDOrKey) request, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } if client.ctx != nil { ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } response, err := client.Do(request) if err != nil { return nil, err } defer response.Body.Close() if response.StatusCode != http.StatusOK { err := fmt.Errorf( "HTTP response %d, query was %s", response.StatusCode, url) return nil, err } var project Project data, _ := io.ReadAll(response.Body) err = json.Unmarshal(data, &project) if err != nil { err := fmt.Errorf("Decoding response %v", err) return nil, err } return &project, nil } // CreateIssue creates a new JIRA issue and returns it func (client *Client) CreateIssue(projectIDOrKey, title, body string, extra map[string]interface{}) (*IssueCreateResult, error) { url := fmt.Sprintf("%s/rest/api/2/issue", client.serverURL) fields := make(map[string]interface{}) fields["summary"] = title fields["description"] = body for key, value := range extra { fields[key] = value } // If the project string is an integer than assume it is an ID. Otherwise it // is a key. _, err := strconv.Atoi(projectIDOrKey) if err == nil { fields["project"] = map[string]string{"id": projectIDOrKey} } else { fields["project"] = map[string]string{"key": projectIDOrKey} } message := make(map[string]interface{}) message["fields"] = fields data, err := json.Marshal(message) if err != nil { return nil, err } request, err := http.NewRequest("POST", url, bytes.NewBuffer(data)) if err != nil { return nil, err } if client.ctx != nil { ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } response, err := client.Do(request) if err != nil { err := fmt.Errorf("Performing request %v", err) return nil, err } defer response.Body.Close() if response.StatusCode != http.StatusCreated { content, _ := io.ReadAll(response.Body) err := fmt.Errorf( "HTTP response %d, query was %s\n data: %s\n response: %s", response.StatusCode, request.URL.String(), data, content) return nil, err } var result IssueCreateResult data, _ = io.ReadAll(response.Body) err = json.Unmarshal(data, &result) if err != nil { err := fmt.Errorf("Decoding response %v", err) return nil, err } return &result, nil } // UpdateIssueTitle changes the "summary" field of a JIRA issue func (client *Client) UpdateIssueTitle(issueKeyOrID, title string) (time.Time, error) { url := fmt.Sprintf( "%s/rest/api/2/issue/%s", client.serverURL, issueKeyOrID) var responseTime time.Time // NOTE(josh): Since updates are a list of heterogeneous objects let's just // manually build the JSON text data, err := json.Marshal(title) if err != nil { return responseTime, err } var buffer bytes.Buffer _, _ = fmt.Fprintf(&buffer, `{"update":{"summary":[`) _, _ = fmt.Fprintf(&buffer, `{"set":%s}`, data) _, _ = fmt.Fprintf(&buffer, `]}}`) data = buffer.Bytes() request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data)) if err != nil { return responseTime, err } response, err := client.Do(request) if err != nil { err := fmt.Errorf("Performing request %v", err) return responseTime, err } defer response.Body.Close() if response.StatusCode != http.StatusNoContent { content, _ := io.ReadAll(response.Body) err := fmt.Errorf( "HTTP response %d, query was %s\n data: %s\n response: %s", response.StatusCode, request.URL.String(), data, content) return responseTime, err } dateHeader, ok := response.Header["Date"] if !ok || len(dateHeader) != 1 { // No "Date" header, or empty, or multiple of them. Regardless, we don't // have a date we can return return responseTime, nil } responseTime, err = http.ParseTime(dateHeader[0]) if err != nil { return time.Time{}, err } return responseTime, nil } // UpdateIssueBody changes the "description" field of a JIRA issue func (client *Client) UpdateIssueBody(issueKeyOrID, body string) (time.Time, error) { url := fmt.Sprintf( "%s/rest/api/2/issue/%s", client.serverURL, issueKeyOrID) var responseTime time.Time // NOTE(josh): Since updates are a list of heterogeneous objects let's just // manually build the JSON text data, err := json.Marshal(body) if err != nil { return responseTime, err } var buffer bytes.Buffer _, _ = fmt.Fprintf(&buffer, `{"update":{"description":[`) _, _ = fmt.Fprintf(&buffer, `{"set":%s}`, data) _, _ = fmt.Fprintf(&buffer, `]}}`) data = buffer.Bytes() request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data)) if err != nil { return responseTime, err } if client.ctx != nil { ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } response, err := client.Do(request) if err != nil { err := fmt.Errorf("Performing request %v", err) return responseTime, err } defer response.Body.Close() if response.StatusCode != http.StatusNoContent { content, _ := io.ReadAll(response.Body) err := fmt.Errorf( "HTTP response %d, query was %s\n data: %s\n response: %s", response.StatusCode, request.URL.String(), data, content) return responseTime, err } dateHeader, ok := response.Header["Date"] if !ok || len(dateHeader) != 1 { // No "Date" header, or empty, or multiple of them. Regardless, we don't // have a date we can return return responseTime, nil } responseTime, err = http.ParseTime(dateHeader[0]) if err != nil { return time.Time{}, err } return responseTime, nil } // AddComment adds a new comment to an issue (and returns it). func (client *Client) AddComment(issueKeyOrID, body string) (*Comment, error) { url := fmt.Sprintf( "%s/rest/api/2/issue/%s/comment", client.serverURL, issueKeyOrID) params := CommentCreate{Body: body} data, err := json.Marshal(params) if err != nil { return nil, err } request, err := http.NewRequest("POST", url, bytes.NewBuffer(data)) if err != nil { return nil, err } if client.ctx != nil { ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } response, err := client.Do(request) if err != nil { err := fmt.Errorf("Performing request %v", err) return nil, err } defer response.Body.Close() if response.StatusCode != http.StatusCreated { content, _ := io.ReadAll(response.Body) err := fmt.Errorf( "HTTP response %d, query was %s\n data: %s\n response: %s", response.StatusCode, request.URL.String(), data, content) return nil, err } var result Comment data, _ = io.ReadAll(response.Body) err = json.Unmarshal(data, &result) if err != nil { err := fmt.Errorf("Decoding response %v", err) return nil, err } return &result, nil } // UpdateComment changes the text of a comment func (client *Client) UpdateComment(issueKeyOrID, commentID, body string) ( *Comment, error) { url := fmt.Sprintf( "%s/rest/api/2/issue/%s/comment/%s", client.serverURL, issueKeyOrID, commentID) params := CommentCreate{Body: body} data, err := json.Marshal(params) if err != nil { return nil, err } request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data)) if err != nil { return nil, err } if client.ctx != nil { ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } response, err := client.Do(request) if err != nil { err := fmt.Errorf("Performing request %v", err) return nil, err } defer response.Body.Close() if response.StatusCode != http.StatusOK { err := fmt.Errorf( "HTTP response %d, query was %s", response.StatusCode, request.URL.String()) return nil, err } var result Comment data, _ = io.ReadAll(response.Body) err = json.Unmarshal(data, &result) if err != nil { err := fmt.Errorf("Decoding response %v", err) return nil, err } return &result, nil } // UpdateLabels changes labels for an issue func (client *Client) UpdateLabels(issueKeyOrID string, added, removed []bug.Label) (time.Time, error) { url := fmt.Sprintf( "%s/rest/api/2/issue/%s/", client.serverURL, issueKeyOrID) var responseTime time.Time // NOTE(josh): Since updates are a list of heterogeneous objects let's just // manually build the JSON text var buffer bytes.Buffer _, _ = fmt.Fprintf(&buffer, `{"update":{"labels":[`) first := true for _, label := range added { if !first { _, _ = fmt.Fprintf(&buffer, ",") } _, _ = fmt.Fprintf(&buffer, `{"add":"%s"}`, label) first = false } for _, label := range removed { if !first { _, _ = fmt.Fprintf(&buffer, ",") } _, _ = fmt.Fprintf(&buffer, `{"remove":"%s"}`, label) first = false } _, _ = fmt.Fprintf(&buffer, "]}}") data := buffer.Bytes() request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data)) if err != nil { return responseTime, err } if client.ctx != nil { ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } response, err := client.Do(request) if err != nil { err := fmt.Errorf("Performing request %v", err) return responseTime, err } defer response.Body.Close() if response.StatusCode != http.StatusNoContent { content, _ := io.ReadAll(response.Body) err := fmt.Errorf( "HTTP response %d, query was %s\n data: %s\n response: %s", response.StatusCode, request.URL.String(), data, content) return responseTime, err } dateHeader, ok := response.Header["Date"] if !ok || len(dateHeader) != 1 { // No "Date" header, or empty, or multiple of them. Regardless, we don't // have a date we can return return responseTime, nil } responseTime, err = http.ParseTime(dateHeader[0]) if err != nil { return time.Time{}, err } return responseTime, nil } // GetTransitions returns a list of available transitions for an issue func (client *Client) GetTransitions(issueKeyOrID string) (*TransitionList, error) { url := fmt.Sprintf( "%s/rest/api/2/issue/%s/transitions", client.serverURL, issueKeyOrID) request, err := http.NewRequest("GET", url, nil) if err != nil { err := fmt.Errorf("Creating request %v", err) return nil, err } if client.ctx != nil { ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } response, err := client.Do(request) if err != nil { err := fmt.Errorf("Performing request %v", err) return nil, err } defer response.Body.Close() if response.StatusCode != http.StatusOK { err := fmt.Errorf( "HTTP response %d, query was %s", response.StatusCode, request.URL.String()) return nil, err } var message TransitionList data, _ := io.ReadAll(response.Body) err = json.Unmarshal(data, &message) if err != nil { err := fmt.Errorf("Decoding response %v", err) return nil, err } return &message, nil } func getTransitionTo(tlist *TransitionList, desiredStateNameOrID string) *Transition { for _, transition := range tlist.Transitions { if transition.To.ID == desiredStateNameOrID { return &transition } else if transition.To.Name == desiredStateNameOrID { return &transition } } return nil } // DoTransition changes the "status" of an issue func (client *Client) DoTransition(issueKeyOrID string, transitionID string) (time.Time, error) { url := fmt.Sprintf( "%s/rest/api/2/issue/%s/transitions", client.serverURL, issueKeyOrID) var responseTime time.Time // TODO(josh)[767ee72]: Figure out a good way to "configure" the // open/close state mapping. It would be *great* if we could actually // *compute* the necessary transitions and prompt for missing metadata... // but that is complex var buffer bytes.Buffer _, _ = fmt.Fprintf(&buffer, `{"transition":{"id":"%s"}, "resolution": {"name": "Done"}}`, transitionID) request, err := http.NewRequest("POST", url, bytes.NewBuffer(buffer.Bytes())) if err != nil { return responseTime, err } if client.ctx != nil { ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } response, err := client.Do(request) if err != nil { err := fmt.Errorf("Performing request %v", err) return responseTime, err } defer response.Body.Close() if response.StatusCode != http.StatusNoContent { err := errors.Wrap(errTransitionNotAllowed, fmt.Sprintf( "HTTP response %d, query was %s", response.StatusCode, request.URL.String())) return responseTime, err } dateHeader, ok := response.Header["Date"] if !ok || len(dateHeader) != 1 { // No "Date" header, or empty, or multiple of them. Regardless, we don't // have a date we can return return responseTime, nil } responseTime, err = http.ParseTime(dateHeader[0]) if err != nil { return time.Time{}, err } return responseTime, nil } // GetServerInfo Fetch server information from the /serverinfo endpoint // https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue func (client *Client) GetServerInfo() (*ServerInfo, error) { url := fmt.Sprintf("%s/rest/api/2/serverinfo", client.serverURL) request, err := http.NewRequest("GET", url, nil) if err != nil { err := fmt.Errorf("Creating request %v", err) return nil, err } if client.ctx != nil { ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) defer cancel() request = request.WithContext(ctx) } response, err := client.Do(request) if err != nil { err := fmt.Errorf("Performing request %v", err) return nil, err } defer response.Body.Close() if response.StatusCode != http.StatusOK { err := fmt.Errorf( "HTTP response %d, query was %s", response.StatusCode, request.URL.String()) return nil, err } var message ServerInfo data, _ := io.ReadAll(response.Body) err = json.Unmarshal(data, &message) if err != nil { err := fmt.Errorf("Decoding response %v", err) return nil, err } return &message, nil } // GetServerTime returns the current time on the server func (client *Client) GetServerTime() (Time, error) { var result Time info, err := client.GetServerInfo() if err != nil { return result, err } return info.ServerTime, nil }