From fe0595ab459b78ef0c8137d7ce3e7f3d083a6d85 Mon Sep 17 00:00:00 2001 From: Douglas Chimento Date: Sun, 29 May 2016 11:30:45 -0400 Subject: [PATCH 1/5] updating with search and worklogs --- .gitignore | 1 + issue.go | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index daf913b..76b8418 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ _testmain.go *.exe *.test *.prof +*.iml diff --git a/issue.go b/issue.go index 42b27eb..f067f34 100644 --- a/issue.go +++ b/issue.go @@ -3,6 +3,9 @@ package jira import ( "fmt" "net/http" + "net/url" + "strings" + "time" ) const ( @@ -26,6 +29,8 @@ type Issue struct { Fields *IssueFields `json:"fields,omitempty"` } +type Issues []*Issue + // IssueFields represents single fields of a JIRA issue. // Every JIRA issue has several fields attached. type IssueFields struct { @@ -34,14 +39,12 @@ type IssueFields struct { // * "aggregatetimespent": null, // * "workratio": -1, // * "lastViewed": null, - // * "labels": [], // * "timeestimate": null, // * "aggregatetimeoriginalestimate": null, // * "timeoriginalestimate": null, // * "timetracking": {}, // * "attachment": [], // * "aggregatetimeestimate": null, - // * "subtasks": [], // * "environment": null, // * "duedate": null, Type IssueType `json:"issuetype"` @@ -61,10 +64,12 @@ type IssueFields struct { Status *Status `json:"status,omitempty"` Progress *Progress `json:"progress,omitempty"` AggregateProgress *Progress `json:"aggregateprogress,omitempty"` - Worklog []*Worklog `json:"worklog.worklogs,omitempty"` + WorklogPage *WorklogPage `json:"worklog,omitempty"` IssueLinks []*IssueLink `json:"issuelinks,omitempty"` Comments []*Comment `json:"comment.comments,omitempty"` FixVersions []*FixVersion `json:"fixVersions,omitempty"` + Labels []string `json:"labels,omitempty"` + SubTasks Issues `json:"subtasks,omitempty"` } // IssueType represents a type of a JIRA issue. @@ -158,10 +163,39 @@ type Progress struct { Total int `json:"total"` } +type WorklogPage struct { + StartAt uint `json:"startAt"` + MaxResults uint `json:"maxResults"` + Total uint `json:"total"` + Worklogs []*Worklog `json:"worklogs"` +} + // Worklog represents the work log of a JIRA issue. // JIRA Wiki: https://confluence.atlassian.com/jira/logging-work-on-an-issue-185729605.html type Worklog struct { - // TODO Add Worklogs + ID string `json:"id"` + Self string `json:"self"` + IssueId string `json:"issueId"` + TimeSpent string `json:"timeSpent"` + TimeSpentSeconds uint64 `json:"timeSpentSeconds"` + Comment string `json:"comment"` + Updated JiraTime `json:"updated"` + Created JiraTime `json:"created"` + Started JiraTime `json:"started"` + Author *Assignee `json:"author"` +} + +type JiraTime struct { + Time time.Time +} + +func (t *JiraTime) UnmarshalJSON(b []byte) error { + ti, err := time.Parse("\"2006-01-02T15:04:05.999-0700\"", string(b)) + if err != nil { + return err + } + *t = JiraTime{ti} + return nil } // IssueLink represents a link between two issues in JIRA. @@ -215,6 +249,10 @@ type CommentVisibility struct { Value string `json:"value"` } +type SearchResult struct { + Issues Issues `json:"issues"` +} + // Get returns a full representation of the issue for the given issue key. // JIRA will attempt to identify the issue by the issueIdOrKey path parameter. // This can be an issue id, or an issue key. @@ -237,6 +275,38 @@ func (s *IssueService) Get(issueID string) (*Issue, *http.Response, error) { return issue, resp, nil } +type CustomFields map[string]string + +// Returns a map of customfield_* keys with string values +func (s *IssueService) GetCustomFields(issueID string) (CustomFields, *http.Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s", issueID) + req, err := s.client.NewRequest("GET", apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + issue := new(map[string]interface{}) + resp, err := s.client.Do(req, issue) + if err != nil { + return nil, resp, err + } + m := *issue + f := m["fields"] + cf := make(CustomFields) + if f == nil { + return cf, resp, nil + } + + if rec, ok := f.(map[string]interface{}); ok { + for key, val := range rec { + if strings.Contains(key, "customfield") { + cf[key] = fmt.Sprint(val) + } + } + } + return cf, resp, nil +} + // Create creates an issue or a sub-task from a JSON representation. // Creating a sub-task is similar to creating a regular issue, with two important differences: // The issueType field must correspond to a sub-task issue type and you must provide a parent field in the issue create request containing the id or key of the parent issue. @@ -290,3 +360,15 @@ func (s *IssueService) AddLink(issueLink *IssueLink) (*http.Response, error) { resp, err := s.client.Do(req, nil) return resp, err } + +// Search for tickets +// 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) Search(jql string) (*SearchResult, error) { + req, err := s.client.NewRequest("GET", "rest/api/2/search?jql="+url.QueryEscape(jql), nil) + if err != nil { + panic(err) + } + resp := new(SearchResult) + _, err = s.client.Do(req, resp) + return resp, err +} From 7fc559153eb37ad9e32cef37e917526cfb3c0de3 Mon Sep 17 00:00:00 2001 From: Douglas Chimento Date: Sun, 29 May 2016 15:10:19 -0400 Subject: [PATCH 2/5] using native time.Time --- issue.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/issue.go b/issue.go index f067f34..0f52700 100644 --- a/issue.go +++ b/issue.go @@ -185,16 +185,16 @@ type Worklog struct { Author *Assignee `json:"author"` } -type JiraTime struct { - Time time.Time -} +type JiraTime time.Time + +type CustomFields map[string]string func (t *JiraTime) UnmarshalJSON(b []byte) error { ti, err := time.Parse("\"2006-01-02T15:04:05.999-0700\"", string(b)) if err != nil { return err } - *t = JiraTime{ti} + *t = JiraTime(ti) return nil } @@ -275,7 +275,6 @@ func (s *IssueService) Get(issueID string) (*Issue, *http.Response, error) { return issue, resp, nil } -type CustomFields map[string]string // Returns a map of customfield_* keys with string values func (s *IssueService) GetCustomFields(issueID string) (CustomFields, *http.Response, error) { From e2bff3f8098960e20f7c664d8e15dfa6ae7aba3a Mon Sep 17 00:00:00 2001 From: Douglas Chimento Date: Tue, 31 May 2016 12:09:04 -0400 Subject: [PATCH 3/5] update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 76b8418..dfbdb5e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ _testmain.go *.test *.prof *.iml +.idea From d77b32f3ddb1e3ba7efc7b670ec79dc1b2416001 Mon Sep 17 00:00:00 2001 From: Douglas Chimento Date: Fri, 3 Jun 2016 20:54:51 -0400 Subject: [PATCH 4/5] go fmt --- issue_test.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/issue_test.go b/issue_test.go index 562d871..d3536df 100644 --- a/issue_test.go +++ b/issue_test.go @@ -320,14 +320,13 @@ func TestIssue_Search(t *testing.T) { w.WriteHeader(http.StatusOK) fmt.Fprint(w, `{"expand": "schema,names","startAt": 0,"maxResults": 50,"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}}}]}`) }) - _ ,err := testClient.Issue.Search("something") + _, err := testClient.Issue.Search("something") if err != nil { t.Errorf("Error given: %s", err) } } - func Test_CustomFields(t *testing.T) { setup() defer teardown() @@ -348,4 +347,4 @@ func Test_CustomFields(t *testing.T) { if cf != "test" { t.Error("Expected \"test\" for custom field") } -} \ No newline at end of file +} From 1628d1b1d30378daa368d7edd3901b97120e3770 Mon Sep 17 00:00:00 2001 From: Andy Grunwald Date: Sat, 4 Jun 2016 10:41:34 +0200 Subject: [PATCH 5/5] Adjusted a few things to be in line with other methods --- issue.go | 67 ++++++++++++++++++++++++++++++--------------------- issue_test.go | 5 +++- 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/issue.go b/issue.go index d4e36f9..dacddd3 100644 --- a/issue.go +++ b/issue.go @@ -179,14 +179,17 @@ type Progress struct { Total int `json:"total"` } -type JiraTime time.Time +// Time represents the Time definition of JIRA as a time.Time of go +type Time time.Time -func (t *JiraTime) UnmarshalJSON(b []byte) error { +// UnmarshalJSON will transform the JIRA time into a time.Time +// during the transformation of the JIRA JSON response +func (t *Time) UnmarshalJSON(b []byte) error { ti, err := time.Parse("\"2006-01-02T15:04:05.999-0700\"", string(b)) if err != nil { return err } - *t = JiraTime(ti) + *t = Time(ti) return nil } @@ -202,17 +205,17 @@ type Worklog struct { // 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 JiraTime `json:"created"` - Updated JiraTime `json:"updated"` - Started string `json:"started"` - TimeSpent string `json:"timeSpent"` - TimeSpentSeconds int `json:"timeSpentSeconds"` - ID string `json:"id"` - IssueID string `json:"issueId"` + 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 string `json:"started"` + TimeSpent string `json:"timeSpent"` + TimeSpentSeconds int `json:"timeSpentSeconds"` + ID string `json:"id"` + IssueID string `json:"issueId"` } // Subtasks represents all issues of a parent issue. @@ -274,6 +277,16 @@ type CommentVisibility struct { Value string `json:"value,omitempty"` } +// searchResult is only a small wrapper arround the Search (with JQL) method +// to be able to parse the results +type searchResult struct { + Issues []Issue `json:"issues"` +} + +// CustomFields represents custom fields of JIRA +// This can heavily differ between JIRA instances +type CustomFields map[string]string + // Get returns a full representation of the issue for the given issue key. // JIRA will attempt to identify the issue by the issueIdOrKey path parameter. // This can be an issue id, or an issue key. @@ -406,25 +419,22 @@ func (s *IssueService) AddLink(issueLink *IssueLink) (*http.Response, error) { return resp, err } -type searchResult struct { - Issues []Issue `json:"issues"` -} - -// Search for tickets +// Search will search for tickets according to the jql +// // 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) Search(jql string) ([]Issue, error) { - req, err := s.client.NewRequest("GET", "rest/api/2/search?jql="+url.QueryEscape(jql), nil) +func (s *IssueService) Search(jql string) ([]Issue, *http.Response, error) { + u := fmt.Sprintf("rest/api/2/search?jql=%s", url.QueryEscape(jql)) + req, err := s.client.NewRequest("GET", u, nil) if err != nil { - panic(err) + return []Issue{}, nil, err } - resp := new(searchResult) - _, err = s.client.Do(req, resp) - return resp.Issues, err + + v := new(searchResult) + resp, err := s.client.Do(req, v) + return v.Issues, resp, err } -type CustomFields map[string]string - -// Returns a map of customfield_* keys with string values +// GetCustomFields returns a map of customfield_* keys with string values func (s *IssueService) GetCustomFields(issueID string) (CustomFields, *http.Response, error) { apiEndpoint := fmt.Sprintf("rest/api/2/issue/%s", issueID) req, err := s.client.NewRequest("GET", apiEndpoint, nil) @@ -437,6 +447,7 @@ func (s *IssueService) GetCustomFields(issueID string) (CustomFields, *http.Resp if err != nil { return nil, resp, err } + m := *issue f := m["fields"] cf := make(CustomFields) diff --git a/issue_test.go b/issue_test.go index d3536df..029411c 100644 --- a/issue_test.go +++ b/issue_test.go @@ -320,8 +320,11 @@ func TestIssue_Search(t *testing.T) { w.WriteHeader(http.StatusOK) fmt.Fprint(w, `{"expand": "schema,names","startAt": 0,"maxResults": 50,"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}}}]}`) }) - _, err := testClient.Issue.Search("something") + _, resp, err := testClient.Issue.Search("something") + if resp == nil { + t.Errorf("Response given: %+v", resp) + } if err != nil { t.Errorf("Error given: %s", err) }