diff --git a/.travis.yml b/.travis.yml index 54fb29c..34dc3b8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,11 +3,11 @@ language: go sudo: false go: - - "1.7.x" - "1.8.x" - "1.9.x" - "1.10.x" - "1.11.x" + - "1.12.x" before_install: - go get -t ./... diff --git a/Gopkg.lock b/Gopkg.lock deleted file mode 100644 index 00b3051..0000000 --- a/Gopkg.lock +++ /dev/null @@ -1,36 +0,0 @@ -# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. - - -[[projects]] - name = "github.com/fatih/structs" - packages = ["."] - revision = "a720dfa8df582c51dee1b36feabb906bde1588bd" - version = "v1.0" - -[[projects]] - branch = "master" - name = "github.com/google/go-querystring" - packages = ["query"] - revision = "53e6ce116135b80d037921a7fdd5138cf32d7a8a" - -[[projects]] - name = "github.com/pkg/errors" - packages = ["."] - revision = "645ef00459ed84a119197bfb8d8205042c6df63d" - version = "v0.8.0" - -[[projects]] - name = "github.com/trivago/tgo" - packages = [ - "tcontainer", - "treflect" - ] - revision = "e4d1ddd28c17dd89ed26327cf69fded22060671b" - version = "v1.0.1" - -[solve-meta] - analyzer-name = "dep" - analyzer-version = 1 - inputs-digest = "e84ca9eea6d233e0947b0d760913db2983fd4cbf6fd0d8690c737a71affb635c" - solver-name = "gps-cdcl" - solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml deleted file mode 100644 index 5ebe7d4..0000000 --- a/Gopkg.toml +++ /dev/null @@ -1,46 +0,0 @@ -# Gopkg.toml example -# -# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md -# for detailed Gopkg.toml documentation. -# -# required = ["github.com/user/thing/cmd/thing"] -# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] -# -# [[constraint]] -# name = "github.com/user/project" -# version = "1.0.0" -# -# [[constraint]] -# name = "github.com/user/project2" -# branch = "dev" -# source = "github.com/myfork/project2" -# -# [[override]] -# name = "github.com/x/y" -# version = "2.4.0" -# -# [prune] -# non-go = false -# go-tests = true -# unused-packages = true - - -[[constraint]] - name = "github.com/fatih/structs" - version = "1.0.0" - -[[constraint]] - branch = "master" - name = "github.com/google/go-querystring" - -[[constraint]] - name = "github.com/pkg/errors" - version = "0.8.0" - -[[constraint]] - name = "github.com/trivago/tgo" - version = "1.0.1" - -[prune] - go-tests = true - unused-packages = true diff --git a/README.md b/README.md index da5851e..922d0e2 100644 --- a/README.md +++ b/README.md @@ -17,15 +17,18 @@ 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 JIRA have a look at [latest JIRA REST API documentation](https://docs.atlassian.com/jira/REST/latest/). -## Compatible JIRA versions +## Requirements -This package was tested against JIRA v6.3.4 and v7.1.2. +* Go >= 1.8 +* JIRA v6.3.4 & v7.1.2. ## Installation It is go gettable - $ go get github.com/andygrunwald/go-jira +```bash +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. @@ -40,8 +43,10 @@ import ( (optional) to run unit / example tests: - $ cd $GOPATH/src/github.com/andygrunwald/go-jira - $ go test -v ./... +```bash +cd $GOPATH/src/github.com/andygrunwald/go-jira +go test -v ./... +``` ## API @@ -239,9 +244,9 @@ If you are new to pull requests, checkout [Collaborating on projects using issue ### Dependency management -`go-jira` uses `dep` for dependency management. After cloning the repo, it's easy to make sure you have the correct dependencies by running `dep ensure`. +`go-jira` uses `go modules` for dependency management. After cloning the repo, it's easy to make sure you have the correct dependencies by running `go mod tidy`. -For adding new dependencies, updating dependencies, and other operations, the [Daily Dep](https://golang.github.io/dep/docs/daily-dep.html) is a good place to start. +For adding new dependencies, updating dependencies, and other operations, the [Daily workflow](https://github.com/golang/go/wiki/Modules#daily-workflow) is a good place to start. ### Sandbox environment for testing diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c31a750 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/andygrunwald/go-jira + +go 1.12 + +require ( + github.com/fatih/structs v1.0.0 + github.com/google/go-cmp v0.3.0 + github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135 + github.com/pkg/errors v0.8.0 + github.com/trivago/tgo v1.0.1 + golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734 +) diff --git a/issue.go b/issue.go index 19afa07..2e35001 100644 --- a/issue.go +++ b/issue.go @@ -7,6 +7,7 @@ import ( "io" "io/ioutil" "mime/multipart" + "net/http" "net/url" "reflect" "strings" @@ -295,6 +296,10 @@ type Parent struct { // Time represents the Time definition of JIRA as a time.Time of go type Time time.Time +func (t Time) Equal(u Time) bool { + return time.Time(t).Equal(time.Time(u)) +} + // Date represents the Date definition of JIRA as a time.Time of go type Date time.Time @@ -394,17 +399,23 @@ type Worklog struct { // WorklogRecord represents one entry of a Worklog type WorklogRecord struct { - Self string `json:"self,omitempty" structs:"self,omitempty"` - Author *User `json:"author,omitempty" structs:"author,omitempty"` - UpdateAuthor *User `json:"updateAuthor,omitempty" structs:"updateAuthor,omitempty"` - Comment string `json:"comment,omitempty" structs:"comment,omitempty"` - Created *Time `json:"created,omitempty" structs:"created,omitempty"` - Updated *Time `json:"updated,omitempty" structs:"updated,omitempty"` - Started *Time `json:"started,omitempty" structs:"started,omitempty"` - TimeSpent string `json:"timeSpent,omitempty" structs:"timeSpent,omitempty"` - TimeSpentSeconds int `json:"timeSpentSeconds,omitempty" structs:"timeSpentSeconds,omitempty"` - ID string `json:"id,omitempty" structs:"id,omitempty"` - IssueID string `json:"issueId,omitempty" structs:"issueId,omitempty"` + Self string `json:"self,omitempty" structs:"self,omitempty"` + Author *User `json:"author,omitempty" structs:"author,omitempty"` + UpdateAuthor *User `json:"updateAuthor,omitempty" structs:"updateAuthor,omitempty"` + Comment string `json:"comment,omitempty" structs:"comment,omitempty"` + Created *Time `json:"created,omitempty" structs:"created,omitempty"` + Updated *Time `json:"updated,omitempty" structs:"updated,omitempty"` + Started *Time `json:"started,omitempty" structs:"started,omitempty"` + TimeSpent string `json:"timeSpent,omitempty" structs:"timeSpent,omitempty"` + TimeSpentSeconds int `json:"timeSpentSeconds,omitempty" structs:"timeSpentSeconds,omitempty"` + ID string `json:"id,omitempty" structs:"id,omitempty"` + IssueID string `json:"issueId,omitempty" structs:"issueId,omitempty"` + Properties []EntityProperty `json:"properties,omitempty"` +} + +type EntityProperty struct { + Key string `json:"key"` + Value interface{} `json:"value"` } // TimeTracking represents the timetracking fields of a JIRA issue. @@ -527,6 +538,22 @@ type GetQueryOptions struct { ProjectKeys string `url:"projectKeys,omitempty"` } +// GetWorklogsQueryOptions specifies the optional parameters for the Get Worklogs method +type GetWorklogsQueryOptions struct { + StartAt int64 `url:"startAt,omitempty"` + MaxResults int32 `url:"maxResults,omitempty"` + Expand string `url:"expand,omitempty"` +} + +type AddWorklogQueryOptions struct { + NotifyUsers bool `url:"notifyUsers,omitempty"` + AdjustEstimate string `url:"adjustEstimate,omitempty"` + NewEstimate string `url:"newEstimate,omitempty"` + ReduceBy string `url:"reduceBy,omitempty"` + Expand string `url:"expand,omitempty"` + OverrideEditableFlag bool `url:"overrideEditableFlag,omitempty"` +} + // CustomFields represents custom fields of JIRA // This can heavily differ between JIRA instances type CustomFields map[string]string @@ -626,7 +653,7 @@ func (s *IssueService) PostAttachment(issueID string, r io.Reader, attachmentNam // 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) { +func (s *IssueService) GetWorklogs(issueID string, options ...func(*http.Request) error) (*Worklog, *Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/worklog", issueID) req, err := s.client.NewRequest("GET", apiEndpoint, nil) @@ -634,11 +661,34 @@ func (s *IssueService) GetWorklogs(issueID string) (*Worklog, *Response, error) return nil, nil, err } + for _, option := range options { + err = option(req) + if err != nil { + return nil, nil, err + } + } + v := new(Worklog) resp, err := s.client.Do(req, v) return v, resp, err } +// Applies query options to http request. +// This helper is meant to be used with all "QueryOptions" structs. +func WithQueryOptions(options interface{}) func(*http.Request) error { + q, err := query.Values(options) + if err != nil { + return func(*http.Request) error { + return err + } + } + + return func(r *http.Request) error { + r.URL.RawQuery = q.Encode() + return nil + } +} + // Create creates an issue or a sub-task from a JSON representation. // Creating a sub-task is similar to creating a regular issue, with two important differences: // The issueType field must correspond to a sub-task issue type and you must provide a parent field in the issue create request containing the id or key of the parent issue. @@ -787,13 +837,20 @@ func (s *IssueService) DeleteComment(issueID, commentID string) error { // AddWorklogRecord adds a new worklog record to issueID. // // https://developer.atlassian.com/cloud/jira/platform/rest/#api-api-2-issue-issueIdOrKey-worklog-post -func (s *IssueService) AddWorklogRecord(issueID string, record *WorklogRecord) (*WorklogRecord, *Response, error) { +func (s *IssueService) AddWorklogRecord(issueID string, record *WorklogRecord, options ...func(*http.Request) error) (*WorklogRecord, *Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s/worklog", issueID) req, err := s.client.NewRequest("POST", apiEndpoint, record) if err != nil { return nil, nil, err } + for _, option := range options { + err = option(req) + if err != nil { + return nil, nil, err + } + } + responseRecord := new(WorklogRecord) resp, err := s.client.Do(req, responseRecord) if err != nil { diff --git a/issue_test.go b/issue_test.go index 56fe34b..130127b 100644 --- a/issue_test.go +++ b/issue_test.go @@ -3,6 +3,7 @@ package jira import ( "encoding/json" "fmt" + "github.com/google/go-cmp/cmp" "io" "io/ioutil" "net/http" @@ -1316,31 +1317,128 @@ func TestIssueService_Delete(t *testing.T) { } } +func getTime(original time.Time) *Time { + jiraTime := Time(original) + + return &jiraTime +} + 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") + tt := []struct { + name string + response string + issueId string + uri string + worklog *Worklog + err error + option *AddWorklogQueryOptions + }{ + { + name: "simple worklog", + response: `{"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"}]}`, + issueId: "10002", + uri: "/rest/api/2/issue/%s/worklog", + worklog: &Worklog{ + StartAt: 1, + MaxResults: 40, + Total: 1, + Worklogs: []WorklogRecord{ + { + Self: "http://kelpie9:8081/rest/api/2/issue/10002/worklog/3", + Author: &User{ + Self: "http://www.example.com/jira/rest/api/2/user?username=fred", + Name: "fred", + DisplayName: "Fred F. User", + }, + UpdateAuthor: &User{ + Self: "http://www.example.com/jira/rest/api/2/user?username=fred", + Name: "fred", + DisplayName: "Fred F. User", + }, + Created: getTime(time.Date(2016, time.March, 16, 4, 22, 37, 356000000, time.UTC)), + Started: getTime(time.Date(2016, time.March, 16, 4, 22, 37, 356000000, time.UTC)), + Updated: getTime(time.Date(2016, time.March, 16, 4, 22, 37, 356000000, time.UTC)), + TimeSpent: "1h", + TimeSpentSeconds: 3600, + ID: "3", + IssueID: "10002", + }, + }, + }, + }, + { + name: "expanded worklog", + response: `{"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","properties":[{"key":"foo","value":{"bar":"baz"}}]}]}`, + issueId: "10002", + uri: "/rest/api/2/issue/%s/worklog?expand=properties", + worklog: &Worklog{ + StartAt: 1, + MaxResults: 40, + Total: 1, + Worklogs: []WorklogRecord{ + { + Self: "http://kelpie9:8081/rest/api/2/issue/10002/worklog/3", + Author: &User{ + Self: "http://www.example.com/jira/rest/api/2/user?username=fred", + Name: "fred", + DisplayName: "Fred F. User", + }, + UpdateAuthor: &User{ + Self: "http://www.example.com/jira/rest/api/2/user?username=fred", + Name: "fred", + DisplayName: "Fred F. User", + }, + Created: getTime(time.Date(2016, time.March, 16, 4, 22, 37, 356000000, time.UTC)), + Started: getTime(time.Date(2016, time.March, 16, 4, 22, 37, 356000000, time.UTC)), + Updated: getTime(time.Date(2016, time.March, 16, 4, 22, 37, 356000000, time.UTC)), + TimeSpent: "1h", + TimeSpentSeconds: 3600, + ID: "3", + IssueID: "10002", + Properties: []EntityProperty{ + { + Key: "foo", + Value: map[string]interface{}{ + "bar": "baz", + }, + }, + }, + }, + }, + }, + option: &AddWorklogQueryOptions{Expand: "properties"}, + }, } - if len(worklog.Worklogs) != 1 { - t.Error("Expected 1 worklog") - } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + uri := fmt.Sprintf(tc.uri, tc.issueId) + testMux.HandleFunc(uri, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + testRequestURL(t, r, uri) + _, _ = fmt.Fprint(w, tc.response) + }) - if worklog.Worklogs[0].Author.Name != "fred" { - t.Error("Expected worklog author to be fred") - } + var worklog *Worklog + var err error - if err != nil { - t.Errorf("Error given: %s", err) + if tc.option != nil { + worklog, _, err = testClient.Issue.GetWorklogs(tc.issueId, WithQueryOptions(tc.option)) + } else { + worklog, _, err = testClient.Issue.GetWorklogs(tc.issueId) + } + + if err != nil && !cmp.Equal(err, tc.err) { + t.Errorf("unexpected error: %v", err) + } + + if !cmp.Equal(worklog, tc.worklog) { + t.Errorf("unexpected worklog structure: %s", cmp.Diff(worklog, tc.worklog)) + } + }) } }