diff --git a/authentication.go b/authentication.go new file mode 100644 index 0000000..e8ba0db --- /dev/null +++ b/authentication.go @@ -0,0 +1,66 @@ +package jira + +import ( + "fmt" +) + +// AuthenticationService handles authentication for the JIRA instance / API. +// +// JIRA API docs: https://docs.atlassian.com/jira/REST/latest/#authentication +type AuthenticationService struct { + client *Client +} + +// Session represents a Session JSON response by the JIRA API. +type Session struct { + Self string `json:"self,omitempty"` + Name string `json:"name,omitempty"` + Session struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"session,omitempty"` + LoginInfo struct { + FailedLoginCount int `json:"failedLoginCount"` + LoginCount int `json:"loginCount"` + LastFailedLoginTime string `json:"lastFailedLoginTime"` + PreviousLoginTime string `json:"previousLoginTime"` + } `json:"loginInfo"` +} + +// AcquireSessionCookie creates a new session for a user in JIRA. +// Once a session has been successfully created it can be used to access any of JIRA's remote APIs and also the web UI by passing the appropriate HTTP Cookie header. +// The header will by automatically applied to every API request. +// Note that it is generally preferrable to use HTTP BASIC authentication with the REST API. +// However, this resource may be used to mimic the behaviour of JIRA's log-in page (e.g. to display log-in errors to a user). +// +// JIRA API docs: https://docs.atlassian.com/jira/REST/latest/#d2e459 +func (s *AuthenticationService) AcquireSessionCookie(username, password string) (bool, error) { + apiEndpoint := "rest/auth/1/session" + body := struct { + Username string `json:"username"` + Password string `json:"password"` + }{ + username, + password, + } + + req, err := s.client.NewRequest("POST", apiEndpoint, body) + if err != nil { + return false, err + } + + session := new(Session) + resp, err := s.client.Do(req, session) + if resp.StatusCode != 200 || err != nil { + return false, fmt.Errorf("Auth at JIRA instance failed (HTTP(S) request). %s", err) + } + + s.client.session = session + + return true, nil +} + +// TODO Missing API Call GET (Returns information about the currently authenticated user's session) +// See https://docs.atlassian.com/jira/REST/latest/#d2e456 +// TODO Missing API Call DELETE (Logs the current user out of JIRA, destroying the existing session, if any.) +// https://docs.atlassian.com/jira/REST/latest/#d2e456 diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..5c5a316 --- /dev/null +++ b/errors.go @@ -0,0 +1,19 @@ +package jira + +import ( + "fmt" + "net/http" +) + +// ErrorResponse reports one or more errors caused by an API request. +type ErrorResponse struct { + Response *http.Response // HTTP response that caused this error + ErrorMessages []string `json:"errorMessages,omitempty"` + Errors map[string]string `json:"errors,omitempty"` +} + +func (r *ErrorResponse) Error() string { + return fmt.Sprintf("%v %v: %d %v %+v", + r.Response.Request.Method, r.Response.Request.URL, + r.Response.StatusCode, r.ErrorMessages, r.Errors) +} diff --git a/issue.go b/issue.go new file mode 100644 index 0000000..dad4a40 --- /dev/null +++ b/issue.go @@ -0,0 +1,250 @@ +package jira + +import ( + "fmt" + "net/http" +) + +const ( + // AssigneeAutomatic represents the value of the "Assignee: Automatic" of JIRA + AssigneeAutomatic = "-1" +) + +// IssueService handles Issues for the JIRA instance / API. +// +// JIRA API docs: https://docs.atlassian.com/jira/REST/latest/#d2e2279 +type IssueService struct { + client *Client +} + +// Issue represents a JIRA issue. +type Issue struct { + Expand string `json:"expand,omitempty"` + ID string `json:"id,omitempty"` + Self string `json:"self,omitempty"` + Key string `json:"key,omitempty"` + Fields *IssueFields `json:"fields,omitempty"` +} + +// IssueFields represents single fields of a JIRA issue. +// Every JIRA issue has several fields attached. +type IssueFields struct { + // TODO Missing fields + // * "timespent": null, + // * "fixVersions": [], + // * "aggregatetimespent": null, + // * "workratio": -1, + // * "lastViewed": null, + // * "labels": [], + // * "timeestimate": null, + // * "aggregatetimeoriginalestimate": null, + // * "timeoriginalestimate": null, + // * "timetracking": {}, + // * "attachment": [], + // * "aggregatetimeestimate": null, + // * "subtasks": [], + // * "environment": null, + // * "duedate": null, + IssueType IssueType `json:"issuetype"` + Project Project `json:"project"` + Resolution *Resolution `json:"resolution,omitempty"` + Priority *Priority `json:"priority,omitempty"` + Resolutiondate string `json:"resolutiondate,omitempty"` + Created string `json:"created,omitempty"` + Watches *Watches `json:"watches,omitempty"` + Assignee *Assignee `json:"assignee"` + Updated string `json:"updated,omitempty"` + Description string `json:"description"` + Summary string `json:"summary"` + Creator *Assignee `json:"Creator,omitempty"` + Reporter *Assignee `json:"reporter,omitempty"` + Components []*Component `json:"components,omitempty"` + Status *Status `json:"status,omitempty"` + Progress *Progress `json:"progress,omitempty"` + AggregateProgress *Progress `json:"aggregateprogress,omitempty"` + Worklog *Worklog `json:"worklog,omitempty"` + IssueLinks []*IssueLink `json:"issuelinks,omitempty"` + Comments *CommentList `json:"comment,omitempty"` +} + +// IssueType represents a type of a JIRA issue. +// Typical types are "Request", "Bug", "Story", ... +type IssueType struct { + Self string `json:"self,omitempty"` + ID string `json:"id,omitempty"` + Description string `json:"description,omitempty"` + IconURL string `json:"iconUrl,omitempty"` + Name string `json:"name,omitempty"` + Subtask bool `json:"subtask,omitempty"` +} + +// Project represents a JIRA Project. +type Project struct { + Self string `json:"self,omitempty"` + ID string `json:"id,omitempty"` + Key string `json:"key,omitempty"` + Name string `json:"name,omitempty"` + AvatarURLs map[string]string `json:"avatarUrls,omitempty"` +} + +// Resolution represents a resolution of a JIRA issue. +// Typical types are "Fixed", "Suspended", "Won't Fix", ... +type Resolution struct { + Self string `json:"self"` + ID string `json:"id"` + Description string `json:"description"` + Name string `json:"name"` +} + +// Priority represents a priority of a JIRA issue. +// Typical types are "Normal", "Moderate", "Urgent", ... +type Priority struct { + Self string `json:"self,omitempty"` + IconURL string `json:"iconUrl,omitempty"` + Name string `json:"name,omitempty"` + ID string `json:"id,omitempty"` +} + +// Watches represents a type of how many user are "observing" a JIRA issue to track the status / updates. +type Watches struct { + Self string `json:"self,omitempty"` + WatchCount int `json:"watchCount,omitempty"` + IsWatching bool `json:"isWatching,omitempty"` +} + +// Assignee represents a user who is this JIRA issue assigned to. +type Assignee struct { + Self string `json:"self,omitempty"` + Name string `json:"name,omitempty"` + EmailAddress string `json:"emailAddress,omitempty"` + AvatarURLs map[string]string `json:"avatarUrls,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Active bool `json:"active,omitempty"` +} + +// Component represents a "component" of a JIRA issue. +// Components can be user defined in every JIRA instance. +type Component struct { + Self string `json:"self,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + +// Status represents the current status of a JIRA issue. +// Typical status are "Open", "In Progress", "Closed", ... +// Status can be user defined in every JIRA instance. +type Status struct { + Self string `json:"self"` + Description string `json:"description"` + IconURL string `json:"iconUrl"` + Name string `json:"name"` + ID string `json:"id"` + StatusCategory StatusCategory `json:"statusCategory"` +} + +// StatusCategory represents the category a status belongs to. +// Those categories can be user defined in every JIRA instance. +type StatusCategory struct { + Self string `json:"self"` + ID int `json:"id"` + Name string `json:"name"` + Key string `json:"key"` + ColorName string `json:"colorName"` +} + +// Progress represents the progress of a JIRA issue. +type Progress struct { + Progress int `json:"progress"` + Total int `json:"total"` +} + +// Worklog represents the work log of a JIRA issue. +// JIRA Wiki: https://confluence.atlassian.com/jira/logging-work-on-an-issue-185729605.html +type Worklog struct { + // Missing fields + // * "worklogs": [] + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` +} + +// IssueLink represents a link between two issues in JIRA. +type IssueLink struct { + ID string `json:"id"` + Self string `json:"self"` + Type IssueLinkType `json:"type"` + OutwardIssue Issue `json:"outwardIssue"` + InwardIssue Issue `json:"inwardIssue"` +} + +// IssueLinkType represents a type of a link between to issues in JIRA. +// Typical issue link types are "Related to", "Duplicate", "Is blocked by", etc. +type IssueLinkType struct { + ID string `json:"id"` + Self string `json:"self"` + Name string `json:"name"` + Inward string `json:"inward"` + Outward string `json:"outward"` +} + +// Comment represents a comment by a person to an issue in JIRA. +type Comment struct { + Self string `json:"self"` + Name string `json:"name"` + Author Assignee `json:"author"` + Body string `json:"body"` + UpdateAuthor Assignee `json:"updateAuthor"` + Updated string `json:"updated"` + Created string `json:"created"` +} + +// CommentList represents a list of comments by various persons of an issue in JIRA. +type CommentList struct { + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` + Comments []Comment `json:"comments"` +} + +// Get returns a full representation of the issue for the given issue key. +// JIRA will attempt to identify the issue by the issueIdOrKey path parameter. +// This can be an issue id, or an issue key. +// If the issue cannot be found via an exact match, JIRA will also look for the issue in a case-insensitive way, or by looking to see if the issue was moved. +// +// JIRA API docs: https://docs.atlassian.com/jira/REST/latest/#d2e2609 +func (s *IssueService) Get(issueID string) (*Issue, *http.Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s", issueID) + req, err := s.client.NewRequest("GET", apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + issue := new(Issue) + resp, err := s.client.Do(req, issue) + if err != nil { + return nil, resp, err + } + + return issue, resp, nil +} + +// Create creates an issue or a sub-task from a JSON representation. +// Creating a sub-task is similar to creating a regular issue, with two important differences: +// The issueType field must correspond to a sub-task issue type and you must provide a parent field in the issue create request containing the id or key of the parent issue. +// +// JIRA API docs: https://docs.atlassian.com/jira/REST/latest/#d2e2280 +func (s *IssueService) Create(issue *Issue) (*Issue, *http.Response, error) { + apiEndpoint := "rest/api/2/issue/" + req, err := s.client.NewRequest("POST", apiEndpoint, issue) + if err != nil { + return nil, nil, err + } + + responseIssue := new(Issue) + resp, err := s.client.Do(req, responseIssue) + if err != nil { + return nil, resp, err + } + + return responseIssue, resp, nil +} diff --git a/jira.go b/jira.go new file mode 100644 index 0000000..5d7ddb8 --- /dev/null +++ b/jira.go @@ -0,0 +1,131 @@ +package jira + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" +) + +// A Client manages communication with the JIRA API. +type Client struct { + // HTTP client used to communicate with the API. + client *http.Client + + // Base URL for API requests. + baseURL *url.URL + + // Session storage if the user authentificate with a Session cookie + session *Session + + // Services used for talking to different parts of the JIRA API. + Authentication *AuthenticationService + Issue *IssueService +} + +// NewClient returns a new JIRA API client. +// If a nil httpClient is provided, http.DefaultClient will be used. +// To use API methods which require authentication you can follow the prefered solution and +// provide an http.Client that will perform the authentication for you with OAuth and HTTP Basic (such as that provided by the golang.org/x/oauth2 library). +// As an alternative you can use Session Cookie based authentication provided by this package as well. +// See https://docs.atlassian.com/jira/REST/latest/#authentication +// baseURL is the HTTP endpoint of your JIRA instance and should always be specified with a trailing slash. +func NewClient(httpClient *http.Client, baseURL string) (*Client, error) { + if httpClient == nil { + httpClient = http.DefaultClient + } + + parsedBaseURL, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + + c := &Client{ + client: httpClient, + baseURL: parsedBaseURL, + } + c.Authentication = &AuthenticationService{client: c} + c.Issue = &IssueService{client: c} + + return c, nil +} + +// NewRequest creates an API request. +// A relative URL can be provided in urlStr, in which case it is resolved relative to the baseURL of the Client. +// Relative URLs should always be specified without a preceding slash. +// If specified, the value pointed to by body is JSON encoded and included as the request body. +func (c *Client) NewRequest(method, urlStr string, body interface{}) (*http.Request, error) { + rel, err := url.Parse(urlStr) + if err != nil { + return nil, err + } + + u := c.baseURL.ResolveReference(rel) + + var buf io.ReadWriter + if body != nil { + buf = new(bytes.Buffer) + err := json.NewEncoder(buf).Encode(body) + if err != nil { + return nil, err + } + } + + req, err := http.NewRequest(method, u.String(), buf) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + + // Set session cookie if there is one + if c.session != nil { + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", c.session.Session.Name, c.session.Session.Value)) + } + + return req, nil +} + +// Do sends an API request and returns the API response. +// The API response is JSON decoded and stored in the value pointed to by v, or returned as an error if an API error has occurred. +func (c *Client) Do(req *http.Request, v interface{}) (*http.Response, error) { + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + err = CheckResponse(resp) + if err != nil { + // Even though there was an error, we still return the response + // in case the caller wants to inspect it further + return resp, err + } + + if v != nil { + err = json.NewDecoder(resp.Body).Decode(v) + } + + return resp, err +} + +// CheckResponse checks the API response for errors, and returns them if present. +// A response is considered an error if it has a status code outside the 200 range. +// API error responses are expected to have either no response body, or a JSON response body that maps to ErrorResponse. +// Any other response body will be silently ignored. +func CheckResponse(r *http.Response) error { + if c := r.StatusCode; 200 <= c && c <= 299 { + return nil + } + + errorResponse := &ErrorResponse{Response: r} + data, err := ioutil.ReadAll(r.Body) + if err == nil && data != nil { + json.Unmarshal(data, errorResponse) + } + return errorResponse +}