Merge branch '18300-release-ticket-association'
[arvados-dev.git] / lib / redmine / issues.go
diff --git a/lib/redmine/issues.go b/lib/redmine/issues.go
new file mode 100644 (file)
index 0000000..19f4c3e
--- /dev/null
@@ -0,0 +1,213 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+// Somewhat inspired by https://github.com/mattn/go-redmine (MIT licensed)
+
+package redmine
+
+import (
+       "encoding/json"
+       "fmt"
+       "net/url"
+       "strconv"
+       "strings"
+)
+
+// In read operations the Redmine API returns ID fields like ProjectID.
+// When updating or creating an object, it wants a Project field.
+// This struct represents both for convenience.
+type Issue struct {
+       ID             int                `json:"id"`
+       Subject        string             `json:"subject"`
+       Description    string             `json:"description,omitempty"`
+       ProjectID      int                `json:"project_id,omitempty"`
+       Project        *IDName            `json:"project,omitempty"`
+       ParentIssueID  int                `json:"parent_issue_id,omitempty"`
+       Parent         *ID                `json:"parent,omitempty"`
+       StatusID       int                `json:"status_id,omitempty"`
+       Status         *IDName            `json:"status,omitempty"`
+       FixedVersionID int                `json:"fixed_version_id,omitempty"`
+       FixedVersion   *IDName            `json:"fixed_version,omitempty"`
+       ReleaseID      int                `json:"release_id,omitempty"`
+       Release        map[string]*IDName `json:"release,omitempty"`
+       TrackerID      int                `json:"tracker_id,omitempty"`
+       Tracker        *IDName            `json:"tracker,omitempty"`
+       PriorityID     int                `json:"priority_id,omitempty"`
+       Priority       *IDName            `json:"priority,omitempty"`
+       CategoryID     int                `json:"category_id,omitempty"`
+       Category       *IDName            `json:"category,omitempty"`
+       AssignedToID   int                `json:"assigned_to_id,omitempty"`
+       AssignedTo     *IDName            `json:"assigned_to,omitempty"`
+       WatcherUserIDs []int              `json:"watcher_user_ids,omitempty"`
+       Watchers       []*IDName          `json:"watchers,omitempty"`
+       IsPrivate      bool               `json:"is_private,omitempty"`
+       EstimatedHours float64            `json:"estimated_hours,omitempty"`
+       Notes          string             `json:"notes,omitempty"`
+}
+
+type IssueFilter struct {
+       ProjectID string
+       StatusID  string
+       Subject   string
+       ParentID  string
+}
+
+type issuesResult struct {
+       Issues     []Issue `json:"issues"`
+       TotalCount uint    `json:"total_count"`
+       Offset     uint    `json:"offset"`
+       Limit      uint    `json:"limit"`
+}
+
+type issueWrapper struct {
+       Issue Issue `json:"issue"`
+}
+
+// issueFilters converts an *IssueFilter into a slice of filter strings
+func issueFilters(issueFilter *IssueFilter) []string {
+       var filterParameters []string
+
+       if issueFilter == nil {
+               return filterParameters
+       }
+
+       if len(issueFilter.ProjectID) > 0 {
+               filterParameters = append(filterParameters, fmt.Sprintf("project_id=%v", issueFilter.ProjectID))
+       }
+       if len(issueFilter.StatusID) > 0 {
+               filterParameters = append(filterParameters, fmt.Sprintf("status_id=%v", issueFilter.StatusID))
+       }
+       if len(issueFilter.ParentID) > 0 {
+               filterParameters = append(filterParameters, fmt.Sprintf("parent_id=%v", issueFilter.ParentID))
+       }
+       if len(issueFilter.Subject) > 0 {
+               filterParameters = append(filterParameters, fmt.Sprintf("subject=~%v", issueFilter.Subject))
+       }
+
+       if len(filterParameters) > 0 {
+               return filterParameters[1:]
+       }
+
+       return filterParameters
+}
+
+// FilteredIssues returns a slice of issues that matches the f criteria
+func (c *Client) FilteredIssues(f *IssueFilter) ([]Issue, error) {
+       s := issueFilters(f)
+
+       res, err := c.Get("/issues.json?" + strings.Join(s, "&"))
+       if err != nil {
+               return nil, err
+       }
+       defer res.Body.Close()
+
+       var r issuesResult
+       err = responseHelper(res, &r, 200)
+       if err != nil {
+               return nil, err
+       }
+       return r.Issues, nil
+}
+
+// CreateIssue creates a redmine issue
+func (c *Client) CreateIssue(issue Issue) (*Issue, error) {
+       var ir issueWrapper
+       ir.Issue = issue
+       s, err := json.Marshal(ir)
+       if err != nil {
+               return nil, err
+       }
+       res, err := c.Put("/issues.json", string(s))
+       if err != nil {
+               return nil, err
+       }
+       defer res.Body.Close()
+
+       var r issueWrapper
+       err = responseHelper(res, &r, 200)
+       if err != nil {
+               return nil, err
+       }
+       return &r.Issue, nil
+}
+
+// GetIssue retrieves a redmine Issue object by id
+func (c *Client) GetIssue(ID int) (*Issue, error) {
+       res, err := c.Get("/issues/" + strconv.Itoa(ID) + ".json")
+       if err != nil {
+               return nil, err
+       }
+       defer res.Body.Close()
+
+       if res.StatusCode == 404 {
+               return nil, fmt.Errorf("Issue with id %d not found", ID)
+       }
+       var r issueWrapper
+       err = responseHelper(res, &r, 200)
+       if err != nil {
+               return nil, err
+       }
+       return &r.Issue, nil
+}
+
+// UpdateIssue updates a redmine issue
+func (c *Client) UpdateIssue(issue Issue) error {
+       var ir issueWrapper
+       issue.ProjectID = issue.Project.ID
+       ir.Issue = issue
+       s, err := json.Marshal(ir)
+       if err != nil {
+               return err
+       }
+       res, err := c.Put("/issues/"+strconv.Itoa(issue.ID)+".json", string(s))
+       if err != nil {
+               return err
+       }
+       defer res.Body.Close()
+       if res.StatusCode == 404 {
+               return fmt.Errorf("Issue with id %d not found", issue.ID)
+       }
+
+       return responseHelper(res, nil, 200)
+}
+
+// FindOrCreateIssue finds or creates an issue with a given subject, parentID, versionID and projectID
+func (c *Client) FindOrCreateIssue(subject string, parentID int, versionID int, projectID int) (Issue, error) {
+       var f IssueFilter
+       var issue Issue
+       f.Subject = url.QueryEscape(subject)
+       if parentID != 0 {
+               f.ParentID = strconv.Itoa(parentID)
+       }
+       if projectID != 0 {
+               f.ProjectID = strconv.Itoa(projectID)
+       }
+       f.StatusID = "*"
+       issues, err := c.FilteredIssues(&f)
+       if err != nil {
+               return issue, err
+       }
+       if len(issues) > 0 {
+               // Issue found, return it
+               return issues[0], err
+       }
+
+       // Create new issue
+       issue.ProjectID = projectID
+       issue.FixedVersionID = versionID
+       issue.Subject = subject
+       if parentID != 0 {
+               issue.Parent = &ID{ID: parentID}
+       }
+
+       i, err := c.CreateIssue(issue)
+       return *i, err
+}
+
+// SetRelease updates the release for an issue
+func (c *Client) SetRelease(issue Issue, release int) error {
+       issue.ReleaseID = release
+       issue.Release = nil
+       return c.UpdateIssue(issue)
+}