1
0
mirror of https://github.com/interviewstreet/go-jira.git synced 2025-02-01 13:07:50 +02:00

Merge branch 'master' into rbriski/jira_error

This commit is contained in:
rbriski 2017-11-03 15:24:23 -07:00 committed by GitHub
commit 7df17cc390
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 3101 additions and 305 deletions

View File

@ -6,10 +6,12 @@ go:
- 1.4
- 1.5
- 1.6
- 1.7
- 1.8
- 1.9
before_install:
- go get github.com/mattn/goveralls
- go get golang.org/x/tools/cmd/cover
- go get -t ./...
script:
- $HOME/gopath/bin/goveralls -service=travis-ci
- GOMAXPROCS=4 GORACE="halt_on_error=1" go test -race -v ./...

2
Makefile Normal file
View File

@ -0,0 +1,2 @@
test:
go test -v ./...

114
README.md
View File

@ -3,7 +3,6 @@
[![GoDoc](https://godoc.org/github.com/andygrunwald/go-jira?status.svg)](https://godoc.org/github.com/andygrunwald/go-jira)
[![Build Status](https://travis-ci.org/andygrunwald/go-jira.svg?branch=master)](https://travis-ci.org/andygrunwald/go-jira)
[![Go Report Card](https://goreportcard.com/badge/github.com/andygrunwald/go-jira)](https://goreportcard.com/report/github.com/andygrunwald/go-jira)
[![Coverage Status](https://coveralls.io/repos/github/andygrunwald/go-jira/badge.svg?branch=master)](https://coveralls.io/github/andygrunwald/go-jira?branch=master)
[Go](https://golang.org/) client library for [Atlassian JIRA](https://www.atlassian.com/software/jira).
@ -12,9 +11,9 @@
## Features
* Authentication (HTTP Basic, OAuth, Session Cookie)
* Create and receive issues
* Create and retrieve issues
* Create and retrieve issue transitions (status updates)
* Call every API endpoint of the JIRA, even it is not directly implemented in this library
* Call every API endpoint of the JIRA, even if it is not directly implemented in this library
This package is not JIRA API complete (yet), but you can call every API endpoint you want. See [Call a not implemented API endpoint](#call-a-not-implemented-api-endpoint) how to do this. For all possible API endpoints of JRIA have a look at [latest JIRA REST API documentation](https://docs.atlassian.com/jira/REST/latest/).
@ -28,6 +27,17 @@ It is go gettable
$ go get github.com/andygrunwald/go-jira
For stable versions you can use one of our tags with [gopkg.in](http://labix.org/gopkg.in). E.g.
```go
package main
import (
jira "gopkg.in/andygrunwald/go-jira.v1"
)
...
```
(optional) to run unit / example tests:
$ cd $GOPATH/src/github.com/andygrunwald/go-jira
@ -58,7 +68,7 @@ import (
func main() {
jiraClient, _ := jira.NewClient(nil, "https://issues.apache.org/jira/")
issue, _, _ := jiraClient.Issue.Get("MESOS-3325")
issue, _, _ := jiraClient.Issue.Get("MESOS-3325", nil)
fmt.Printf("%s: %+v\n", issue.Key, issue.Fields.Summary)
fmt.Printf("Type: %s\n", issue.Fields.Type.Name)
@ -70,10 +80,41 @@ func main() {
}
```
### Authenticate with session cookie
### Authenticate with jira
Some actions require an authenticated user.
Here is an example with a session cookie authentification.
#### Authenticate with basic auth
Here is an example with basic auth authentication.
```go
package main
import (
"fmt"
"github.com/andygrunwald/go-jira"
)
func main() {
jiraClient, err := jira.NewClient(nil, "https://your.jira-instance.com/")
if err != nil {
panic(err)
}
jiraClient.Authentication.SetBasicAuth("username", "password")
issue, _, err := jiraClient.Issue.Get("SYS-5156", nil)
if err != nil {
panic(err)
}
fmt.Printf("%s: %+v\n", issue.Key, issue.Fields.Summary)
}
```
#### Authenticate with session cookie
Here is an example with session cookie authentication.
```go
package main
@ -95,7 +136,64 @@ func main() {
panic(err)
}
issue, _, err := jiraClient.Issue.Get("SYS-5156")
issue, _, err := jiraClient.Issue.Get("SYS-5156", nil)
if err != nil {
panic(err)
}
fmt.Printf("%s: %+v\n", issue.Key, issue.Fields.Summary)
}
```
#### Authenticate with OAuth
If you want to connect via OAuth to your JIRA Cloud instance checkout the [example of using OAuth authentication with JIRA in Go](https://gist.github.com/Lupus/edafe9a7c5c6b13407293d795442fe67) by [@Lupus](https://github.com/Lupus).
For more details have a look at the [issue #56](https://github.com/andygrunwald/go-jira/issues/56).
### Create an issue
Example how to create an issue.
```go
package main
import (
"fmt"
"github.com/andygrunwald/go-jira"
)
func main() {
jiraClient, err := jira.NewClient(nil, "https://your.jira-instance.com/")
if err != nil {
panic(err)
}
res, err := jiraClient.Authentication.AcquireSessionCookie("username", "password")
if err != nil || res == false {
fmt.Printf("Result: %v\n", res)
panic(err)
}
i := jira.Issue{
Fields: &jira.IssueFields{
Assignee: &jira.User{
Name: "myuser",
},
Reporter: &jira.User{
Name: "youruser",
},
Description: "Test Issue",
Type: jira.IssueType{
Name: "Bug",
},
Project: jira.Project{
Key: "PROJ1",
},
Summary: "Just a demo issue",
},
}
issue, _, err := jiraClient.Issue.Create(&i)
if err != nil {
panic(err)
}
@ -169,4 +267,4 @@ If you are new to pull requests, checkout [Collaborating on projects using issue
## License
This project is released under the terms of the [MIT license](http://en.wikipedia.org/wiki/MIT_License).
This project is released under the terms of the [MIT license](http://en.wikipedia.org/wiki/MIT_License).

View File

@ -1,15 +1,33 @@
package jira
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
)
const (
// HTTP Basic Authentication
authTypeBasic = 1
// HTTP Session Authentication
authTypeSession = 2
)
// AuthenticationService handles authentication for the JIRA instance / API.
//
// JIRA API docs: https://docs.atlassian.com/jira/REST/latest/#authentication
type AuthenticationService struct {
client *Client
// Authentication type
authType int
// Basic auth username
username string
// Basic auth password
password string
}
// Session represents a Session JSON response by the JIRA API.
@ -54,9 +72,9 @@ func (s *AuthenticationService) AcquireSessionCookie(username, password string)
session := new(Session)
resp, err := s.client.Do(req, session)
if resp != nil {
session.Cookies = resp.Cookies()
}
if resp != nil {
session.Cookies = resp.Cookies()
}
if err != nil {
return false, fmt.Errorf("Auth at JIRA instance failed (HTTP(S) request). %s", err)
@ -66,19 +84,97 @@ func (s *AuthenticationService) AcquireSessionCookie(username, password string)
}
s.client.session = session
s.authType = authTypeSession
return true, nil
}
// Authenticated reports if the current Client has an authenticated session with JIRA
// SetBasicAuth sets username and password for the basic auth against the JIRA instance.
func (s *AuthenticationService) SetBasicAuth(username, password string) {
s.username = username
s.password = password
s.authType = authTypeBasic
}
// Authenticated reports if the current Client has authentication details for JIRA
func (s *AuthenticationService) Authenticated() bool {
if s != nil {
return s.client.session != nil
if s.authType == authTypeSession {
return s.client.session != nil
} else if s.authType == authTypeBasic {
return s.username != ""
}
}
return false
}
// TODO Missing API Call GET (Returns information about the currently authenticated user's session)
// See https://docs.atlassian.com/jira/REST/latest/#auth/1/session
// TODO Missing API Call DELETE (Logs the current user out of JIRA, destroying the existing session, if any.)
// See https://docs.atlassian.com/jira/REST/latest/#auth/1/session
// Logout logs out the current user that has been authenticated and the session in the client is destroyed.
//
// JIRA API docs: https://docs.atlassian.com/jira/REST/latest/#auth/1/session
func (s *AuthenticationService) Logout() error {
if s.authType != authTypeSession || s.client.session == nil {
return fmt.Errorf("No user is authenticated yet.")
}
apiEndpoint := "rest/auth/1/session"
req, err := s.client.NewRequest("DELETE", apiEndpoint, nil)
if err != nil {
return fmt.Errorf("Creating the request to log the user out failed : %s", err)
}
resp, err := s.client.Do(req, nil)
if err != nil {
return fmt.Errorf("Error sending the logout request: %s", err)
}
if resp.StatusCode != 204 {
return fmt.Errorf("The logout was unsuccessful with status %d", resp.StatusCode)
}
// If logout successful, delete session
s.client.session = nil
return nil
}
// GetCurrentUser gets the details of the current user.
//
// JIRA API docs: https://docs.atlassian.com/jira/REST/latest/#auth/1/session
func (s *AuthenticationService) GetCurrentUser() (*Session, error) {
if s == nil {
return nil, fmt.Errorf("AUthenticaiton Service is not instantiated")
}
if s.authType != authTypeSession || s.client.session == nil {
return nil, fmt.Errorf("No user is authenticated yet")
}
apiEndpoint := "rest/auth/1/session"
req, err := s.client.NewRequest("GET", apiEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("Could not create request for getting user info : %s", err)
}
resp, err := s.client.Do(req, nil)
if err != nil {
return nil, fmt.Errorf("Error sending request to get user info : %s", err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("Getting user info failed with status : %d", resp.StatusCode)
}
defer resp.Body.Close()
ret := new(Session)
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("Couldn't read body from the response : %s", err)
}
err = json.Unmarshal(data, &ret)
if err != nil {
return nil, fmt.Errorf("Could not unmarshall received user info : %s", err)
}
return ret, nil
}

View File

@ -5,6 +5,7 @@ import (
"fmt"
"io/ioutil"
"net/http"
"reflect"
"testing"
)
@ -73,6 +74,29 @@ func TestAuthenticationService_AcquireSessionCookie_Success(t *testing.T) {
if testClient.Authentication.Authenticated() != true {
t.Error("Expected true, but result was false")
}
if testClient.Authentication.authType != authTypeSession {
t.Errorf("Expected authType %d. Got %d", authTypeSession, testClient.Authentication.authType)
}
}
func TestAuthenticationService_SetBasicAuth(t *testing.T) {
setup()
defer teardown()
testClient.Authentication.SetBasicAuth("test-user", "test-password")
if testClient.Authentication.username != "test-user" {
t.Errorf("Expected username test-user. Got %s", testClient.Authentication.username)
}
if testClient.Authentication.password != "test-password" {
t.Errorf("Expected password test-password. Got %s", testClient.Authentication.password)
}
if testClient.Authentication.authType != authTypeBasic {
t.Errorf("Expected authType %d. Got %d", authTypeBasic, testClient.Authentication.authType)
}
}
func TestAuthenticationService_Authenticated(t *testing.T) {
@ -84,3 +108,214 @@ func TestAuthenticationService_Authenticated(t *testing.T) {
t.Error("Expected false, but result was true")
}
}
func TestAuthenticationService_Authenticated_WithBasicAuth(t *testing.T) {
setup()
defer teardown()
testClient.Authentication.SetBasicAuth("test-user", "test-password")
// Test before we've attempted to authenticate
if testClient.Authentication.Authenticated() != true {
t.Error("Expected true, but result was false")
}
}
func TestAuthenticationService_Authenticated_WithBasicAuthButNoUsername(t *testing.T) {
setup()
defer teardown()
testClient.Authentication.SetBasicAuth("", "test-password")
// Test before we've attempted to authenticate
if testClient.Authentication.Authenticated() != false {
t.Error("Expected false, but result was true")
}
}
func TestAithenticationService_GetUserInfo_AccessForbidden_Fail(t *testing.T) {
setup()
defer teardown()
testMux.HandleFunc("/rest/auth/1/session", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
testMethod(t, r, "POST")
testRequestURL(t, r, "/rest/auth/1/session")
b, err := ioutil.ReadAll(r.Body)
if err != nil {
t.Errorf("Error in read body: %s", err)
}
if bytes.Index(b, []byte(`"username":"foo"`)) < 0 {
t.Error("No username found")
}
if bytes.Index(b, []byte(`"password":"bar"`)) < 0 {
t.Error("No password found")
}
fmt.Fprint(w, `{"session":{"name":"JSESSIONID","value":"12345678901234567890"},"loginInfo":{"failedLoginCount":10,"loginCount":127,"lastFailedLoginTime":"2016-03-16T04:22:35.386+0000","previousLoginTime":"2016-03-16T04:22:35.386+0000"}}`)
}
if r.Method == "GET" {
testMethod(t, r, "GET")
testRequestURL(t, r, "/rest/auth/1/session")
w.WriteHeader(http.StatusForbidden)
}
})
testClient.Authentication.AcquireSessionCookie("foo", "bar")
_, err := testClient.Authentication.GetCurrentUser()
if err == nil {
t.Errorf("Non nil error expect, received nil")
}
}
func TestAuthenticationService_GetUserInfo_NonOkStatusCode_Fail(t *testing.T) {
setup()
defer teardown()
testMux.HandleFunc("/rest/auth/1/session", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
testMethod(t, r, "POST")
testRequestURL(t, r, "/rest/auth/1/session")
b, err := ioutil.ReadAll(r.Body)
if err != nil {
t.Errorf("Error in read body: %s", err)
}
if bytes.Index(b, []byte(`"username":"foo"`)) < 0 {
t.Error("No username found")
}
if bytes.Index(b, []byte(`"password":"bar"`)) < 0 {
t.Error("No password found")
}
fmt.Fprint(w, `{"session":{"name":"JSESSIONID","value":"12345678901234567890"},"loginInfo":{"failedLoginCount":10,"loginCount":127,"lastFailedLoginTime":"2016-03-16T04:22:35.386+0000","previousLoginTime":"2016-03-16T04:22:35.386+0000"}}`)
}
if r.Method == "GET" {
testMethod(t, r, "GET")
testRequestURL(t, r, "/rest/auth/1/session")
//any status but 200
w.WriteHeader(240)
}
})
testClient.Authentication.AcquireSessionCookie("foo", "bar")
_, err := testClient.Authentication.GetCurrentUser()
if err == nil {
t.Errorf("Non nil error expect, received nil")
}
}
func TestAuthenticationService_GetUserInfo_FailWithoutLogin(t *testing.T) {
// no setup() required here
testClient = new(Client)
_, err := testClient.Authentication.GetCurrentUser()
if err == nil {
t.Errorf("Expected error, but got %s", err)
}
}
func TestAuthenticationService_GetUserInfo_Success(t *testing.T) {
setup()
defer teardown()
testUserInfo := new(Session)
testUserInfo.Name = "foo"
testUserInfo.Self = "https://my.jira.com/rest/api/latest/user?username=foo"
testUserInfo.LoginInfo.FailedLoginCount = 12
testUserInfo.LoginInfo.LastFailedLoginTime = "2016-09-06T16:41:23.949+0200"
testUserInfo.LoginInfo.LoginCount = 357
testUserInfo.LoginInfo.PreviousLoginTime = "2016-09-07T11:36:23.476+0200"
testMux.HandleFunc("/rest/auth/1/session", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
testMethod(t, r, "POST")
testRequestURL(t, r, "/rest/auth/1/session")
b, err := ioutil.ReadAll(r.Body)
if err != nil {
t.Errorf("Error in read body: %s", err)
}
if bytes.Index(b, []byte(`"username":"foo"`)) < 0 {
t.Error("No username found")
}
if bytes.Index(b, []byte(`"password":"bar"`)) < 0 {
t.Error("No password found")
}
fmt.Fprint(w, `{"session":{"name":"JSESSIONID","value":"12345678901234567890"},"loginInfo":{"failedLoginCount":10,"loginCount":127,"lastFailedLoginTime":"2016-03-16T04:22:35.386+0000","previousLoginTime":"2016-03-16T04:22:35.386+0000"}}`)
}
if r.Method == "GET" {
testMethod(t, r, "GET")
testRequestURL(t, r, "/rest/auth/1/session")
fmt.Fprint(w, `{"self":"https://my.jira.com/rest/api/latest/user?username=foo","name":"foo","loginInfo":{"failedLoginCount":12,"loginCount":357,"lastFailedLoginTime":"2016-09-06T16:41:23.949+0200","previousLoginTime":"2016-09-07T11:36:23.476+0200"}}`)
}
})
testClient.Authentication.AcquireSessionCookie("foo", "bar")
userinfo, err := testClient.Authentication.GetCurrentUser()
if err != nil {
t.Errorf("Nil error expect, received %s", err)
}
equal := reflect.DeepEqual(*testUserInfo, *userinfo)
if !equal {
t.Error("The user information doesn't match")
}
}
func TestAuthenticationService_Logout_Success(t *testing.T) {
setup()
defer teardown()
testMux.HandleFunc("/rest/auth/1/session", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
testMethod(t, r, "POST")
testRequestURL(t, r, "/rest/auth/1/session")
b, err := ioutil.ReadAll(r.Body)
if err != nil {
t.Errorf("Error in read body: %s", err)
}
if bytes.Index(b, []byte(`"username":"foo"`)) < 0 {
t.Error("No username found")
}
if bytes.Index(b, []byte(`"password":"bar"`)) < 0 {
t.Error("No password found")
}
fmt.Fprint(w, `{"session":{"name":"JSESSIONID","value":"12345678901234567890"},"loginInfo":{"failedLoginCount":10,"loginCount":127,"lastFailedLoginTime":"2016-03-16T04:22:35.386+0000","previousLoginTime":"2016-03-16T04:22:35.386+0000"}}`)
}
if r.Method == "DELETE" {
// return 204
w.WriteHeader(http.StatusNoContent)
}
})
testClient.Authentication.AcquireSessionCookie("foo", "bar")
err := testClient.Authentication.Logout()
if err != nil {
t.Errorf("Expected nil error, got %s", err)
}
}
func TestAuthenticationService_Logout_FailWithoutLogin(t *testing.T) {
setup()
defer teardown()
testMux.HandleFunc("/rest/auth/1/session", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" {
// 401
w.WriteHeader(http.StatusUnauthorized)
}
})
err := testClient.Authentication.Logout()
if err == nil {
t.Error("Expected not nil, got nil")
}
}

View File

@ -14,20 +14,20 @@ type BoardService struct {
// BoardsList reflects a list of agile boards
type BoardsList struct {
MaxResults int `json:"maxResults"`
StartAt int `json:"startAt"`
Total int `json:"total"`
IsLast bool `json:"isLast"`
Values []Board `json:"values"`
MaxResults int `json:"maxResults" structs:"maxResults"`
StartAt int `json:"startAt" structs:"startAt"`
Total int `json:"total" structs:"total"`
IsLast bool `json:"isLast" structs:"isLast"`
Values []Board `json:"values" structs:"values"`
}
// Board represents a JIRA agile board
type Board struct {
ID int `json:"id,omitempty"`
Self string `json:"self,omitempty"`
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"`
FilterID int `json:"filterId,omitempty"`
ID int `json:"id,omitempty" structs:"id,omitempty"`
Self string `json:"self,omitempty" structs:"self,omitempty"`
Name string `json:"name,omitempty" structs:"name,omitemtpy"`
Type string `json:"type,omitempty" structs:"type,omitempty"`
FilterID int `json:"filterId,omitempty" structs:"filterId,omitempty"`
}
// BoardListOptions specifies the optional parameters to the BoardService.GetList
@ -46,19 +46,19 @@ type BoardListOptions struct {
// Wrapper struct for search result
type sprintsResult struct {
Sprints []Sprint `json:"values"`
Sprints []Sprint `json:"values" structs:"values"`
}
// Sprint represents a sprint on JIRA agile board
type Sprint struct {
ID int `json:"id"`
Name string `json:"name"`
CompleteDate *time.Time `json:"completeDate"`
EndDate *time.Time `json:"endDate"`
StartDate *time.Time `json:"startDate"`
OriginBoardID int `json:"originBoardId"`
Self string `json:"self"`
State string `json:"state"`
ID int `json:"id" structs:"id"`
Name string `json:"name" structs:"name"`
CompleteDate *time.Time `json:"completeDate" structs:"completeDate"`
EndDate *time.Time `json:"endDate" structs:"endDate"`
StartDate *time.Time `json:"startDate" structs:"startDate"`
OriginBoardID int `json:"originBoardId" structs:"originBoardId"`
Self string `json:"self" structs:"self"`
State string `json:"state" structs:"state"`
}
// GetAllBoards will returns all boards. This only includes boards that the user has permission to view.

130
example_test.go Normal file
View File

@ -0,0 +1,130 @@
package jira_test
import (
"crypto/tls"
"fmt"
"net/http"
jira "github.com/andygrunwald/go-jira"
)
func ExampleNewClient() {
jiraClient, _ := jira.NewClient(nil, "https://issues.apache.org/jira/")
issue, _, _ := jiraClient.Issue.Get("MESOS-3325", nil)
fmt.Printf("%s: %+v\n", issue.Key, issue.Fields.Summary)
fmt.Printf("Type: %s\n", issue.Fields.Type.Name)
fmt.Printf("Priority: %s\n", issue.Fields.Priority.Name)
// Output:
// MESOS-3325: Running mesos-slave@0.23 in a container causes slave to be lost after a restart
// Type: Bug
// Priority: Critical
}
func ExampleNewClient_ignoreCertificateErrors() {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}
jiraClient, _ := jira.NewClient(client, "https://issues.apache.org/jira/")
issue, _, _ := jiraClient.Issue.Get("MESOS-3325", nil)
fmt.Printf("%s: %+v\n", issue.Key, issue.Fields.Summary)
fmt.Printf("Type: %s\n", issue.Fields.Type.Name)
fmt.Printf("Priority: %s\n", issue.Fields.Priority.Name)
// Output:
// MESOS-3325: Running mesos-slave@0.23 in a container causes slave to be lost after a restart
// Type: Bug
// Priority: Critical
}
func ExampleAuthenticationService_SetBasicAuth() {
jiraClient, err := jira.NewClient(nil, "https://your.jira-instance.com/")
if err != nil {
panic(err)
}
jiraClient.Authentication.SetBasicAuth("username", "password")
issue, _, err := jiraClient.Issue.Get("SYS-5156", nil)
if err != nil {
panic(err)
}
fmt.Printf("%s: %+v\n", issue.Key, issue.Fields.Summary)
}
func ExampleAuthenticationService_AcquireSessionCookie() {
jiraClient, err := jira.NewClient(nil, "https://your.jira-instance.com/")
if err != nil {
panic(err)
}
res, err := jiraClient.Authentication.AcquireSessionCookie("username", "password")
if err != nil || res == false {
fmt.Printf("Result: %v\n", res)
panic(err)
}
issue, _, err := jiraClient.Issue.Get("SYS-5156", nil)
if err != nil {
panic(err)
}
fmt.Printf("%s: %+v\n", issue.Key, issue.Fields.Summary)
}
func ExampleIssueService_Create() {
jiraClient, err := jira.NewClient(nil, "https://your.jira-instance.com/")
if err != nil {
panic(err)
}
res, err := jiraClient.Authentication.AcquireSessionCookie("username", "password")
if err != nil || res == false {
fmt.Printf("Result: %v\n", res)
panic(err)
}
i := jira.Issue{
Fields: &jira.IssueFields{
Assignee: &jira.User{
Name: "myuser",
},
Reporter: &jira.User{
Name: "youruser",
},
Description: "Test Issue",
Type: jira.IssueType{
Name: "Bug",
},
Project: jira.Project{
Key: "PROJ1",
},
Summary: "Just a demo issue",
},
}
issue, _, err := jiraClient.Issue.Create(&i)
if err != nil {
panic(err)
}
fmt.Printf("%s: %+v\n", issue.Key, issue.Fields.Summary)
}
func ExampleClient_Do() {
jiraClient, _ := jira.NewClient(nil, "https://jira.atlassian.com/")
req, _ := jiraClient.NewRequest("GET", "/rest/api/2/project", nil)
projects := new([]jira.Project)
_, err := jiraClient.Do(req, projects)
if err != nil {
panic(err)
}
for _, project := range *projects {
fmt.Printf("%s: %s\n", project.Key, project.Name)
}
}

53
group.go Normal file
View File

@ -0,0 +1,53 @@
package jira
import (
"fmt"
)
// GroupService handles Groups for the JIRA instance / API.
//
// JIRA API docs: https://docs.atlassian.com/jira/REST/server/#api/2/group
type GroupService struct {
client *Client
}
// groupMembersResult is only a small wrapper around the Group* methods
// to be able to parse the results
type groupMembersResult struct {
StartAt int `json:"startAt"`
MaxResults int `json:"maxResults"`
Total int `json:"total"`
Members []GroupMember `json:"values"`
}
// GroupMember reflects a single member of a group
type GroupMember struct {
Self string `json:"self,omitempty"`
Name string `json:"name,omitempty"`
Key string `json:"key,omitempty"`
EmailAddress string `json:"emailAddress,omitempty"`
DisplayName string `json:"displayName,omitempty"`
Active bool `json:"active,omitempty"`
TimeZone string `json:"timeZone,omitempty"`
}
// Get returns a paginated list of users who are members of the specified group and its subgroups.
// Users in the page are ordered by user names.
// User of this resource is required to have sysadmin or admin permissions.
//
// JIRA API docs: https://docs.atlassian.com/jira/REST/server/#api/2/group-getUsersFromGroup
func (s *GroupService) Get(name string) ([]GroupMember, *Response, error) {
apiEndpoint := fmt.Sprintf("rest/api/2/group/member?groupname=%s", name)
req, err := s.client.NewRequest("GET", apiEndpoint, nil)
if err != nil {
return nil, nil, err
}
group := new(groupMembersResult)
resp, err := s.client.Do(req, group)
if err != nil {
return nil, resp, err
}
return group.Members, resp, nil
}

732
issue.go
View File

@ -2,12 +2,19 @@ package jira
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"mime/multipart"
"net/url"
"reflect"
"strings"
"time"
"github.com/fatih/structs"
"github.com/google/go-querystring/query"
"github.com/trivago/tgo/tcontainer"
)
const (
@ -24,170 +31,255 @@ type IssueService struct {
// 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"`
Expand string `json:"expand,omitempty" structs:"expand,omitempty"`
ID string `json:"id,omitempty" structs:"id,omitempty"`
Self string `json:"self,omitempty" structs:"self,omitempty"`
Key string `json:"key,omitempty" structs:"key,omitempty"`
Fields *IssueFields `json:"fields,omitempty" structs:"fields,omitempty"`
Changelog *Changelog `json:"changelog,omitempty" structs:"changelog,omitempty"`
}
// ChangelogItems reflects one single changelog item of a history item
type ChangelogItems struct {
Field string `json:"field" structs:"field"`
FieldType string `json:"fieldtype" structs:"fieldtype"`
From interface{} `json:"from" structs:"from"`
FromString string `json:"fromString" structs:"fromString"`
To interface{} `json:"to" structs:"to"`
ToString string `json:"toString" structs:"toString"`
}
// ChangelogHistory reflects one single changelog history entry
type ChangelogHistory struct {
Id string `json:"id" structs:"id"`
Author User `json:"author" structs:"author"`
Created string `json:"created" structs:"created"`
Items []ChangelogItems `json:"items" structs:"items"`
}
// Changelog reflects the change log of an issue
type Changelog struct {
Histories []ChangelogHistory `json:"histories,omitempty"`
}
// Attachment represents a JIRA attachment
type Attachment struct {
Self string `json:"self,omitempty"`
ID string `json:"id,omitempty"`
Filename string `json:"filename,omitempty"`
Author *User `json:"author,omitempty"`
Created string `json:"created,omitempty"`
Size int `json:"size,omitempty"`
MimeType string `json:"mimeType,omitempty"`
Content string `json:"content,omitempty"`
Thumbnail string `json:"thumbnail,omitempty"`
Self string `json:"self,omitempty" structs:"self,omitempty"`
ID string `json:"id,omitempty" structs:"id,omitempty"`
Filename string `json:"filename,omitempty" structs:"filename,omitempty"`
Author *User `json:"author,omitempty" structs:"author,omitempty"`
Created string `json:"created,omitempty" structs:"created,omitempty"`
Size int `json:"size,omitempty" structs:"size,omitempty"`
MimeType string `json:"mimeType,omitempty" structs:"mimeType,omitempty"`
Content string `json:"content,omitempty" structs:"content,omitempty"`
Thumbnail string `json:"thumbnail,omitempty" structs:"thumbnail,omitempty"`
}
// Epic represents the epic to which an issue is associated
// Not that this struct does not process the returned "color" value
type Epic struct {
ID int `json:"id"`
Key string `json:"key"`
Self string `json:"self"`
Name string `json:"name"`
Summary string `json:"summary"`
Done bool `json:"done"`
ID int `json:"id" structs:"id"`
Key string `json:"key" structs:"key"`
Self string `json:"self" structs:"self"`
Name string `json:"name" structs:"name"`
Summary string `json:"summary" structs:"summary"`
Done bool `json:"done" structs:"done"`
}
// IssueFields represents single fields of a JIRA issue.
// Every JIRA issue has several fields attached.
type IssueFields struct {
// TODO Missing fields
// * "timespent": null,
// * "aggregatetimespent": null,
// * "workratio": -1,
// * "lastViewed": null,
// * "timeestimate": null,
// * "aggregatetimeoriginalestimate": null,
// * "timeoriginalestimate": null,
// * "timetracking": {},
// * "aggregatetimeestimate": null,
// * "environment": null,
// * "duedate": null,
Type IssueType `json:"issuetype"`
Project Project `json:"project,omitempty"`
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 *User `json:"assignee,omitempty"`
Updated string `json:"updated,omitempty"`
Description string `json:"description,omitempty"`
Summary string `json:"summary"`
Creator *User `json:"Creator,omitempty"`
Reporter *User `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 *Comments `json:"comment,omitempty"`
FixVersions []*FixVersion `json:"fixVersions,omitempty"`
Labels []string `json:"labels,omitempty"`
Subtasks []*Subtasks `json:"subtasks,omitempty"`
Attachments []*Attachment `json:"attachment,omitempty"`
Epic *Epic `json:"epic,omitempty"`
// * "aggregatetimespent": null,
// * "workratio": -1,
// * "lastViewed": null,
// * "aggregatetimeoriginalestimate": null,
// * "aggregatetimeestimate": null,
// * "environment": null,
Expand string `json:"expand,omitempty" structs:"expand,omitempty"`
Type IssueType `json:"issuetype" structs:"issuetype"`
Project Project `json:"project,omitempty" structs:"project,omitempty"`
Resolution *Resolution `json:"resolution,omitempty" structs:"resolution,omitempty"`
Priority *Priority `json:"priority,omitempty" structs:"priority,omitempty"`
Resolutiondate string `json:"resolutiondate,omitempty" structs:"resolutiondate,omitempty"`
Created string `json:"created,omitempty" structs:"created,omitempty"`
Duedate string `json:"duedate,omitempty" structs:"duedate,omitempty"`
Watches *Watches `json:"watches,omitempty" structs:"watches,omitempty"`
Assignee *User `json:"assignee,omitempty" structs:"assignee,omitempty"`
Updated string `json:"updated,omitempty" structs:"updated,omitempty"`
Description string `json:"description,omitempty" structs:"description,omitempty"`
Summary string `json:"summary" structs:"summary"`
Creator *User `json:"Creator,omitempty" structs:"Creator,omitempty"`
Reporter *User `json:"reporter,omitempty" structs:"reporter,omitempty"`
Components []*Component `json:"components,omitempty" structs:"components,omitempty"`
Status *Status `json:"status,omitempty" structs:"status,omitempty"`
Progress *Progress `json:"progress,omitempty" structs:"progress,omitempty"`
AggregateProgress *Progress `json:"aggregateprogress,omitempty" structs:"aggregateprogress,omitempty"`
TimeTracking *TimeTracking `json:"timetracking,omitempty" structs:"timetracking,omitempty"`
TimeSpent int `json:"timespent,omitempty" structs:"timespent,omitempty"`
TimeEstimate int `json:"timeestimate,omitempty" structs:"timeestimate,omitempty"`
TimeOriginalEstimate int `json:"timeoriginalestimate,omitempty" structs:"timeoriginalestimate,omitempty"`
Worklog *Worklog `json:"worklog,omitempty" structs:"worklog,omitempty"`
IssueLinks []*IssueLink `json:"issuelinks,omitempty" structs:"issuelinks,omitempty"`
Comments *Comments `json:"comment,omitempty" structs:"comment,omitempty"`
FixVersions []*FixVersion `json:"fixVersions,omitempty" structs:"fixVersions,omitempty"`
Labels []string `json:"labels,omitempty" structs:"labels,omitempty"`
Subtasks []*Subtasks `json:"subtasks,omitempty" structs:"subtasks,omitempty"`
Attachments []*Attachment `json:"attachment,omitempty" structs:"attachment,omitempty"`
Epic *Epic `json:"epic,omitempty" structs:"epic,omitempty"`
Parent *Parent `json:"parent,omitempty" structs:"parent,omitempty"`
Unknowns tcontainer.MarshalMap
}
// MarshalJSON is a custom JSON marshal function for the IssueFields structs.
// It handles JIRA custom fields and maps those from / to "Unknowns" key.
func (i *IssueFields) MarshalJSON() ([]byte, error) {
m := structs.Map(i)
unknowns, okay := m["Unknowns"]
if okay {
// if unknowns present, shift all key value from unknown to a level up
for key, value := range unknowns.(tcontainer.MarshalMap) {
m[key] = value
}
delete(m, "Unknowns")
}
return json.Marshal(m)
}
// UnmarshalJSON is a custom JSON marshal function for the IssueFields structs.
// It handles JIRA custom fields and maps those from / to "Unknowns" key.
func (i *IssueFields) UnmarshalJSON(data []byte) error {
// Do the normal unmarshalling first
// Details for this way: http://choly.ca/post/go-json-marshalling/
type Alias IssueFields
aux := &struct {
*Alias
}{
Alias: (*Alias)(i),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
totalMap := tcontainer.NewMarshalMap()
err := json.Unmarshal(data, &totalMap)
if err != nil {
return err
}
t := reflect.TypeOf(*i)
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
tagDetail := field.Tag.Get("json")
if tagDetail == "" {
// ignore if there are no tags
continue
}
options := strings.Split(tagDetail, ",")
if len(options) == 0 {
return fmt.Errorf("No tags options found for %s", field.Name)
}
// the first one is the json tag
key := options[0]
if _, okay := totalMap.Value(key); okay {
delete(totalMap, key)
}
}
i = (*IssueFields)(aux.Alias)
// all the tags found in the struct were removed. Whatever is left are unknowns to struct
i.Unknowns = totalMap
return nil
}
// 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"`
AvatarID int `json:"avatarId,omitempty"`
Self string `json:"self,omitempty" structs:"self,omitempty"`
ID string `json:"id,omitempty" structs:"id,omitempty"`
Description string `json:"description,omitempty" structs:"description,omitempty"`
IconURL string `json:"iconUrl,omitempty" structs:"iconUrl,omitempty"`
Name string `json:"name,omitempty" structs:"name,omitempty"`
Subtask bool `json:"subtask,omitempty" structs:"subtask,omitempty"`
AvatarID int `json:"avatarId,omitempty" structs:"avatarId,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"`
Self string `json:"self" structs:"self"`
ID string `json:"id" structs:"id"`
Description string `json:"description" structs:"description"`
Name string `json:"name" structs:"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"`
Self string `json:"self,omitempty" structs:"self,omitempty"`
IconURL string `json:"iconUrl,omitempty" structs:"iconUrl,omitempty"`
Name string `json:"name,omitempty" structs:"name,omitempty"`
ID string `json:"id,omitempty" structs:"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"`
}
// User represents a user who is this JIRA issue assigned to.
type User struct {
Self string `json:"self,omitempty"`
Name string `json:"name,omitempty"`
Key string `json:"key,omitempty"`
EmailAddress string `json:"emailAddress,omitempty"`
AvatarUrls AvatarUrls `json:"avatarUrls,omitempty"`
DisplayName string `json:"displayName,omitempty"`
Active bool `json:"active,omitempty"`
TimeZone string `json:"timeZone,omitempty"`
Self string `json:"self,omitempty" structs:"self,omitempty"`
WatchCount int `json:"watchCount,omitempty" structs:"watchCount,omitempty"`
IsWatching bool `json:"isWatching,omitempty" structs:"isWatching,omitempty"`
}
// AvatarUrls represents different dimensions of avatars / images
type AvatarUrls struct {
Four8X48 string `json:"48x48,omitempty"`
Two4X24 string `json:"24x24,omitempty"`
One6X16 string `json:"16x16,omitempty"`
Three2X32 string `json:"32x32,omitempty"`
Four8X48 string `json:"48x48,omitempty" structs:"48x48,omitempty"`
Two4X24 string `json:"24x24,omitempty" structs:"24x24,omitempty"`
One6X16 string `json:"16x16,omitempty" structs:"16x16,omitempty"`
Three2X32 string `json:"32x32,omitempty" structs:"32x32,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"`
Self string `json:"self,omitempty" structs:"self,omitempty"`
ID string `json:"id,omitempty" structs:"id,omitempty"`
Name string `json:"name,omitempty" structs:"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"`
Self string `json:"self" structs:"self"`
Description string `json:"description" structs:"description"`
IconURL string `json:"iconUrl" structs:"iconUrl"`
Name string `json:"name" structs:"name"`
ID string `json:"id" structs:"id"`
StatusCategory StatusCategory `json:"statusCategory" structs:"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"`
Self string `json:"self" structs:"self"`
ID int `json:"id" structs:"id"`
Name string `json:"name" structs:"name"`
Key string `json:"key" structs:"key"`
ColorName string `json:"colorName" structs:"colorName"`
}
// Progress represents the progress of a JIRA issue.
type Progress struct {
Progress int `json:"progress"`
Total int `json:"total"`
Progress int `json:"progress" structs:"progress"`
Total int `json:"total" structs:"total"`
}
// Parent represents the parent of a JIRA issue, to be used with subtask issue types.
type Parent struct {
ID string `json:"id,omitempty" structs:"id"`
Key string `json:"key,omitempty" structs:"key"`
}
// Time represents the Time definition of JIRA as a time.Time of go
@ -195,29 +287,35 @@ type Time time.Time
// Wrapper struct for search result
type transitionResult struct {
Transitions []Transition `json:"transitions"`
Transitions []Transition `json:"transitions" structs:"transitions"`
}
// Transition represents an issue transition in JIRA
type Transition struct {
ID string `json:"id"`
Name string `json:"name"`
Fields map[string]TransitionField `json:"fields"`
ID string `json:"id" structs:"id"`
Name string `json:"name" structs:"name"`
Fields map[string]TransitionField `json:"fields" structs:"fields"`
}
// TransitionField represents the value of one Transistion
// TransitionField represents the value of one Transition
type TransitionField struct {
Required bool `json:"required"`
Required bool `json:"required" structs:"required"`
}
// CreateTransitionPayload is used for creating new issue transitions
type CreateTransitionPayload struct {
Transition TransitionPayload `json:"transition"`
Transition TransitionPayload `json:"transition" structs:"transition"`
}
// TransitionPayload represents the request payload of Transistion calls like DoTransition
// TransitionPayload represents the request payload of Transition calls like DoTransition
type TransitionPayload struct {
ID string `json:"id"`
ID string `json:"id" structs:"id"`
}
// Option represents an option value in a SelectList or MultiSelect
// custom issue field
type Option struct {
Value string `json:"value" structs:"value"`
}
// UnmarshalJSON will transform the JIRA time into a time.Time
@ -235,90 +333,100 @@ func (t *Time) UnmarshalJSON(b []byte) error {
// One Worklog contains zero or n WorklogRecords
// JIRA Wiki: https://confluence.atlassian.com/jira/logging-work-on-an-issue-185729605.html
type Worklog struct {
StartAt int `json:"startAt"`
MaxResults int `json:"maxResults"`
Total int `json:"total"`
Worklogs []WorklogRecord `json:"worklogs"`
StartAt int `json:"startAt" structs:"startAt"`
MaxResults int `json:"maxResults" structs:"maxResults"`
Total int `json:"total" structs:"total"`
Worklogs []WorklogRecord `json:"worklogs" structs:"worklogs"`
}
// WorklogRecord represents one entry of a Worklog
type WorklogRecord struct {
Self string `json:"self"`
Author User `json:"author"`
UpdateAuthor User `json:"updateAuthor"`
Comment string `json:"comment"`
Created Time `json:"created"`
Updated Time `json:"updated"`
Started Time `json:"started"`
TimeSpent string `json:"timeSpent"`
TimeSpentSeconds int `json:"timeSpentSeconds"`
ID string `json:"id"`
IssueID string `json:"issueId"`
Self string `json:"self" structs:"self"`
Author User `json:"author" structs:"author"`
UpdateAuthor User `json:"updateAuthor" structs:"updateAuthor"`
Comment string `json:"comment" structs:"comment"`
Created Time `json:"created" structs:"created"`
Updated Time `json:"updated" structs:"updated"`
Started Time `json:"started" structs:"started"`
TimeSpent string `json:"timeSpent" structs:"timeSpent"`
TimeSpentSeconds int `json:"timeSpentSeconds" structs:"timeSpentSeconds"`
ID string `json:"id" structs:"id"`
IssueID string `json:"issueId" structs:"issueId"`
}
// TimeTracking represents the timetracking fields of a JIRA issue.
type TimeTracking struct {
OriginalEstimate string `json:"originalEstimate,omitempty" structs:"originalEstimate,omitempty"`
RemainingEstimate string `json:"remainingEstimate,omitempty" structs:"remainingEstimate,omitempty"`
TimeSpent string `json:"timeSpent,omitempty" structs:"timeSpent,omitempty"`
OriginalEstimateSeconds int `json:"originalEstimateSeconds,omitempty" structs:"originalEstimateSeconds,omitempty"`
RemainingEstimateSeconds int `json:"remainingEstimateSeconds,omitempty" structs:"remainingEstimateSeconds,omitempty"`
TimeSpentSeconds int `json:"timeSpentSeconds,omitempty" structs:"timeSpentSeconds,omitempty"`
}
// Subtasks represents all issues of a parent issue.
type Subtasks struct {
ID string `json:"id"`
Key string `json:"key"`
Self string `json:"self"`
Fields IssueFields `json:"fields"`
ID string `json:"id" structs:"id"`
Key string `json:"key" structs:"key"`
Self string `json:"self" structs:"self"`
Fields IssueFields `json:"fields" structs:"fields"`
}
// IssueLink represents a link between two issues in JIRA.
type IssueLink struct {
ID string `json:"id,omitempty"`
Self string `json:"self,omitempty"`
Type IssueLinkType `json:"type"`
OutwardIssue *Issue `json:"outwardIssue"`
InwardIssue *Issue `json:"inwardIssue"`
Comment *Comment `json:"comment,omitempty"`
ID string `json:"id,omitempty" structs:"id,omitempty"`
Self string `json:"self,omitempty" structs:"self,omitempty"`
Type IssueLinkType `json:"type" structs:"type"`
OutwardIssue *Issue `json:"outwardIssue" structs:"outwardIssue"`
InwardIssue *Issue `json:"inwardIssue" structs:"inwardIssue"`
Comment *Comment `json:"comment,omitempty" structs:"comment,omitempty"`
}
// 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,omitempty"`
Self string `json:"self,omitempty"`
Name string `json:"name"`
Inward string `json:"inward"`
Outward string `json:"outward"`
ID string `json:"id,omitempty" structs:"id,omitempty"`
Self string `json:"self,omitempty" structs:"self,omitempty"`
Name string `json:"name" structs:"name"`
Inward string `json:"inward" structs:"inward"`
Outward string `json:"outward" structs:"outward"`
}
// Comments represents a list of Comment.
type Comments struct {
Comments []*Comment `json:"comments,omitempty"`
Comments []*Comment `json:"comments,omitempty" structs:"comments,omitempty"`
}
// Comment represents a comment by a person to an issue in JIRA.
type Comment struct {
ID string `json:"id,omitempty"`
Self string `json:"self,omitempty"`
Name string `json:"name,omitempty"`
Author User `json:"author,omitempty"`
Body string `json:"body,omitempty"`
UpdateAuthor User `json:"updateAuthor,omitempty"`
Updated string `json:"updated,omitempty"`
Created string `json:"created,omitempty"`
Visibility CommentVisibility `json:"visibility,omitempty"`
ID string `json:"id,omitempty" structs:"id,omitempty"`
Self string `json:"self,omitempty" structs:"self,omitempty"`
Name string `json:"name,omitempty" structs:"name,omitempty"`
Author User `json:"author,omitempty" structs:"author,omitempty"`
Body string `json:"body,omitempty" structs:"body,omitempty"`
UpdateAuthor User `json:"updateAuthor,omitempty" structs:"updateAuthor,omitempty"`
Updated string `json:"updated,omitempty" structs:"updated,omitempty"`
Created string `json:"created,omitempty" structs:"created,omitempty"`
Visibility CommentVisibility `json:"visibility,omitempty" structs:"visibility,omitempty"`
}
// FixVersion represents a software release in which an issue is fixed.
type FixVersion struct {
Archived *bool `json:"archived,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
ProjectID int `json:"projectId,omitempty"`
ReleaseDate string `json:"releaseDate,omitempty"`
Released *bool `json:"released,omitempty"`
Self string `json:"self,omitempty"`
UserReleaseDate string `json:"userReleaseDate,omitempty"`
Archived *bool `json:"archived,omitempty" structs:"archived,omitempty"`
ID string `json:"id,omitempty" structs:"id,omitempty"`
Name string `json:"name,omitempty" structs:"name,omitempty"`
ProjectID int `json:"projectId,omitempty" structs:"projectId,omitempty"`
ReleaseDate string `json:"releaseDate,omitempty" structs:"releaseDate,omitempty"`
Released *bool `json:"released,omitempty" structs:"released,omitempty"`
Self string `json:"self,omitempty" structs:"self,omitempty"`
UserReleaseDate string `json:"userReleaseDate,omitempty" structs:"userReleaseDate,omitempty"`
}
// CommentVisibility represents he visibility of a comment.
// E.g. Type could be "role" and Value "Administrators"
type CommentVisibility struct {
Type string `json:"type,omitempty"`
Value string `json:"value,omitempty"`
Type string `json:"type,omitempty" structs:"type,omitempty"`
Value string `json:"value,omitempty" structs:"value,omitempty"`
}
// SearchOptions specifies the optional parameters to various List methods that
@ -332,15 +440,30 @@ type SearchOptions struct {
StartAt int `url:"startAt,omitempty"`
// MaxResults: The maximum number of projects to return per page. Default: 50.
MaxResults int `url:"maxResults,omitempty"`
// Expand: Expand specific sections in the returned issues
Expand string `url:"expand,omitempty"`
Fields []string
}
// searchResult is only a small wrapper arround the Search (with JQL) method
// searchResult is only a small wrapper around the Search (with JQL) method
// to be able to parse the results
type searchResult struct {
Issues []Issue `json:"issues"`
StartAt int `json:"startAt"`
MaxResults int `json:"maxResults"`
Total int `json:"total"`
Issues []Issue `json:"issues" structs:"issues"`
StartAt int `json:"startAt" structs:"startAt"`
MaxResults int `json:"maxResults" structs:"maxResults"`
Total int `json:"total" structs:"total"`
}
// GetQueryOptions specifies the optional parameters for the Get Issue methods
type GetQueryOptions struct {
// Fields is the list of fields to return for the issue. By default, all fields are returned.
Fields string `url:"fields,omitempty"`
Expand string `url:"expand,omitempty"`
// Properties is the list of properties to return for the issue. By default no properties are returned.
Properties string `url:"properties,omitempty"`
// FieldsByKeys if true then fields in issues will be referenced by keys instead of ids
FieldsByKeys bool `url:"fieldsByKeys,omitempty"`
UpdateHistory bool `url:"updateHistory,omitempty"`
}
// CustomFields represents custom fields of JIRA
@ -352,14 +475,24 @@ type CustomFields map[string]string
// 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.
//
// The given options will be appended to the query string
//
// JIRA API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issue-getIssue
func (s *IssueService) Get(issueID string) (*Issue, *Response, error) {
func (s *IssueService) Get(issueID string, options *GetQueryOptions) (*Issue, *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
}
if options != nil {
q, err := query.Values(options)
if err != nil {
return nil, nil, err
}
req.URL.RawQuery = q.Encode()
}
issue := new(Issue)
resp, err := s.client.Do(req, issue)
if err != nil {
@ -390,9 +523,9 @@ func (s *IssueService) DownloadAttachment(attachmentID string) (*Response, error
return resp, nil
}
// PostAttachment uploads r (io.Reader) as an attachment to a given attachmentID
func (s *IssueService) PostAttachment(attachmentID string, r io.Reader, attachmentName string) (*[]Attachment, *Response, error) {
apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/attachments", attachmentID)
// PostAttachment uploads r (io.Reader) as an attachment to a given issueID
func (s *IssueService) PostAttachment(issueID string, r io.Reader, attachmentName string) (*[]Attachment, *Response, error) {
apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/attachments", issueID)
b := new(bytes.Buffer)
writer := multipart.NewWriter(b)
@ -428,6 +561,23 @@ func (s *IssueService) PostAttachment(attachmentID string, r io.Reader, attachme
return attachment, resp, nil
}
// GetWorklogs gets all the worklogs for an issue.
// This method is especially important if you need to read all the worklogs, not just the first page.
//
// https://docs.atlassian.com/jira/REST/cloud/#api/2/issue/{issueIdOrKey}/worklog-getIssueWorklog
func (s *IssueService) GetWorklogs(issueID string) (*Worklog, *Response, error) {
apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/worklog", issueID)
req, err := s.client.NewRequest("GET", apiEndpoint, nil)
if err != nil {
return nil, nil, err
}
v := new(Worklog)
resp, err := s.client.Do(req, v)
return v, resp, err
}
// 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.
@ -439,15 +589,63 @@ func (s *IssueService) Create(issue *Issue) (*Issue, *Response, error) {
if err != nil {
return nil, nil, err
}
resp, err := s.client.Do(req, nil)
if err != nil {
// incase of error return the resp for further inspection
return nil, resp, err
}
responseIssue := new(Issue)
resp, err := s.client.Do(req, responseIssue)
defer resp.Body.Close()
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, resp, fmt.Errorf("Could not read the returned data")
}
err = json.Unmarshal(data, responseIssue)
if err != nil {
return nil, resp, fmt.Errorf("Could not unmarshall the data into struct")
}
return responseIssue, resp, nil
}
// Update updates an issue from a JSON representation. The issue is found by key.
//
// JIRA API docs: https://docs.atlassian.com/jira/REST/cloud/#api/2/issue-editIssue
func (s *IssueService) Update(issue *Issue) (*Issue, *Response, error) {
apiEndpoint := fmt.Sprintf("rest/api/2/issue/%v", issue.Key)
req, err := s.client.NewRequest("PUT", apiEndpoint, issue)
if err != nil {
return nil, nil, err
}
resp, err := s.client.Do(req, nil)
if err != nil {
jerr := NewJiraError(resp, err)
return nil, resp, jerr
}
return responseIssue, resp, nil
// This is just to follow the rest of the API's convention of returning an issue.
// Returning the same pointer here is pointless, so we return a copy instead.
ret := *issue
return &ret, resp, nil
}
// Update updates an issue from a JSON representation. The issue is found by key.
//
// https://docs.atlassian.com/jira/REST/7.4.0/#api/2/issue-editIssue
func (s *IssueService) UpdateIssue(jiraId string, data map[string]interface{}) (*Response, error) {
apiEndpoint := fmt.Sprintf("rest/api/2/issue/%v", jiraId)
req, err := s.client.NewRequest("PUT", apiEndpoint, data)
if err != nil {
return nil, err
}
resp, err := s.client.Do(req, nil)
if err != nil {
return resp, err
}
// This is just to follow the rest of the API's convention of returning an issue.
// Returning the same pointer here is pointless, so we return a copy instead.
return resp, nil
}
// AddComment adds a new comment to issueID.
@ -470,6 +668,30 @@ func (s *IssueService) AddComment(issueID string, comment *Comment) (*Comment, *
return responseComment, resp, nil
}
// UpdateComment updates the body of a comment, identified by comment.ID, on the issueID.
//
// JIRA API docs: https://docs.atlassian.com/jira/REST/cloud/#api/2/issue/{issueIdOrKey}/comment-updateComment
func (s *IssueService) UpdateComment(issueID string, comment *Comment) (*Comment, *Response, error) {
reqBody := struct {
Body string `json:"body"`
}{
Body: comment.Body,
}
apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/comment/%s", issueID, comment.ID)
req, err := s.client.NewRequest("POST", apiEndpoint, reqBody)
if err != nil {
return nil, nil, err
}
responseComment := new(Comment)
resp, err := s.client.Do(req, responseComment)
if err != nil {
return nil, resp, err
}
return responseComment, resp, nil
}
// AddLink adds a link between two issues.
//
// JIRA API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issueLink
@ -496,8 +718,8 @@ func (s *IssueService) Search(jql string, options *SearchOptions) ([]Issue, *Res
if options == nil {
u = fmt.Sprintf("rest/api/2/search?jql=%s", url.QueryEscape(jql))
} else {
u = fmt.Sprintf("rest/api/2/search?jql=%s&startAt=%d&maxResults=%d", url.QueryEscape(jql),
options.StartAt, options.MaxResults)
u = fmt.Sprintf("rest/api/2/search?jql=%s&startAt=%d&maxResults=%d&expand=%s&fields=%s", url.QueryEscape(jql),
options.StartAt, options.MaxResults, options.Expand, strings.Join(options.Fields, ","))
}
req, err := s.client.NewRequest("GET", u, nil)
@ -513,6 +735,46 @@ func (s *IssueService) Search(jql string, options *SearchOptions) ([]Issue, *Res
return v.Issues, resp, err
}
// SearchPages will get issues from all pages in a search
//
// JIRA API docs: https://developer.atlassian.com/jiradev/jira-apis/jira-rest-apis/jira-rest-api-tutorials/jira-rest-api-example-query-issues
func (s *IssueService) SearchPages(jql string, options *SearchOptions, f func(Issue) error) error {
if options == nil {
options = &SearchOptions{
StartAt: 0,
MaxResults: 50,
}
}
if options.MaxResults == 0 {
options.MaxResults = 50
}
issues, resp, err := s.Search(jql, options)
if err != nil {
return err
}
for {
for _, issue := range issues {
err = f(issue)
if err != nil {
return err
}
}
if resp.StartAt+resp.MaxResults >= resp.Total {
return nil
}
options.StartAt += resp.MaxResults
issues, resp, err = s.Search(jql, options)
if err != nil {
return err
}
}
}
// GetCustomFields returns a map of customfield_* keys with string values
func (s *IssueService) GetCustomFields(issueID string) (CustomFields, *Response, error) {
apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s", issueID)
@ -538,6 +800,11 @@ func (s *IssueService) GetCustomFields(issueID string) (CustomFields, *Response,
if rec, ok := f.(map[string]interface{}); ok {
for key, val := range rec {
if strings.Contains(key, "customfield") {
if valMap, ok := val.(map[string]interface{}); ok {
if v, ok := valMap["value"]; ok {
val = v
}
}
cf[key] = fmt.Sprint(val)
}
}
@ -569,13 +836,21 @@ func (s *IssueService) GetTransitions(id string) ([]Transition, *Response, error
//
// JIRA API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issue-doTransition
func (s *IssueService) DoTransition(ticketID, transitionID string) (*Response, error) {
apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/transitions", ticketID)
payload := CreateTransitionPayload{
Transition: TransitionPayload{
ID: transitionID,
},
}
return s.DoTransitionWithPayload(ticketID, payload)
}
// DoTransitionWithPayload performs a transition on an issue using any payload.
// When performing the transition you can update or set other issue fields.
//
// JIRA API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/issue-doTransition
func (s *IssueService) DoTransitionWithPayload(ticketID, payload interface{}) (*Response, error) {
apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/transitions", ticketID)
req, err := s.client.NewRequest("POST", apiEndpoint, payload)
if err != nil {
return nil, err
@ -588,3 +863,100 @@ func (s *IssueService) DoTransition(ticketID, transitionID string) (*Response, e
return resp, err
}
// InitIssueWithMetaAndFields returns Issue with with values from fieldsConfig properly set.
// * metaProject should contain metaInformation about the project where the issue should be created.
// * metaIssuetype is the MetaInformation about the Issuetype that needs to be created.
// * fieldsConfig is a key->value pair where key represents the name of the field as seen in the UI
// And value is the string value for that particular key.
// Note: This method doesn't verify that the fieldsConfig is complete with mandatory fields. The fieldsConfig is
// supposed to be already verified with MetaIssueType.CheckCompleteAndAvailable. It will however return
// error if the key is not found.
// All values will be packed into Unknowns. This is much convenient. If the struct fields needs to be
// configured as well, marshalling and unmarshalling will set the proper fields.
func InitIssueWithMetaAndFields(metaProject *MetaProject, metaIssuetype *MetaIssueType, fieldsConfig map[string]string) (*Issue, error) {
issue := new(Issue)
issueFields := new(IssueFields)
issueFields.Unknowns = tcontainer.NewMarshalMap()
// map the field names the User presented to jira's internal key
allFields, _ := metaIssuetype.GetAllFields()
for key, value := range fieldsConfig {
jiraKey, found := allFields[key]
if !found {
return nil, fmt.Errorf("Key %s is not found in the list of fields.", key)
}
valueType, err := metaIssuetype.Fields.String(jiraKey + "/schema/type")
if err != nil {
return nil, err
}
switch valueType {
case "array":
elemType, err := metaIssuetype.Fields.String(jiraKey + "/schema/items")
if err != nil {
return nil, err
}
switch elemType {
case "component":
issueFields.Unknowns[jiraKey] = []Component{{Name: value}}
case "option":
issueFields.Unknowns[jiraKey] = []map[string]string{{"value": value}}
default:
issueFields.Unknowns[jiraKey] = []string{value}
}
case "string":
issueFields.Unknowns[jiraKey] = value
case "date":
issueFields.Unknowns[jiraKey] = value
case "datetime":
issueFields.Unknowns[jiraKey] = value
case "any":
// Treat any as string
issueFields.Unknowns[jiraKey] = value
case "project":
issueFields.Unknowns[jiraKey] = Project{
Name: metaProject.Name,
ID: metaProject.Id,
}
case "priority":
issueFields.Unknowns[jiraKey] = Priority{Name: value}
case "user":
issueFields.Unknowns[jiraKey] = User{
Name: value,
}
case "issuetype":
issueFields.Unknowns[jiraKey] = IssueType{
Name: value,
}
case "option":
issueFields.Unknowns[jiraKey] = Option{
Value: value,
}
default:
return nil, fmt.Errorf("Unknown issue type encountered: %s for %s", valueType, key)
}
}
issue.Fields = issueFields
return issue, nil
}
// Delete will delete a specified issue.
func (s *IssueService) Delete(issueID string) (*Response, error) {
apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s", issueID)
// to enable deletion of subtasks; without this, the request will fail if the issue has subtasks
deletePayload := make(map[string]interface{})
deletePayload["deleteSubtasks"] = "true"
content, _ := json.Marshal(deletePayload)
req, err := s.client.NewRequest("DELETE", apiEndpoint, content)
if err != nil {
return nil, err
}
resp, err := s.client.Do(req, nil)
return resp, err
}

View File

@ -8,6 +8,8 @@ import (
"reflect"
"strings"
"testing"
"github.com/trivago/tgo/tcontainer"
)
func TestIssueService_Get_Success(t *testing.T) {
@ -20,7 +22,29 @@ func TestIssueService_Get_Success(t *testing.T) {
fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","fields":{"watcher":{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false}]},"attachment":[{"self":"http://www.example.com/jira/rest/api/2.0/attachments/10000","filename":"picture.jpg","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred","24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred","32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.461+0000","size":23123,"mimeType":"image/jpeg","content":"http://www.example.com/jira/attachments/10000","thumbnail":"http://www.example.com/jira/secure/thumbnail/10000"}],"sub-tasks":[{"id":"10000","type":{"id":"10000","name":"","inward":"Parent","outward":"Sub-task"},"outwardIssue":{"id":"10003","key":"EX-2","self":"http://www.example.com/jira/rest/api/2/issue/EX-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"description":"example bug report","project":{"self":"http://www.example.com/jira/rest/api/2/project/EX","id":"10000","key":"EX","name":"Example","avatarUrls":{"48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000","24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000","16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000","32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000"},"projectCategory":{"self":"http://www.example.com/jira/rest/api/2/projectCategory/10000","id":"10000","name":"FIRST","description":"First Project Category"}},"comment":{"comments":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10000","id":"10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}]},"issuelinks":[{"id":"10001","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"outwardIssue":{"id":"10004L","key":"PRJ-2","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}},{"id":"10002","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"inwardIssue":{"id":"10004","key":"PRJ-3","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"worklog":{"worklogs":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/worklog/10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2016-03-16T04:22:37.471+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2016-03-16T04:22:37.471+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}]},"updated":"2016-04-06T02:36:53.594-0700","timetracking":{"originalEstimate":"10m","remainingEstimate":"3m","timeSpent":"6m","originalEstimateSeconds":600,"remainingEstimateSeconds":200,"timeSpentSeconds":400}},"names":{"watcher":"watcher","attachment":"attachment","sub-tasks":"sub-tasks","description":"description","project":"project","comment":"comment","issuelinks":"issuelinks","worklog":"worklog","updated":"updated","timetracking":"timetracking"},"schema":{}}`)
})
issue, _, err := testClient.Issue.Get("10002")
issue, _, err := testClient.Issue.Get("10002", nil)
if issue == nil {
t.Error("Expected issue. Issue is nil")
}
if err != nil {
t.Errorf("Error given: %s", err)
}
}
func TestIssueService_Get_WithQuerySuccess(t *testing.T) {
setup()
defer teardown()
testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "GET")
testRequestURL(t, r, "/rest/api/2/issue/10002?expand=foo")
fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","fields":{"watcher":{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false}]},"attachment":[{"self":"http://www.example.com/jira/rest/api/2.0/attachments/10000","filename":"picture.jpg","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred","24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred","32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.461+0000","size":23123,"mimeType":"image/jpeg","content":"http://www.example.com/jira/attachments/10000","thumbnail":"http://www.example.com/jira/secure/thumbnail/10000"}],"sub-tasks":[{"id":"10000","type":{"id":"10000","name":"","inward":"Parent","outward":"Sub-task"},"outwardIssue":{"id":"10003","key":"EX-2","self":"http://www.example.com/jira/rest/api/2/issue/EX-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"description":"example bug report","project":{"self":"http://www.example.com/jira/rest/api/2/project/EX","id":"10000","key":"EX","name":"Example","avatarUrls":{"48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000","24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000","16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000","32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000"},"projectCategory":{"self":"http://www.example.com/jira/rest/api/2/projectCategory/10000","id":"10000","name":"FIRST","description":"First Project Category"}},"comment":{"comments":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10000","id":"10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}]},"issuelinks":[{"id":"10001","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"outwardIssue":{"id":"10004L","key":"PRJ-2","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}},{"id":"10002","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"inwardIssue":{"id":"10004","key":"PRJ-3","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"worklog":{"worklogs":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/worklog/10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2016-03-16T04:22:37.471+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2016-03-16T04:22:37.471+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}]},"updated":"2016-04-06T02:36:53.594-0700","timetracking":{"originalEstimate":"10m","remainingEstimate":"3m","timeSpent":"6m","originalEstimateSeconds":600,"remainingEstimateSeconds":200,"timeSpentSeconds":400}},"names":{"watcher":"watcher","attachment":"attachment","sub-tasks":"sub-tasks","description":"description","project":"project","comment":"comment","issuelinks":"issuelinks","worklog":"worklog","updated":"updated","timetracking":"timetracking"},"schema":{}}`)
})
opt := &GetQueryOptions{
Expand: "foo",
}
issue, _, err := testClient.Issue.Get("10002", opt)
if issue == nil {
t.Error("Expected issue. Issue is nil")
}
@ -54,6 +78,54 @@ func TestIssueService_Create(t *testing.T) {
}
}
func TestIssueService_Update(t *testing.T) {
setup()
defer teardown()
testMux.HandleFunc("/rest/api/2/issue/PROJ-9001", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "PUT")
testRequestURL(t, r, "/rest/api/2/issue/PROJ-9001")
w.WriteHeader(http.StatusNoContent)
})
i := &Issue{
Key: "PROJ-9001",
Fields: &IssueFields{
Description: "example bug report",
},
}
issue, _, err := testClient.Issue.Update(i)
if issue == nil {
t.Error("Expected issue. Issue is nil")
}
if err != nil {
t.Errorf("Error given: %s", err)
}
}
func TestIssueService_UpdateIssue(t *testing.T) {
setup()
defer teardown()
testMux.HandleFunc("/rest/api/2/issue/PROJ-9001", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "PUT")
testRequestURL(t, r, "/rest/api/2/issue/PROJ-9001")
w.WriteHeader(http.StatusNoContent)
})
jId := "PROJ-9001"
i := make(map[string]interface{})
fields := make(map[string]interface{})
i["fields"] = fields
resp, err := testClient.Issue.UpdateIssue(jId, i)
if resp == nil {
t.Error("Expected resp. resp is nil")
}
if err != nil {
t.Errorf("Error given: %s", err)
}
}
func TestIssueService_AddComment(t *testing.T) {
setup()
defer teardown()
@ -81,6 +153,34 @@ func TestIssueService_AddComment(t *testing.T) {
}
}
func TestIssueService_UpdateComment(t *testing.T) {
setup()
defer teardown()
testMux.HandleFunc("/rest/api/2/issue/10000/comment/10001", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "POST")
testRequestURL(t, r, "/rest/api/2/issue/10000/comment/10001")
w.WriteHeader(http.StatusCreated)
fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10001","id":"10001","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}`)
})
c := &Comment{
ID: "10001",
Body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.",
Visibility: CommentVisibility{
Type: "role",
Value: "Administrators",
},
}
comment, _, err := testClient.Issue.UpdateComment("10000", c)
if comment == nil {
t.Error("Expected Comment. Comment is nil")
}
if err != nil {
t.Errorf("Error given: %s", err)
}
}
func TestIssueService_AddLink(t *testing.T) {
setup()
defer teardown()
@ -131,7 +231,7 @@ func TestIssueService_Get_Fields(t *testing.T) {
fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","fields":{"labels":["test"],"watcher":{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false}]},"epic": {"id": 19415,"key": "EPIC-77","self": "https://example.atlassian.net/rest/agile/1.0/epic/19415","name": "Epic Name","summary": "Do it","color": {"key": "color_11"},"done": false},"attachment":[{"self":"http://www.example.com/jira/rest/api/2.0/attachments/10000","filename":"picture.jpg","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred","24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred","32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.461+0000","size":23123,"mimeType":"image/jpeg","content":"http://www.example.com/jira/attachments/10000","thumbnail":"http://www.example.com/jira/secure/thumbnail/10000"}],"sub-tasks":[{"id":"10000","type":{"id":"10000","name":"","inward":"Parent","outward":"Sub-task"},"outwardIssue":{"id":"10003","key":"EX-2","self":"http://www.example.com/jira/rest/api/2/issue/EX-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"description":"example bug report","project":{"self":"http://www.example.com/jira/rest/api/2/project/EX","id":"10000","key":"EX","name":"Example","avatarUrls":{"48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000","24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000","16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000","32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000"},"projectCategory":{"self":"http://www.example.com/jira/rest/api/2/projectCategory/10000","id":"10000","name":"FIRST","description":"First Project Category"}},"comment":{"comments":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10000","id":"10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}]},"issuelinks":[{"id":"10001","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"outwardIssue":{"id":"10004L","key":"PRJ-2","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}},{"id":"10002","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"inwardIssue":{"id":"10004","key":"PRJ-3","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"worklog":{"worklogs":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/worklog/10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2016-03-16T04:22:37.471+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2016-03-16T04:22:37.471+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}]},"updated":"2016-04-06T02:36:53.594-0700","timetracking":{"originalEstimate":"10m","remainingEstimate":"3m","timeSpent":"6m","originalEstimateSeconds":600,"remainingEstimateSeconds":200,"timeSpentSeconds":400}},"names":{"watcher":"watcher","attachment":"attachment","sub-tasks":"sub-tasks","description":"description","project":"project","comment":"comment","issuelinks":"issuelinks","worklog":"worklog","updated":"updated","timetracking":"timetracking"},"schema":{}}`)
})
issue, _, err := testClient.Issue.Get("10002")
issue, _, err := testClient.Issue.Get("10002", nil)
if issue == nil {
t.Error("Expected issue. Issue is nil")
}
@ -322,12 +422,12 @@ func TestIssueService_Search(t *testing.T) {
defer teardown()
testMux.HandleFunc("/rest/api/2/search", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "GET")
testRequestURL(t, r, "/rest/api/2/search?jql=something&startAt=1&maxResults=40")
testRequestURL(t, r, "/rest/api/2/search?jql=something&startAt=1&maxResults=40&expand=foo")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"expand": "schema,names","startAt": 1,"maxResults": 40,"total": 6,"issues": [{"expand": "html","id": "10230","self": "http://kelpie9:8081/rest/api/2/issue/BULK-62","key": "BULK-62","fields": {"summary": "testing","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/5","id": "5","description": "The sub-task of the issue","iconUrl": "http://kelpie9:8081/images/icons/issue_subtask.gif","name": "Sub-task","subtask": true},"customfield_10071": null}},{"expand": "html","id": "10004","self": "http://kelpie9:8081/rest/api/2/issue/BULK-47","key": "BULK-47","fields": {"summary": "Cheese v1 2.0 issue","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/3","id": "3","description": "A task that needs to be done.","iconUrl": "http://kelpie9:8081/images/icons/task.gif","name": "Task","subtask": false}}}]}`)
})
opt := &SearchOptions{StartAt: 1, MaxResults: 40}
opt := &SearchOptions{StartAt: 1, MaxResults: 40, Expand: "foo"}
_, resp, err := testClient.Issue.Search("something", opt)
if resp == nil {
@ -378,6 +478,44 @@ func TestIssueService_Search_WithoutPaging(t *testing.T) {
}
}
func TestIssueService_SearchPages(t *testing.T) {
setup()
defer teardown()
testMux.HandleFunc("/rest/api/2/search", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "GET")
if r.URL.String() == "/rest/api/2/search?jql=something&startAt=1&maxResults=2&expand=foo&fields=" {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"expand": "schema,names","startAt": 1,"maxResults": 2,"total": 6,"issues": [{"expand": "html","id": "10230","self": "http://kelpie9:8081/rest/api/2/issue/BULK-62","key": "BULK-62","fields": {"summary": "testing","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/5","id": "5","description": "The sub-task of the issue","iconUrl": "http://kelpie9:8081/images/icons/issue_subtask.gif","name": "Sub-task","subtask": true},"customfield_10071": null}},{"expand": "html","id": "10004","self": "http://kelpie9:8081/rest/api/2/issue/BULK-47","key": "BULK-47","fields": {"summary": "Cheese v1 2.0 issue","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/3","id": "3","description": "A task that needs to be done.","iconUrl": "http://kelpie9:8081/images/icons/task.gif","name": "Task","subtask": false}}}]}`)
return
} else if r.URL.String() == "/rest/api/2/search?jql=something&startAt=3&maxResults=2&expand=foo&fields=" {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"expand": "schema,names","startAt": 3,"maxResults": 2,"total": 6,"issues": [{"expand": "html","id": "10230","self": "http://kelpie9:8081/rest/api/2/issue/BULK-62","key": "BULK-62","fields": {"summary": "testing","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/5","id": "5","description": "The sub-task of the issue","iconUrl": "http://kelpie9:8081/images/icons/issue_subtask.gif","name": "Sub-task","subtask": true},"customfield_10071": null}},{"expand": "html","id": "10004","self": "http://kelpie9:8081/rest/api/2/issue/BULK-47","key": "BULK-47","fields": {"summary": "Cheese v1 2.0 issue","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/3","id": "3","description": "A task that needs to be done.","iconUrl": "http://kelpie9:8081/images/icons/task.gif","name": "Task","subtask": false}}}]}`)
return
} else if r.URL.String() == "/rest/api/2/search?jql=something&startAt=5&maxResults=2&expand=foo&fields=" {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"expand": "schema,names","startAt": 5,"maxResults": 2,"total": 6,"issues": [{"expand": "html","id": "10230","self": "http://kelpie9:8081/rest/api/2/issue/BULK-62","key": "BULK-62","fields": {"summary": "testing","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/5","id": "5","description": "The sub-task of the issue","iconUrl": "http://kelpie9:8081/images/icons/issue_subtask.gif","name": "Sub-task","subtask": true},"customfield_10071": null}}]}`)
return
}
t.Errorf("Unexpected URL: %v", r.URL)
})
opt := &SearchOptions{StartAt: 1, MaxResults: 2, Expand: "foo"}
issues := make([]Issue, 0)
err := testClient.Issue.SearchPages("something", opt, func(issue Issue) error {
issues = append(issues, issue)
return nil
})
if err != nil {
t.Errorf("Error given: %s", err)
}
if len(issues) != 5 {
t.Errorf("Expected 5 issues, %v given", len(issues))
}
}
func TestIssueService_GetCustomFields(t *testing.T) {
setup()
defer teardown()
@ -400,6 +538,28 @@ func TestIssueService_GetCustomFields(t *testing.T) {
}
}
func TestIssueService_GetComplexCustomFields(t *testing.T) {
setup()
defer teardown()
testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "GET")
testRequestURL(t, r, "/rest/api/2/issue/10002")
fmt.Fprint(w, `{"expand":"renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations","id":"10002","self":"http://www.example.com/jira/rest/api/2/issue/10002","key":"EX-1","fields":{"customfield_123":{"self":"http://www.example.com/jira/rest/api/2/customFieldOption/123","value":"test","id":"123"},"watcher":{"self":"http://www.example.com/jira/rest/api/2/issue/EX-1/watchers","isWatching":false,"watchCount":1,"watchers":[{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false}]},"attachment":[{"self":"http://www.example.com/jira/rest/api/2.0/attachments/10000","filename":"picture.jpg","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred","24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred","32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.461+0000","size":23123,"mimeType":"image/jpeg","content":"http://www.example.com/jira/attachments/10000","thumbnail":"http://www.example.com/jira/secure/thumbnail/10000"}],"sub-tasks":[{"id":"10000","type":{"id":"10000","name":"","inward":"Parent","outward":"Sub-task"},"outwardIssue":{"id":"10003","key":"EX-2","self":"http://www.example.com/jira/rest/api/2/issue/EX-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"description":"example bug report","project":{"self":"http://www.example.com/jira/rest/api/2/project/EX","id":"10000","key":"EX","name":"Example","avatarUrls":{"48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000","24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000","16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000","32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000"},"projectCategory":{"self":"http://www.example.com/jira/rest/api/2/projectCategory/10000","id":"10000","name":"FIRST","description":"First Project Category"}},"comment":{"comments":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/comment/10000","id":"10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.","updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","visibility":{"type":"role","value":"Administrators"}}]},"issuelinks":[{"id":"10001","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"outwardIssue":{"id":"10004L","key":"PRJ-2","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}},{"id":"10002","type":{"id":"10000","name":"Dependent","inward":"depends on","outward":"is depended by"},"inwardIssue":{"id":"10004","key":"PRJ-3","self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3","fields":{"status":{"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png","name":"Open"}}}}],"worklog":{"worklogs":[{"self":"http://www.example.com/jira/rest/api/2/issue/10010/worklog/10000","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"comment":"I did some work here.","updated":"2016-03-16T04:22:37.471+0000","visibility":{"type":"group","value":"jira-developers"},"started":"2016-03-16T04:22:37.471+0000","timeSpent":"3h 20m","timeSpentSeconds":12000,"id":"100028","issueId":"10002"}]},"updated":"2016-04-06T02:36:53.594-0700","timetracking":{"originalEstimate":"10m","remainingEstimate":"3m","timeSpent":"6m","originalEstimateSeconds":600,"remainingEstimateSeconds":200,"timeSpentSeconds":400}},"names":{"watcher":"watcher","attachment":"attachment","sub-tasks":"sub-tasks","description":"description","project":"project","comment":"comment","issuelinks":"issuelinks","worklog":"worklog","updated":"updated","timetracking":"timetracking"},"schema":{}}`)
})
issue, _, err := testClient.Issue.GetCustomFields("10002")
if err != nil {
t.Errorf("Error given: %s", err)
}
if issue == nil {
t.Error("Expected Customfields")
}
cf := issue["customfield_123"]
if cf != "test" {
t.Error("Expected \"test\" for custom field")
}
}
func TestIssueService_GetTransitions(t *testing.T) {
setup()
defer teardown()
@ -465,3 +625,592 @@ func TestIssueService_DoTransition(t *testing.T) {
t.Errorf("Got error: %v", err)
}
}
func TestIssueService_DoTransitionWithPayload(t *testing.T) {
setup()
defer teardown()
testAPIEndpoint := "/rest/api/2/issue/123/transitions"
transitionID := "22"
customPayload := map[string]interface{}{
"update": map[string]interface{}{
"comment": []map[string]interface{}{
{
"add": map[string]string{
"body": "Hello World",
},
},
},
},
"transition": TransitionPayload{
ID: transitionID,
},
}
testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "POST")
testRequestURL(t, r, testAPIEndpoint)
decoder := json.NewDecoder(r.Body)
payload := map[string]interface{}{}
err := decoder.Decode(&payload)
if err != nil {
t.Errorf("Got error: %v", err)
}
contains := func(key string) bool {
_, ok := payload[key]
return ok
}
if !contains("update") || !contains("transition") {
t.Fatalf("Excpected update, transition to be in payload, got %s instead", payload)
}
transition, ok := payload["transition"].(map[string]interface{})
if !ok {
t.Fatalf("Excpected transition to be in payload, got %s instead", payload["transition"])
}
if transition["id"].(string) != transitionID {
t.Errorf("Expected %s to be in payload, got %s instead", transitionID, transition["id"])
}
})
_, err := testClient.Issue.DoTransitionWithPayload("123", customPayload)
if err != nil {
t.Errorf("Got error: %v", err)
}
}
func TestIssueFields_TestMarshalJSON_PopulateUnknownsSuccess(t *testing.T) {
data := `{
"customfield_123":"test",
"description":"example bug report",
"project":{
"self":"http://www.example.com/jira/rest/api/2/project/EX",
"id":"10000",
"key":"EX",
"name":"Example",
"avatarUrls":{
"48x48":"http://www.example.com/jira/secure/projectavatar?size=large&pid=10000",
"24x24":"http://www.example.com/jira/secure/projectavatar?size=small&pid=10000",
"16x16":"http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000",
"32x32":"http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000"
},
"projectCategory":{
"self":"http://www.example.com/jira/rest/api/2/projectCategory/10000",
"id":"10000",
"name":"FIRST",
"description":"First Project Category"
}
},
"issuelinks":[
{
"id":"10001",
"type":{
"id":"10000",
"name":"Dependent",
"inward":"depends on",
"outward":"is depended by"
},
"outwardIssue":{
"id":"10004L",
"key":"PRJ-2",
"self":"http://www.example.com/jira/rest/api/2/issue/PRJ-2",
"fields":{
"status":{
"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png",
"name":"Open"
}
}
}
},
{
"id":"10002",
"type":{
"id":"10000",
"name":"Dependent",
"inward":"depends on",
"outward":"is depended by"
},
"inwardIssue":{
"id":"10004",
"key":"PRJ-3",
"self":"http://www.example.com/jira/rest/api/2/issue/PRJ-3",
"fields":{
"status":{
"iconUrl":"http://www.example.com/jira//images/icons/statuses/open.png",
"name":"Open"
}
}
}
}
]
}`
i := new(IssueFields)
err := json.Unmarshal([]byte(data), i)
if err != nil {
t.Errorf("Expected nil error, received %s", err)
}
if len(i.Unknowns) != 1 {
t.Errorf("Expected 1 unknown field to be present, received %d", len(i.Unknowns))
}
if i.Description != "example bug report" {
t.Errorf("Expected description to be \"%s\", received \"%s\"", "example bug report", i.Description)
}
}
func TestIssueFields_MarshalJSON_OmitsEmptyFields(t *testing.T) {
i := &IssueFields{
Description: "blahblah",
Type: IssueType{
Name: "Story",
},
Labels: []string{"aws-docker"},
}
rawdata, err := json.Marshal(i)
if err != nil {
t.Errorf("Expected nil err, received %s", err)
}
// convert json to map and see if unset keys are there
issuef := tcontainer.NewMarshalMap()
err = json.Unmarshal(rawdata, &issuef)
if err != nil {
t.Errorf("Expected nil err, received %s", err)
}
_, err = issuef.Int("issuetype/avatarId")
if err == nil {
t.Error("Expected non nil error, received nil")
}
// verify that the field that should be there, is.
name, err := issuef.String("issuetype/name")
if err != nil {
t.Errorf("Expected nil err, received %s", err)
}
if name != "Story" {
t.Errorf("Expected Story, received %s", name)
}
}
func TestIssueFields_MarshalJSON_Success(t *testing.T) {
i := &IssueFields{
Description: "example bug report",
Unknowns: tcontainer.MarshalMap{
"customfield_123": "test",
},
Project: Project{
Self: "http://www.example.com/jira/rest/api/2/project/EX",
ID: "10000",
Key: "EX",
},
}
bytes, err := json.Marshal(i)
if err != nil {
t.Errorf("Expected nil err, received %s", err)
}
received := new(IssueFields)
// the order of json might be different. so unmarshal it again and compare objects
err = json.Unmarshal(bytes, received)
if err != nil {
t.Errorf("Expected nil err, received %s", err)
}
if !reflect.DeepEqual(i, received) {
t.Errorf("Received object different from expected. Expected %+v, received %+v", i, received)
}
}
func TestInitIssueWithMetaAndFields_Success(t *testing.T) {
metaProject := MetaProject{
Name: "Engineering - Dept",
Id: "ENG",
}
fields := tcontainer.NewMarshalMap()
fields["summary"] = map[string]interface{}{
"name": "Summary",
"schema": map[string]interface{}{
"type": "string",
},
}
metaIssueType := MetaIssueType{
Fields: fields,
}
expectedSummary := "Issue Summary"
fieldConfig := map[string]string{
"Summary": "Issue Summary",
}
issue, err := InitIssueWithMetaAndFields(&metaProject, &metaIssueType, fieldConfig)
if err != nil {
t.Errorf("Expected nil error, received %s", err)
}
gotSummary, found := issue.Fields.Unknowns["summary"]
if !found {
t.Errorf("Expected summary to be set in issue. Not set.")
}
if gotSummary != expectedSummary {
t.Errorf("Expected %s received %s", expectedSummary, gotSummary)
}
}
func TestInitIssueWithMetaAndFields_ArrayValueType(t *testing.T) {
metaProject := MetaProject{
Name: "Engineering - Dept",
Id: "ENG",
}
fields := tcontainer.NewMarshalMap()
fields["component"] = map[string]interface{}{
"name": "Component/s",
"schema": map[string]interface{}{
"type": "array",
"items": "component",
},
}
metaIssueType := MetaIssueType{
Fields: fields,
}
expectedComponent := "Jira automation"
fieldConfig := map[string]string{
"Component/s": expectedComponent,
}
issue, err := InitIssueWithMetaAndFields(&metaProject, &metaIssueType, fieldConfig)
if err != nil {
t.Errorf("Expected nil error, received %s", err)
}
c, isArray := issue.Fields.Unknowns["component"].([]Component)
if isArray == false {
t.Error("Expected array, non array object received")
}
if len(c) != 1 {
t.Errorf("Expected received array to be of length 1. Got %d", len(c))
}
gotComponent := c[0].Name
if err != nil {
t.Errorf("Expected err to be nil, received %s", err)
}
if gotComponent != expectedComponent {
t.Errorf("Expected %s received %s", expectedComponent, gotComponent)
}
}
func TestInitIssueWithMetaAndFields_DateValueType(t *testing.T) {
metaProject := MetaProject{
Name: "Engineering - Dept",
Id: "ENG",
}
fields := tcontainer.NewMarshalMap()
fields["created"] = map[string]interface{}{
"name": "Created",
"schema": map[string]interface{}{
"type": "date",
},
}
metaIssueType := MetaIssueType{
Fields: fields,
}
expectedCreated := "19 oct 2012"
fieldConfig := map[string]string{
"Created": expectedCreated,
}
issue, err := InitIssueWithMetaAndFields(&metaProject, &metaIssueType, fieldConfig)
if err != nil {
t.Errorf("Expected nil error, received %s", err)
}
gotCreated, err := issue.Fields.Unknowns.String("created")
if err != nil {
t.Errorf("Expected err to be nil, received %s", err)
}
if gotCreated != expectedCreated {
t.Errorf("Expected %s received %s", expectedCreated, gotCreated)
}
}
func TestInitIssueWithMetaAndFields_UserValueType(t *testing.T) {
metaProject := MetaProject{
Name: "Engineering - Dept",
Id: "ENG",
}
fields := tcontainer.NewMarshalMap()
fields["assignee"] = map[string]interface{}{
"name": "Assignee",
"schema": map[string]interface{}{
"type": "user",
},
}
metaIssueType := MetaIssueType{
Fields: fields,
}
expectedAssignee := "jdoe"
fieldConfig := map[string]string{
"Assignee": expectedAssignee,
}
issue, err := InitIssueWithMetaAndFields(&metaProject, &metaIssueType, fieldConfig)
if err != nil {
t.Errorf("Expected nil error, received %s", err)
}
a, _ := issue.Fields.Unknowns.Value("assignee")
gotAssignee := a.(User).Name
if gotAssignee != expectedAssignee {
t.Errorf("Expected %s received %s", expectedAssignee, gotAssignee)
}
}
func TestInitIssueWithMetaAndFields_ProjectValueType(t *testing.T) {
metaProject := MetaProject{
Name: "Engineering - Dept",
Id: "ENG",
}
fields := tcontainer.NewMarshalMap()
fields["project"] = map[string]interface{}{
"name": "Project",
"schema": map[string]interface{}{
"type": "project",
},
}
metaIssueType := MetaIssueType{
Fields: fields,
}
setProject := "somewhere"
fieldConfig := map[string]string{
"Project": setProject,
}
issue, err := InitIssueWithMetaAndFields(&metaProject, &metaIssueType, fieldConfig)
if err != nil {
t.Errorf("Expected nil error, received %s", err)
}
a, _ := issue.Fields.Unknowns.Value("project")
gotProject := a.(Project).Name
if gotProject != metaProject.Name {
t.Errorf("Expected %s received %s", metaProject.Name, gotProject)
}
}
func TestInitIssueWithMetaAndFields_PriorityValueType(t *testing.T) {
metaProject := MetaProject{
Name: "Engineering - Dept",
Id: "ENG",
}
fields := tcontainer.NewMarshalMap()
fields["priority"] = map[string]interface{}{
"name": "Priority",
"schema": map[string]interface{}{
"type": "priority",
},
}
metaIssueType := MetaIssueType{
Fields: fields,
}
expectedPriority := "Normal"
fieldConfig := map[string]string{
"Priority": expectedPriority,
}
issue, err := InitIssueWithMetaAndFields(&metaProject, &metaIssueType, fieldConfig)
if err != nil {
t.Errorf("Expected nil error, received %s", err)
}
a, _ := issue.Fields.Unknowns.Value("priority")
gotPriority := a.(Priority).Name
if gotPriority != expectedPriority {
t.Errorf("Expected %s received %s", expectedPriority, gotPriority)
}
}
func TestInitIssueWithMetaAndFields_SelectList(t *testing.T) {
metaProject := MetaProject{
Name: "Engineering - Dept",
Id: "ENG",
}
fields := tcontainer.NewMarshalMap()
fields["someitem"] = map[string]interface{}{
"name": "A Select Item",
"schema": map[string]interface{}{
"type": "option",
},
}
metaIssueType := MetaIssueType{
Fields: fields,
}
expectedVal := "Value"
fieldConfig := map[string]string{
"A Select Item": expectedVal,
}
issue, err := InitIssueWithMetaAndFields(&metaProject, &metaIssueType, fieldConfig)
if err != nil {
t.Errorf("Expected nil error, received %s", err)
}
a, _ := issue.Fields.Unknowns.Value("someitem")
gotVal := a.(Option).Value
if gotVal != expectedVal {
t.Errorf("Expected %s received %s", expectedVal, gotVal)
}
}
func TestInitIssueWithMetaAndFields_IssuetypeValueType(t *testing.T) {
metaProject := MetaProject{
Name: "Engineering - Dept",
Id: "ENG",
}
fields := tcontainer.NewMarshalMap()
fields["issuetype"] = map[string]interface{}{
"name": "Issue type",
"schema": map[string]interface{}{
"type": "issuetype",
},
}
metaIssueType := MetaIssueType{
Fields: fields,
}
expectedIssuetype := "Bug"
fieldConfig := map[string]string{
"Issue type": expectedIssuetype,
}
issue, err := InitIssueWithMetaAndFields(&metaProject, &metaIssueType, fieldConfig)
if err != nil {
t.Errorf("Expected nil error, received %s", err)
}
a, _ := issue.Fields.Unknowns.Value("issuetype")
gotIssuetype := a.(IssueType).Name
if gotIssuetype != expectedIssuetype {
t.Errorf("Expected %s received %s", expectedIssuetype, gotIssuetype)
}
}
func TestInitIssueWithmetaAndFields_FailureWithUnknownValueType(t *testing.T) {
metaProject := MetaProject{
Name: "Engineering - Dept",
Id: "ENG",
}
fields := tcontainer.NewMarshalMap()
fields["issuetype"] = map[string]interface{}{
"name": "Issue type",
"schema": map[string]interface{}{
"type": "randomType",
},
}
metaIssueType := MetaIssueType{
Fields: fields,
}
fieldConfig := map[string]string{
"Issue tyoe": "sometype",
}
_, err := InitIssueWithMetaAndFields(&metaProject, &metaIssueType, fieldConfig)
if err == nil {
t.Error("Expected non nil error, received nil")
}
}
func TestIssueService_Delete(t *testing.T) {
setup()
defer teardown()
testMux.HandleFunc("/rest/api/2/issue/10002", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "DELETE")
testRequestURL(t, r, "/rest/api/2/issue/10002")
w.WriteHeader(http.StatusNoContent)
fmt.Fprint(w, `{}`)
})
resp, err := testClient.Issue.Delete("10002")
if resp.StatusCode != 204 {
t.Error("Expected issue not deleted.")
}
if err != nil {
t.Errorf("Error given: %s", err)
}
}
func TestIssueService_GetWorklogs(t *testing.T) {
setup()
defer teardown()
testMux.HandleFunc("/rest/api/2/issue/10002/worklog", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "GET")
testRequestURL(t, r, "/rest/api/2/issue/10002/worklog")
fmt.Fprint(w, `{"startAt": 1,"maxResults": 40,"total": 1,"worklogs": [{"id": "3","self": "http://kelpie9:8081/rest/api/2/issue/10002/worklog/3","author":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"updateAuthor":{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","name":"fred","displayName":"Fred F. User","active":false},"created":"2016-03-16T04:22:37.356+0000","updated":"2016-03-16T04:22:37.356+0000","comment":"","started":"2016-03-16T04:22:37.356+0000","timeSpent": "1h","timeSpentSeconds": 3600,"issueId":"10002"}]}`)
})
worklog, _, err := testClient.Issue.GetWorklogs("10002")
if worklog == nil {
t.Error("Expected worklog. Worklog is nil")
}
if len(worklog.Worklogs) != 1 {
t.Error("Expected 1 worklog")
}
if worklog.Worklogs[0].Author.Name != "fred" {
t.Error("Expected worklog author to be fred")
}
if err != nil {
t.Errorf("Error given: %s", err)
}
}

75
jira.go
View File

@ -29,6 +29,8 @@ type Client struct {
Project *ProjectService
Board *BoardService
Sprint *SprintService
User *UserService
Group *GroupService
}
// NewClient returns a new JIRA API client.
@ -57,10 +59,49 @@ func NewClient(httpClient *http.Client, baseURL string) (*Client, error) {
c.Project = &ProjectService{client: c}
c.Board = &BoardService{client: c}
c.Sprint = &SprintService{client: c}
c.User = &UserService{client: c}
c.Group = &GroupService{client: c}
return c, nil
}
// NewRawRequest 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.
// Allows using an optional native io.Reader for sourcing the request body.
func (c *Client) NewRawRequest(method, urlStr string, body io.Reader) (*http.Request, error) {
rel, err := url.Parse(urlStr)
if err != nil {
return nil, err
}
u := c.baseURL.ResolveReference(rel)
req, err := http.NewRequest(method, u.String(), body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
// Set authentication information
if c.Authentication.authType == authTypeSession {
// Set session cookie if there is one
if c.session != nil {
for _, cookie := range c.session.Cookies {
req.AddCookie(cookie)
}
}
} else if c.Authentication.authType == authTypeBasic {
// Set basic auth information
if c.Authentication.username != "" {
req.SetBasicAuth(c.Authentication.username, c.Authentication.password)
}
}
return req, 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.
@ -76,7 +117,7 @@ func (c *Client) NewRequest(method, urlStr string, body interface{}) (*http.Requ
var buf io.ReadWriter
if body != nil {
buf = new(bytes.Buffer)
err := json.NewEncoder(buf).Encode(body)
err = json.NewEncoder(buf).Encode(body)
if err != nil {
return nil, err
}
@ -89,10 +130,18 @@ func (c *Client) NewRequest(method, urlStr string, body interface{}) (*http.Requ
req.Header.Set("Content-Type", "application/json")
// Set session cookie if there is one
if c.session != nil {
for _, cookie := range c.session.Cookies {
req.AddCookie(cookie)
// Set authentication information
if c.Authentication.authType == authTypeSession {
// Set session cookie if there is one
if c.session != nil {
for _, cookie := range c.session.Cookies {
req.AddCookie(cookie)
}
}
} else if c.Authentication.authType == authTypeBasic {
// Set basic auth information
if c.Authentication.username != "" {
req.SetBasicAuth(c.Authentication.username, c.Authentication.password)
}
}
@ -141,10 +190,18 @@ func (c *Client) NewMultiPartRequest(method, urlStr string, buf *bytes.Buffer) (
// Set required headers
req.Header.Set("X-Atlassian-Token", "nocheck")
// Set session cookie if there is one
if c.session != nil {
for _, cookie := range c.session.Cookies {
req.AddCookie(cookie)
// Set authentication information
if c.Authentication.authType == authTypeSession {
// Set session cookie if there is one
if c.session != nil {
for _, cookie := range c.session.Cookies {
req.AddCookie(cookie)
}
}
} else if c.Authentication.authType == authTypeBasic {
// Set basic auth information
if c.Authentication.username != "" {
req.SetBasicAuth(c.Authentication.username, c.Authentication.password)
}
}

View File

@ -2,7 +2,6 @@ package jira
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
@ -107,6 +106,12 @@ func TestNewClient_WithServices(t *testing.T) {
if c.Sprint == nil {
t.Error("No SprintService provided")
}
if c.User == nil {
t.Error("No UserService provided")
}
if c.Group == nil {
t.Error("No GroupService provided")
}
}
func TestCheckResponse(t *testing.T) {
@ -127,7 +132,7 @@ func TestCheckResponse(t *testing.T) {
func TestClient_NewRequest(t *testing.T) {
c, err := NewClient(nil, testJIRAInstanceURL)
if err != nil {
t.Errorf("An error occured. Expected nil. Got %+v.", err)
t.Errorf("An error occurred. Expected nil. Got %+v.", err)
}
inURL, outURL := "rest/api/2/issue/", testJIRAInstanceURL+"rest/api/2/issue/"
@ -146,22 +151,27 @@ func TestClient_NewRequest(t *testing.T) {
}
}
func TestClient_NewRequest_InvalidJSON(t *testing.T) {
func TestClient_NewRawRequest(t *testing.T) {
c, err := NewClient(nil, testJIRAInstanceURL)
if err != nil {
t.Errorf("An error occured. Expected nil. Got %+v.", err)
t.Errorf("An error occurred. Expected nil. Got %+v.", err)
}
type T struct {
A map[int]interface{}
}
_, err = c.NewRequest("GET", "/", &T{})
inURL, outURL := "rest/api/2/issue/", testJIRAInstanceURL+"rest/api/2/issue/"
if err == nil {
t.Error("Expected error to be returned.")
outBody := `{"key":"MESOS"}` + "\n"
inBody := outBody
req, _ := c.NewRawRequest("GET", inURL, strings.NewReader(outBody))
// Test that relative URL was expanded
if got, want := req.URL.String(), outURL; got != want {
t.Errorf("NewRawRequest(%q) URL is %v, want %v", inURL, got, want)
}
if err, ok := err.(*json.UnsupportedTypeError); !ok {
t.Errorf("Expected a JSON error; got %+v.", err)
// Test that body was JSON encoded
body, _ := ioutil.ReadAll(req.Body)
if got, want := string(body), outBody; got != want {
t.Errorf("NewRawRequest(%v) Body is %v, want %v", inBody, got, want)
}
}
@ -177,7 +187,7 @@ func testURLParseError(t *testing.T, err error) {
func TestClient_NewRequest_BadURL(t *testing.T) {
c, err := NewClient(nil, testJIRAInstanceURL)
if err != nil {
t.Errorf("An error occured. Expected nil. Got %+v.", err)
t.Errorf("An error occurred. Expected nil. Got %+v.", err)
}
_, err = c.NewRequest("GET", ":", nil)
testURLParseError(t, err)
@ -186,31 +196,54 @@ func TestClient_NewRequest_BadURL(t *testing.T) {
func TestClient_NewRequest_SessionCookies(t *testing.T) {
c, err := NewClient(nil, testJIRAInstanceURL)
if err != nil {
t.Errorf("An error occured. Expected nil. Got %+v.", err)
t.Errorf("An error occurred. Expected nil. Got %+v.", err)
}
cookie := &http.Cookie{Name: "testcookie", Value: "testvalue"}
c.session = &Session{Cookies: []*http.Cookie{cookie}}
c.Authentication.authType = authTypeSession
inURL := "rest/api/2/issue/"
inBody := &Issue{Key: "MESOS"}
req, err := c.NewRequest("GET", inURL, inBody)
if err != nil {
t.Errorf("An error occured. Expected nil. Got %+v.", err)
t.Errorf("An error occurred. Expected nil. Got %+v.", err)
}
if len(req.Cookies()) != len(c.session.Cookies) {
t.Errorf("An error occured. Expected %d cookie(s). Got %d.", len(c.session.Cookies), len(req.Cookies()))
t.Errorf("An error occurred. Expected %d cookie(s). Got %d.", len(c.session.Cookies), len(req.Cookies()))
}
for i, v := range req.Cookies() {
if v.String() != c.session.Cookies[i].String() {
t.Errorf("An error occured. Unexpected cookie. Expected %s, actual %s.", v.String(), c.session.Cookies[i].String())
t.Errorf("An error occurred. Unexpected cookie. Expected %s, actual %s.", v.String(), c.session.Cookies[i].String())
}
}
}
func TestClient_NewRequest_BasicAuth(t *testing.T) {
c, err := NewClient(nil, testJIRAInstanceURL)
if err != nil {
t.Errorf("An error occurred. Expected nil. Got %+v.", err)
}
c.Authentication.SetBasicAuth("test-user", "test-password")
inURL := "rest/api/2/issue/"
inBody := &Issue{Key: "MESOS"}
req, err := c.NewRequest("GET", inURL, inBody)
if err != nil {
t.Errorf("An error occurred. Expected nil. Got %+v.", err)
}
username, password, ok := req.BasicAuth()
if !ok || username != "test-user" || password != "test-password" {
t.Errorf("An error occurred. Expected basic auth username %s and password %s. Got username %s and password %s.", "test-user", "test-password", username, password)
}
}
// If a nil body is passed to gerrit.NewRequest, make sure that nil is also passed to http.NewRequest.
// In most cases, passing an io.Reader that returns no content is fine,
// since there is no difference between an HTTP request body that is an empty string versus one that is not set at all.
@ -218,7 +251,7 @@ func TestClient_NewRequest_SessionCookies(t *testing.T) {
func TestClient_NewRequest_EmptyBody(t *testing.T) {
c, err := NewClient(nil, testJIRAInstanceURL)
if err != nil {
t.Errorf("An error occured. Expected nil. Got %+v.", err)
t.Errorf("An error occurred. Expected nil. Got %+v.", err)
}
req, err := c.NewRequest("GET", "/", nil)
if err != nil {
@ -232,32 +265,59 @@ func TestClient_NewRequest_EmptyBody(t *testing.T) {
func TestClient_NewMultiPartRequest(t *testing.T) {
c, err := NewClient(nil, testJIRAInstanceURL)
if err != nil {
t.Errorf("An error occured. Expected nil. Got %+v.", err)
t.Errorf("An error occurred. Expected nil. Got %+v.", err)
}
cookie := &http.Cookie{Name: "testcookie", Value: "testvalue"}
c.session = &Session{Cookies: []*http.Cookie{cookie}}
c.Authentication.authType = authTypeSession
inURL := "rest/api/2/issue/"
inBuf := bytes.NewBufferString("teststring")
req, err := c.NewMultiPartRequest("GET", inURL, inBuf)
if err != nil {
t.Errorf("An error occured. Expected nil. Got %+v.", err)
t.Errorf("An error occurred. Expected nil. Got %+v.", err)
}
if len(req.Cookies()) != len(c.session.Cookies) {
t.Errorf("An error occured. Expected %d cookie(s). Got %d.", len(c.session.Cookies), len(req.Cookies()))
t.Errorf("An error occurred. Expected %d cookie(s). Got %d.", len(c.session.Cookies), len(req.Cookies()))
}
for i, v := range req.Cookies() {
if v.String() != c.session.Cookies[i].String() {
t.Errorf("An error occured. Unexpected cookie. Expected %s, actual %s.", v.String(), c.session.Cookies[i].String())
t.Errorf("An error occurred. Unexpected cookie. Expected %s, actual %s.", v.String(), c.session.Cookies[i].String())
}
}
if req.Header.Get("X-Atlassian-Token") != "nocheck" {
t.Errorf("An error occured. Unexpected X-Atlassian-Token header value. Expected nocheck, actual %s.", req.Header.Get("X-Atlassian-Token"))
t.Errorf("An error occurred. Unexpected X-Atlassian-Token header value. Expected nocheck, actual %s.", req.Header.Get("X-Atlassian-Token"))
}
}
func TestClient_NewMultiPartRequest_BasicAuth(t *testing.T) {
c, err := NewClient(nil, testJIRAInstanceURL)
if err != nil {
t.Errorf("An error occurred. Expected nil. Got %+v.", err)
}
c.Authentication.SetBasicAuth("test-user", "test-password")
inURL := "rest/api/2/issue/"
inBuf := bytes.NewBufferString("teststring")
req, err := c.NewMultiPartRequest("GET", inURL, inBuf)
if err != nil {
t.Errorf("An error occurred. Expected nil. Got %+v.", err)
}
username, password, ok := req.BasicAuth()
if !ok || username != "test-user" || password != "test-password" {
t.Errorf("An error occurred. Expected basic auth username %s and password %s. Got username %s and password %s.", "test-user", "test-password", username, password)
}
if req.Header.Get("X-Atlassian-Token") != "nocheck" {
t.Errorf("An error occurred. Unexpected X-Atlassian-Token header value. Expected nocheck, actual %s.", req.Header.Get("X-Atlassian-Token"))
}
}

182
metaissue.go Normal file
View File

@ -0,0 +1,182 @@
package jira
import (
"fmt"
"strings"
"github.com/trivago/tgo/tcontainer"
)
// CreateMetaInfo contains information about fields and their attributed to create a ticket.
type CreateMetaInfo struct {
Expand string `json:"expand,omitempty"`
Projects []*MetaProject `json:"projects,omitempty"`
}
// MetaProject is the meta information about a project returned from createmeta api
type MetaProject struct {
Expand string `json:"expand,omitempty"`
Self string `json:"self, omitempty"`
Id string `json:"id,omitempty"`
Key string `json:"key,omitempty"`
Name string `json:"name,omitempty"`
// omitted avatarUrls
IssueTypes []*MetaIssueType `json:"issuetypes,omitempty"`
}
// MetaIssueType represents the different issue types a project has.
//
// Note: Fields is interface because this is an object which can
// have arbitraty keys related to customfields. It is not possible to
// expect these for a general way. This will be returning a map.
// Further processing must be done depending on what is required.
type MetaIssueType 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"`
Subtasks bool `json:"subtask,omitempty"`
Expand string `json:"expand,omitempty"`
Fields tcontainer.MarshalMap `json:"fields,omitempty"`
}
// GetCreateMeta makes the api call to get the meta information required to create a ticket
func (s *IssueService) GetCreateMeta(projectkey string) (*CreateMetaInfo, *Response, error) {
apiEndpoint := fmt.Sprintf("rest/api/2/issue/createmeta?projectKeys=%s&expand=projects.issuetypes.fields", projectkey)
req, err := s.client.NewRequest("GET", apiEndpoint, nil)
if err != nil {
return nil, nil, err
}
meta := new(CreateMetaInfo)
resp, err := s.client.Do(req, meta)
if err != nil {
return nil, resp, err
}
return meta, resp, nil
}
// GetProjectWithName returns a project with "name" from the meta information received. If not found, this returns nil.
// The comparison of the name is case insensitive.
func (m *CreateMetaInfo) GetProjectWithName(name string) *MetaProject {
for _, m := range m.Projects {
if strings.ToLower(m.Name) == strings.ToLower(name) {
return m
}
}
return nil
}
// GetProjectWithKey returns a project with "name" from the meta information received. If not found, this returns nil.
// The comparison of the name is case insensitive.
func (m *CreateMetaInfo) GetProjectWithKey(key string) *MetaProject {
for _, m := range m.Projects {
if strings.ToLower(m.Key) == strings.ToLower(key) {
return m
}
}
return nil
}
// GetIssueTypeWithName returns an IssueType with name from a given MetaProject. If not found, this returns nil.
// The comparison of the name is case insensitive
func (p *MetaProject) GetIssueTypeWithName(name string) *MetaIssueType {
for _, m := range p.IssueTypes {
if strings.ToLower(m.Name) == strings.ToLower(name) {
return m
}
}
return nil
}
// GetMandatoryFields returns a map of all the required fields from the MetaIssueTypes.
// if a field returned by the api was:
// "customfield_10806": {
// "required": true,
// "schema": {
// "type": "any",
// "custom": "com.pyxis.greenhopper.jira:gh-epic-link",
// "customId": 10806
// },
// "name": "Epic Link",
// "hasDefaultValue": false,
// "operations": [
// "set"
// ]
// }
// the returned map would have "Epic Link" as the key and "customfield_10806" as value.
// This choice has been made so that the it is easier to generate the create api request later.
func (t *MetaIssueType) GetMandatoryFields() (map[string]string, error) {
ret := make(map[string]string)
for key := range t.Fields {
required, err := t.Fields.Bool(key + "/required")
if err != nil {
return nil, err
}
if required {
name, err := t.Fields.String(key + "/name")
if err != nil {
return nil, err
}
ret[name] = key
}
}
return ret, nil
}
// GetAllFields returns a map of all the fields for an IssueType. This includes all required and not required.
// The key of the returned map is what you see in the form and the value is how it is representated in the jira schema.
func (t *MetaIssueType) GetAllFields() (map[string]string, error) {
ret := make(map[string]string)
for key := range t.Fields {
name, err := t.Fields.String(key + "/name")
if err != nil {
return nil, err
}
ret[name] = key
}
return ret, nil
}
// CheckCompleteAndAvailable checks if the given fields satisfies the mandatory field required to create a issue for the given type
// And also if the given fields are available.
func (t *MetaIssueType) CheckCompleteAndAvailable(config map[string]string) (bool, error) {
mandatory, err := t.GetMandatoryFields()
if err != nil {
return false, err
}
all, err := t.GetAllFields()
if err != nil {
return false, err
}
// check templateconfig against mandatory fields
for key := range mandatory {
if _, okay := config[key]; !okay {
var requiredFields []string
for name := range mandatory {
requiredFields = append(requiredFields, name)
}
return false, fmt.Errorf("Required field not found in provided jira.fields. Required are: %#v", requiredFields)
}
}
// check templateConfig against all fields to verify they are available
for key := range config {
if _, okay := all[key]; !okay {
var availableFields []string
for name := range all {
availableFields = append(availableFields, name)
}
return false, fmt.Errorf("Fields in jira.fields are not available in jira. Available are: %#v", availableFields)
}
}
return true, nil
}

629
metaissue_test.go Normal file
View File

@ -0,0 +1,629 @@
package jira
import (
"fmt"
"net/http"
"testing"
)
func TestIssueService_GetCreateMeta_Success(t *testing.T) {
setup()
defer teardown()
testAPIEndpoint := "/rest/api/2/issue/createmeta"
testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "GET")
testRequestURL(t, r, testAPIEndpoint)
fmt.Fprint(w, `{
"expand": "projects",
"projects": [{
"expand": "issuetypes",
"self": "https://my.jira.com/rest/api/2/project/11300",
"id": "11300",
"key": "SPN",
"name": "Super Project Name",
"avatarUrls": {
"48x48": "https://my.jira.com/secure/projectavatar?pid=11300&avatarId=14405",
"24x24": "https://my.jira.com/secure/projectavatar?size=small&pid=11300&avatarId=14405",
"16x16": "https://my.jira.com/secure/projectavatar?size=xsmall&pid=11300&avatarId=14405",
"32x32": "https://my.jira.com/secure/projectavatar?size=medium&pid=11300&avatarId=14405"
},
"issuetypes": [{
"self": "https://my.jira.com/rest/api/2/issuetype/6",
"id": "6",
"description": "An issue which ideally should be able to be completed in one step",
"iconUrl": "https://my.jira.com/secure/viewavatar?size=xsmall&avatarId=14006&avatarType=issuetype",
"name": "Request",
"subtask": false,
"expand": "fields",
"fields": {
"summary": {
"required": true,
"schema": {
"type": "string",
"system": "summary"
},
"name": "Summary",
"hasDefaultValue": false,
"operations": [
"set"
]
},
"issuetype": {
"required": true,
"schema": {
"type": "issuetype",
"system": "issuetype"
},
"name": "Issue Type",
"hasDefaultValue": false,
"operations": [
],
"allowedValues": [{
"self": "https://my.jira.com/rest/api/2/issuetype/6",
"id": "6",
"description": "An issue which ideally should be able to be completed in one step",
"iconUrl": "https://my.jira.com/secure/viewavatar?size=xsmall&avatarId=14006&avatarType=issuetype",
"name": "Request",
"subtask": false,
"avatarId": 14006
}]
},
"components": {
"required": true,
"schema": {
"type": "array",
"items": "component",
"system": "components"
},
"name": "Component/s",
"hasDefaultValue": false,
"operations": [
"add",
"set",
"remove"
],
"allowedValues": [{
"self": "https://my.jira.com/rest/api/2/component/14144",
"id": "14144",
"name": "Build automation",
"description": "Jenkins, webhooks, etc."
}, {
"self": "https://my.jira.com/rest/api/2/component/14149",
"id": "14149",
"name": "Caches and noSQL",
"description": "Cassandra, Memcached, Redis, Twemproxy, Xcache"
}, {
"self": "https://my.jira.com/rest/api/2/component/14152",
"id": "14152",
"name": "Cloud services",
"description": "AWS and similar services"
}, {
"self": "https://my.jira.com/rest/api/2/component/14147",
"id": "14147",
"name": "Code quality tools",
"description": "Code sniffer, Sonar"
}, {
"self": "https://my.jira.com/rest/api/2/component/14156",
"id": "14156",
"name": "Configuration management and provisioning",
"description": "Apache/PHP modules, Consul, Salt"
}, {
"self": "https://my.jira.com/rest/api/2/component/13606",
"id": "13606",
"name": "Cronjobs",
"description": "Cronjobs in general"
}, {
"self": "https://my.jira.com/rest/api/2/component/14150",
"id": "14150",
"name": "Data pipelines and queues",
"description": "Kafka, RabbitMq"
}, {
"self": "https://my.jira.com/rest/api/2/component/14159",
"id": "14159",
"name": "Database",
"description": "MySQL related problems"
}, {
"self": "https://my.jira.com/rest/api/2/component/14314",
"id": "14314",
"name": "Documentation"
}, {
"self": "https://my.jira.com/rest/api/2/component/14151",
"id": "14151",
"name": "Git",
"description": "Bitbucket, GitHub, GitLab, Git in general"
}, {
"self": "https://my.jira.com/rest/api/2/component/14155",
"id": "14155",
"name": "HTTP services",
"description": "CDN, HaProxy, HTTP, Varnish"
}, {
"self": "https://my.jira.com/rest/api/2/component/14154",
"id": "14154",
"name": "Job and service scheduling",
"description": "Chronos, Docker, Marathon, Mesos"
}, {
"self": "https://my.jira.com/rest/api/2/component/14158",
"id": "14158",
"name": "Legacy",
"description": "Everything related to legacy"
}, {
"self": "https://my.jira.com/rest/api/2/component/14157",
"id": "14157",
"name": "Monitoring",
"description": "Collectd, Nagios, Monitoring in general"
}, {
"self": "https://my.jira.com/rest/api/2/component/14148",
"id": "14148",
"name": "Other services"
}, {
"self": "https://my.jira.com/rest/api/2/component/13602",
"id": "13602",
"name": "Package management",
"description": "Composer, Medusa, Satis"
}, {
"self": "https://my.jira.com/rest/api/2/component/14145",
"id": "14145",
"name": "Release",
"description": "Directory config, release queries, rewrite rules"
}, {
"self": "https://my.jira.com/rest/api/2/component/14146",
"id": "14146",
"name": "Staging systems and VMs",
"description": "Stage, QA machines, KVMs,Vagrant"
}, {
"self": "https://my.jira.com/rest/api/2/component/14153",
"id": "14153",
"name": "Blog"
}, {
"self": "https://my.jira.com/rest/api/2/component/14143",
"id": "14143",
"name": "Test automation",
"description": "Testing infrastructure in general"
}, {
"self": "https://my.jira.com/rest/api/2/component/14221",
"id": "14221",
"name": "Internal Infrastructure"
}]
},
"attachment": {
"required": false,
"schema": {
"type": "array",
"items": "attachment",
"system": "attachment"
},
"name": "Attachment",
"hasDefaultValue": false,
"operations": [
]
},
"duedate": {
"required": false,
"schema": {
"type": "date",
"system": "duedate"
},
"name": "Due Date",
"hasDefaultValue": false,
"operations": [
"set"
]
},
"description": {
"required": false,
"schema": {
"type": "string",
"system": "description"
},
"name": "Description",
"hasDefaultValue": false,
"operations": [
"set"
]
},
"customfield_10806": {
"required": false,
"schema": {
"type": "any",
"custom": "com.pyxis.greenhopper.jira:gh-epic-link",
"customId": 10806
},
"name": "Epic Link",
"hasDefaultValue": false,
"operations": [
"set"
]
},
"project": {
"required": true,
"schema": {
"type": "project",
"system": "project"
},
"name": "Project",
"hasDefaultValue": false,
"operations": [
"set"
],
"allowedValues": [{
"self": "https://my.jira.com/rest/api/2/project/11300",
"id": "11300",
"key": "SPN",
"name": "Super Project Name",
"avatarUrls": {
"48x48": "https://my.jira.com/secure/projectavatar?pid=11300&avatarId=14405",
"24x24": "https://my.jira.com/secure/projectavatar?size=small&pid=11300&avatarId=14405",
"16x16": "https://my.jira.com/secure/projectavatar?size=xsmall&pid=11300&avatarId=14405",
"32x32": "https://my.jira.com/secure/projectavatar?size=medium&pid=11300&avatarId=14405"
},
"projectCategory": {
"self": "https://my.jira.com/rest/api/2/projectCategory/10100",
"id": "10100",
"description": "",
"name": "Product & Development"
}
}]
},
"assignee": {
"required": true,
"schema": {
"type": "user",
"system": "assignee"
},
"name": "Assignee",
"autoCompleteUrl": "https://my.jira.com/rest/api/latest/user/assignable/search?issueKey=null&username=",
"hasDefaultValue": true,
"operations": [
"set"
]
},
"priority": {
"required": false,
"schema": {
"type": "priority",
"system": "priority"
},
"name": "Priority",
"hasDefaultValue": true,
"operations": [
"set"
],
"allowedValues": [{
"self": "https://my.jira.com/rest/api/2/priority/1",
"iconUrl": "https://my.jira.com/images/icons/priorities/blocker.svg",
"name": "Immediate",
"id": "1"
}, {
"self": "https://my.jira.com/rest/api/2/priority/2",
"iconUrl": "https://my.jira.com/images/icons/priorities/critical.svg",
"name": "Urgent",
"id": "2"
}, {
"self": "https://my.jira.com/rest/api/2/priority/3",
"iconUrl": "https://my.jira.com/images/icons/priorities/major.svg",
"name": "High",
"id": "3"
}, {
"self": "https://my.jira.com/rest/api/2/priority/6",
"iconUrl": "https://my.jira.com/images/icons/priorities/moderate.svg",
"name": "Moderate",
"id": "6"
}, {
"self": "https://my.jira.com/rest/api/2/priority/4",
"iconUrl": "https://my.jira.com/images/icons/priorities/minor.svg",
"name": "Normal",
"id": "4"
}, {
"self": "https://my.jira.com/rest/api/2/priority/5",
"iconUrl": "https://my.jira.com/images/icons/priorities/trivial.svg",
"name": "Low",
"id": "5"
}]
},
"labels": {
"required": false,
"schema": {
"type": "array",
"items": "string",
"system": "labels"
},
"name": "Labels",
"autoCompleteUrl": "https://my.jira.com/rest/api/1.0/labels/suggest?query=",
"hasDefaultValue": false,
"operations": [
"add",
"set",
"remove"
]
}
}
}]
}]
}`)
})
issue, _, err := testClient.Issue.GetCreateMeta("SPN")
if err != nil {
t.Errorf("Expected nil error but got %s", err)
}
if len(issue.Projects) != 1 {
t.Errorf("Expected 1 project, got %d", len(issue.Projects))
}
for _, project := range issue.Projects {
if len(project.IssueTypes) != 1 {
t.Errorf("Expected 1 issueTypes, got %d", len(project.IssueTypes))
}
for _, issueTypes := range project.IssueTypes {
requiredFields := 0
fields := issueTypes.Fields
for _, value := range fields {
for key, value := range value.(map[string]interface{}) {
if key == "required" && value == true {
requiredFields = requiredFields + 1
}
}
}
if requiredFields != 5 {
t.Errorf("Expected 5 required fields from Create Meta information, got %d", requiredFields)
}
}
}
}
func TestMetaIssueType_GetMandatoryFields(t *testing.T) {
data := make(map[string]interface{})
data["summary"] = map[string]interface{}{
"required": true,
"name": "Summary",
}
data["components"] = map[string]interface{}{
"required": true,
"name": "Components",
}
data["epicLink"] = map[string]interface{}{
"required": false,
"name": "Epic Link",
}
m := new(MetaIssueType)
m.Fields = data
mandatory, err := m.GetMandatoryFields()
if err != nil {
t.Errorf("Expected nil error, received %s", err)
}
if len(mandatory) != 2 {
t.Errorf("Expected 2 received %+v", mandatory)
}
}
func TestMetaIssueType_GetMandatoryFields_NonExistentRequiredKey_Fail(t *testing.T) {
data := make(map[string]interface{})
data["summary"] = map[string]interface{}{
"name": "Summary",
}
m := new(MetaIssueType)
m.Fields = data
_, err := m.GetMandatoryFields()
if err == nil {
t.Error("Expected non nil errpr, received nil")
}
}
func TestMetaIssueType_GetMandatoryFields_NonExistentNameKey_Fail(t *testing.T) {
data := make(map[string]interface{})
data["summary"] = map[string]interface{}{
"required": true,
}
m := new(MetaIssueType)
m.Fields = data
_, err := m.GetMandatoryFields()
if err == nil {
t.Error("Expected non nil errpr, received nil")
}
}
func TestMetaIssueType_GetAllFields(t *testing.T) {
data := make(map[string]interface{})
data["summary"] = map[string]interface{}{
"required": true,
"name": "Summary",
}
data["components"] = map[string]interface{}{
"required": true,
"name": "Components",
}
data["epicLink"] = map[string]interface{}{
"required": false,
"name": "Epic Link",
}
m := new(MetaIssueType)
m.Fields = data
mandatory, err := m.GetAllFields()
if err != nil {
t.Errorf("Expected nil err, received %s", err)
}
if len(mandatory) != 3 {
t.Errorf("Expected 3 received %+v", mandatory)
}
}
func TestMetaIssueType_GetAllFields_NonExistingNameKey_Fail(t *testing.T) {
data := make(map[string]interface{})
data["summary"] = map[string]interface{}{
"required": true,
}
m := new(MetaIssueType)
m.Fields = data
_, err := m.GetAllFields()
if err == nil {
t.Error("Expected non nil error, received nil")
}
}
func TestMetaIssueType_CheckCompleteAndAvailable_MandatoryMissing(t *testing.T) {
data := make(map[string]interface{})
data["summary"] = map[string]interface{}{
"required": true,
"name": "Summary",
}
data["someKey"] = map[string]interface{}{
"required": false,
"name": "SomeKey",
}
config := map[string]string{
"SomeKey": "somevalue",
}
m := new(MetaIssueType)
m.Fields = data
ok, err := m.CheckCompleteAndAvailable(config)
if err == nil {
t.Error("Expected non nil error. Received nil")
}
if ok != false {
t.Error("Expected false, got true")
}
}
func TestMetaIssueType_CheckCompleteAndAvailable_NotAvailable(t *testing.T) {
data := make(map[string]interface{})
data["summary"] = map[string]interface{}{
"required": true,
"name": "Summary",
}
config := map[string]string{
"Summary": "Issue Summary",
"SomeKey": "somevalue",
}
m := new(MetaIssueType)
m.Fields = data
ok, err := m.CheckCompleteAndAvailable(config)
if err == nil {
t.Error("Expected non nil error. Received nil")
}
if ok != false {
t.Error("Expected false, got true")
}
}
func TestMetaIssueType_CheckCompleteAndAvailable_Success(t *testing.T) {
data := make(map[string]interface{})
data["summary"] = map[string]interface{}{
"required": true,
"name": "Summary",
}
data["someKey"] = map[string]interface{}{
"required": false,
"name": "SomeKey",
}
config := map[string]string{
"SomeKey": "somevalue",
"Summary": "Issue summary",
}
m := new(MetaIssueType)
m.Fields = data
ok, err := m.CheckCompleteAndAvailable(config)
if err != nil {
t.Errorf("Expected nil error. Received %s", err)
}
if ok != true {
t.Error("Expected true, got false")
}
}
func TestCreateMetaInfo_GetProjectWithName_Success(t *testing.T) {
metainfo := new(CreateMetaInfo)
metainfo.Projects = append(metainfo.Projects, &MetaProject{
Name: "SPN",
})
project := metainfo.GetProjectWithName("SPN")
if project == nil {
t.Errorf("Expected non nil value, received nil")
}
}
func TestMetaProject_GetIssueTypeWithName_CaseMismatch_Success(t *testing.T) {
m := new(MetaProject)
m.IssueTypes = append(m.IssueTypes, &MetaIssueType{
Name: "Bug",
})
issuetype := m.GetIssueTypeWithName("BUG")
if issuetype == nil {
t.Errorf("Expected non nil value, received nil")
}
}
func TestCreateMetaInfo_GetProjectWithKey_Success(t *testing.T) {
metainfo := new(CreateMetaInfo)
metainfo.Projects = append(metainfo.Projects, &MetaProject{
Key: "SPNKEY",
})
project := metainfo.GetProjectWithKey("SPNKEY")
if project == nil {
t.Errorf("Expected non nil value, received nil")
}
}
func TestCreateMetaInfo_GetProjectWithKey_NilForNonExistent(t *testing.T) {
metainfo := new(CreateMetaInfo)
metainfo.Projects = append(metainfo.Projects, &MetaProject{
Key: "SPNKEY",
})
project := metainfo.GetProjectWithKey("SPN")
if project != nil {
t.Errorf("Expected nil, received value")
}
}

View File

@ -13,72 +13,72 @@ type ProjectService struct {
// ProjectList represent a list of Projects
type ProjectList []struct {
Expand string `json:"expand"`
Self string `json:"self"`
ID string `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
AvatarUrls AvatarUrls `json:"avatarUrls"`
ProjectTypeKey string `json:"projectTypeKey"`
ProjectCategory ProjectCategory `json:"projectCategory,omitempty"`
Expand string `json:"expand" structs:"expand"`
Self string `json:"self" structs:"self"`
ID string `json:"id" structs:"id"`
Key string `json:"key" structs:"key"`
Name string `json:"name" structs:"name"`
AvatarUrls AvatarUrls `json:"avatarUrls" structs:"avatarUrls"`
ProjectTypeKey string `json:"projectTypeKey" structs:"projectTypeKey"`
ProjectCategory ProjectCategory `json:"projectCategory,omitempty" structs:"projectsCategory,omitempty"`
}
// ProjectCategory represents a single project category
type ProjectCategory struct {
Self string `json:"self"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Self string `json:"self" structs:"self,omitempty"`
ID string `json:"id" structs:"id,omitempty"`
Name string `json:"name" structs:"name,omitempty"`
Description string `json:"description" structs:"description,omitempty"`
}
// Project represents a JIRA Project.
type Project struct {
Expand string `json:"expand,omitempty"`
Self string `json:"self,omitempty"`
ID string `json:"id,omitempty"`
Key string `json:"key,omitempty"`
Description string `json:"description,omitempty"`
Lead User `json:"lead,omitempty"`
Components []ProjectComponent `json:"components,omitempty"`
IssueTypes []IssueType `json:"issueTypes,omitempty"`
URL string `json:"url,omitempty"`
Email string `json:"email,omitempty"`
AssigneeType string `json:"assigneeType,omitempty"`
Versions []Version `json:"versions,omitempty"`
Name string `json:"name,omitempty"`
Expand string `json:"expand,omitempty" structs:"expand,omitempty"`
Self string `json:"self,omitempty" structs:"self,omitempty"`
ID string `json:"id,omitempty" structs:"id,omitempty"`
Key string `json:"key,omitempty" structs:"key,omitempty"`
Description string `json:"description,omitempty" structs:"description,omitempty"`
Lead User `json:"lead,omitempty" structs:"lead,omitempty"`
Components []ProjectComponent `json:"components,omitempty" structs:"components,omitempty"`
IssueTypes []IssueType `json:"issueTypes,omitempty" structs:"issueTypes,omitempty"`
URL string `json:"url,omitempty" structs:"url,omitempty"`
Email string `json:"email,omitempty" structs:"email,omitempty"`
AssigneeType string `json:"assigneeType,omitempty" structs:"assigneeType,omitempty"`
Versions []Version `json:"versions,omitempty" structs:"versions,omitempty"`
Name string `json:"name,omitempty" structs:"name,omitempty"`
Roles struct {
Developers string `json:"Developers,omitempty"`
} `json:"roles,omitempty"`
AvatarUrls AvatarUrls `json:"avatarUrls,omitempty"`
ProjectCategory ProjectCategory `json:"projectCategory,omitempty"`
Developers string `json:"Developers,omitempty" structs:"Developers,omitempty"`
} `json:"roles,omitempty" structs:"roles,omitempty"`
AvatarUrls AvatarUrls `json:"avatarUrls,omitempty" structs:"avatarUrls,omitempty"`
ProjectCategory ProjectCategory `json:"projectCategory,omitempty" structs:"projectCategory,omitempty"`
}
// Version represents a single release version of a project
type Version struct {
Self string `json:"self"`
ID string `json:"id"`
Name string `json:"name"`
Archived bool `json:"archived"`
Released bool `json:"released"`
ReleaseDate string `json:"releaseDate"`
UserReleaseDate string `json:"userReleaseDate"`
ProjectID int `json:"projectId"` // Unlike other IDs, this is returned as a number
Self string `json:"self" structs:"self,omitempty"`
ID string `json:"id" structs:"id,omitempty"`
Name string `json:"name" structs:"name,omitempty"`
Archived bool `json:"archived" structs:"archived,omitempty"`
Released bool `json:"released" structs:"released,omitempty"`
ReleaseDate string `json:"releaseDate" structs:"releaseDate,omitempty"`
UserReleaseDate string `json:"userReleaseDate" structs:"userReleaseDate,omitempty"`
ProjectID int `json:"projectId" structs:"projectId,omitempty"` // Unlike other IDs, this is returned as a number
}
// ProjectComponent represents a single component of a project
type ProjectComponent struct {
Self string `json:"self"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Lead User `json:"lead"`
AssigneeType string `json:"assigneeType"`
Assignee User `json:"assignee"`
RealAssigneeType string `json:"realAssigneeType"`
RealAssignee User `json:"realAssignee"`
IsAssigneeTypeValid bool `json:"isAssigneeTypeValid"`
Project string `json:"project"`
ProjectID int `json:"projectId"`
Self string `json:"self" structs:"self,omitempty"`
ID string `json:"id" structs:"id,omitempty"`
Name string `json:"name" structs:"name,omitempty"`
Description string `json:"description" structs:"description,omitempty"`
Lead User `json:"lead,omitempty" structs:"lead,omitempty"`
AssigneeType string `json:"assigneeType" structs:"assigneeType,omitempty"`
Assignee User `json:"assignee" structs:"assignee,omitempty"`
RealAssigneeType string `json:"realAssigneeType" structs:"realAssigneeType,omitempty"`
RealAssignee User `json:"realAssignee" structs:"realAssignee,omitempty"`
IsAssigneeTypeValid bool `json:"isAssigneeTypeValid" structs:"isAssigneeTypeValid,omitempty"`
Project string `json:"project" structs:"project,omitempty"`
ProjectID int `json:"projectId" structs:"projectId,omitempty"`
}
// GetList gets all projects form JIRA
@ -107,7 +107,7 @@ func (s *ProjectService) GetList() (*ProjectList, *Response, error) {
//
// JIRA API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/project-getProject
func (s *ProjectService) Get(projectID string) (*Project, *Response, error) {
apiEndpoint := fmt.Sprintf("/rest/api/2/project/%s", projectID)
apiEndpoint := fmt.Sprintf("rest/api/2/project/%s", projectID)
req, err := s.client.NewRequest("GET", apiEndpoint, nil)
if err != nil {
return nil, nil, err

74
user.go Normal file
View File

@ -0,0 +1,74 @@
package jira
import (
"encoding/json"
"fmt"
"io/ioutil"
)
// UserService handles users for the JIRA instance / API.
//
// JIRA API docs: https://docs.atlassian.com/jira/REST/cloud/#api/2/user
type UserService struct {
client *Client
}
// User represents a JIRA user.
type User struct {
Self string `json:"self,omitempty" structs:"self,omitempty"`
Name string `json:"name,omitempty" structs:"name,omitempty"`
Password string `json:"-"`
Key string `json:"key,omitempty" structs:"key,omitempty"`
EmailAddress string `json:"emailAddress,omitempty" structs:"emailAddress,omitempty"`
AvatarUrls AvatarUrls `json:"avatarUrls,omitempty" structs:"avatarUrls,omitempty"`
DisplayName string `json:"displayName,omitempty" structs:"displayName,omitempty"`
Active bool `json:"active,omitempty" structs:"active,omitempty"`
TimeZone string `json:"timeZone,omitempty" structs:"timeZone,omitempty"`
ApplicationKeys []string `json:"applicationKeys,omitempty" structs:"applicationKeys,omitempty"`
}
// Get gets user info from JIRA
//
// JIRA API docs: https://docs.atlassian.com/jira/REST/cloud/#api/2/user-getUser
func (s *UserService) Get(username string) (*User, *Response, error) {
apiEndpoint := fmt.Sprintf("/rest/api/2/user?username=%s", username)
req, err := s.client.NewRequest("GET", apiEndpoint, nil)
if err != nil {
return nil, nil, err
}
user := new(User)
resp, err := s.client.Do(req, user)
if err != nil {
return nil, resp, err
}
return user, resp, nil
}
// Create creates an user in JIRA.
//
// JIRA API docs: https://docs.atlassian.com/jira/REST/cloud/#api/2/user-createUser
func (s *UserService) Create(user *User) (*User, *Response, error) {
apiEndpoint := "/rest/api/2/user"
req, err := s.client.NewRequest("POST", apiEndpoint, user)
if err != nil {
return nil, nil, err
}
resp, err := s.client.Do(req, nil)
if err != nil {
return nil, resp, err
}
responseUser := new(User)
defer resp.Body.Close()
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, resp, fmt.Errorf("Could not read the returned data")
}
err = json.Unmarshal(data, responseUser)
if err != nil {
return nil, resp, fmt.Errorf("Could not unmarshall the data into struct")
}
return responseUser, resp, nil
}

57
user_test.go Normal file
View File

@ -0,0 +1,57 @@
package jira
import (
"fmt"
"net/http"
"testing"
)
func TestUserService_Get_Success(t *testing.T) {
setup()
defer teardown()
testMux.HandleFunc("/rest/api/2/user", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "GET")
testRequestURL(t, r, "/rest/api/2/user?username=fred")
fmt.Fprint(w, `{"self":"http://www.example.com/jira/rest/api/2/user?username=fred","key":"fred",
"name":"fred","emailAddress":"fred@example.com","avatarUrls":{"48x48":"http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred",
"24x24":"http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred","16x16":"http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred",
"32x32":"http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"},"displayName":"Fred F. User","active":true,"timeZone":"Australia/Sydney","groups":{"size":3,"items":[
{"name":"jira-user","self":"http://www.example.com/jira/rest/api/2/group?groupname=jira-user"},{"name":"jira-admin",
"self":"http://www.example.com/jira/rest/api/2/group?groupname=jira-admin"},{"name":"important","self":"http://www.example.com/jira/rest/api/2/group?groupname=important"
}]},"applicationRoles":{"size":1,"items":[]},"expand":"groups,applicationRoles"}`)
})
if user, _, err := testClient.User.Get("fred"); err != nil {
t.Errorf("Error given: %s", err)
} else if user == nil {
t.Error("Expected user. User is nil")
}
}
func TestUserService_Create(t *testing.T) {
setup()
defer teardown()
testMux.HandleFunc("/rest/api/2/user", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "POST")
testRequestURL(t, r, "/rest/api/2/user")
w.WriteHeader(http.StatusCreated)
fmt.Fprint(w, `{"name":"charlie","password":"abracadabra","emailAddress":"charlie@atlassian.com",
"displayName":"Charlie of Atlassian","applicationKeys":["jira-core"]}`)
})
u := &User{
Name: "charlie",
Password: "abracadabra",
EmailAddress: "charlie@atlassian.com",
DisplayName: "Charlie of Atlassian",
ApplicationKeys: []string{"jira-core"},
}
if user, _, err := testClient.User.Create(u); err != nil {
t.Errorf("Error given: %s", err)
} else if user == nil {
t.Error("Expected user. User is nil")
}
}