Add the Redmine REST client with a fake-server test suite
Issues (create/update/partial PUT, notes-as-journals), versions, categories, relations, and name-to-id resolution over trackers/ statuses/priorities. Sentinel errors carry http codes one-line; response bodies are never surfaced so the API key cannot leak through error strings (the fake deliberately echoes the presented key to prove it).
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
package redmine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Status filter values for IssueFilter.Status.
|
||||
const (
|
||||
StatusOpen = "open" // Redmine default: open issues only
|
||||
StatusClosed = "closed"
|
||||
StatusAll = "*" // every issue regardless of status
|
||||
)
|
||||
|
||||
// Ref is a nested {id, name} object in Redmine's read shape.
|
||||
type Ref struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// String renders "Name (#ID)" or "#ID" when unnamed.
|
||||
func (r Ref) String() string {
|
||||
if r.Name != "" {
|
||||
return fmt.Sprintf("%s (#%d)", r.Name, r.ID)
|
||||
}
|
||||
return fmt.Sprintf("#%d", r.ID)
|
||||
}
|
||||
|
||||
// StatusRef adds the is_closed flag.
|
||||
type StatusRef struct {
|
||||
Ref
|
||||
IsClosed bool `json:"is_closed"`
|
||||
}
|
||||
|
||||
// Journal is one history entry (a note, with its author and timestamp).
|
||||
type Journal struct {
|
||||
ID int `json:"id"`
|
||||
UserID int `json:"user_id"`
|
||||
Notes string `json:"notes"`
|
||||
CreatedOn string `json:"created_on"`
|
||||
}
|
||||
|
||||
// Issue is Redmine's READ shape: nested objects for project, tracker,
|
||||
// status, priority, category, fixed_version, parent.
|
||||
type Issue struct {
|
||||
ID int `json:"id"`
|
||||
Subject string `json:"subject"`
|
||||
Description string `json:"description"`
|
||||
Project Ref `json:"project"`
|
||||
Tracker Ref `json:"tracker"`
|
||||
Status StatusRef `json:"status"`
|
||||
Priority Ref `json:"priority"`
|
||||
Category Ref `json:"category"`
|
||||
FixedVersion Ref `json:"fixed_version"`
|
||||
Parent Ref `json:"parent"`
|
||||
AssignedTo Ref `json:"assigned_to"`
|
||||
EstimatedHours *float64 `json:"estimated_hours"`
|
||||
DoneRatio int `json:"done_ratio"`
|
||||
DueDate string `json:"due_date"`
|
||||
CreatedOn string `json:"created_on"`
|
||||
UpdatedOn string `json:"updated_on"`
|
||||
Journals []Journal `json:"journals"`
|
||||
}
|
||||
|
||||
// IssueFilter narrows GET /issues.json. Zero fields list open issues
|
||||
// across all projects, Redmine's own default behavior.
|
||||
type IssueFilter struct {
|
||||
Project string // project identifier (e.g. "MOPAC"); empty = all
|
||||
Status string // "open" (default), "closed", "*", or a numeric id
|
||||
FixedVersion int // version id; 0 = no filter
|
||||
Limit int // page size; 0 = Redmine default (25)
|
||||
Offset int
|
||||
}
|
||||
|
||||
func (f IssueFilter) query() url.Values {
|
||||
q := url.Values{}
|
||||
if f.Project != "" {
|
||||
q.Set("project_id", f.Project)
|
||||
}
|
||||
switch {
|
||||
case f.Status == "":
|
||||
q.Set("status_id", StatusOpen)
|
||||
case f.Status == StatusOpen || f.Status == StatusClosed || f.Status == StatusAll:
|
||||
q.Set("status_id", f.Status)
|
||||
default:
|
||||
if _, err := strconv.Atoi(f.Status); err == nil {
|
||||
q.Set("status_id", f.Status)
|
||||
}
|
||||
}
|
||||
if f.FixedVersion != 0 {
|
||||
q.Set("fixed_version_id", strconv.Itoa(f.FixedVersion))
|
||||
}
|
||||
if f.Limit != 0 {
|
||||
q.Set("limit", strconv.Itoa(f.Limit))
|
||||
}
|
||||
if f.Offset != 0 {
|
||||
q.Set("offset", strconv.Itoa(f.Offset))
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// ListIssues returns issues matching the filter plus the server's total
|
||||
// count (ignoring limit/offset pagination).
|
||||
func (c *Client) ListIssues(ctx context.Context, f IssueFilter) ([]Issue, int, error) {
|
||||
var body struct {
|
||||
Issues []Issue `json:"issues"`
|
||||
Total int `json:"total_count"`
|
||||
}
|
||||
if err := c.do(ctx, "GET", "/issues.json", f.query(), nil, &body); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return body.Issues, body.Total, nil
|
||||
}
|
||||
|
||||
// GetIssue fetches one issue. withJournals adds ?include=journals.
|
||||
func (c *Client) GetIssue(ctx context.Context, id int, withJournals bool) (*Issue, error) {
|
||||
q := url.Values{}
|
||||
if withJournals {
|
||||
q.Set("include", "journals")
|
||||
}
|
||||
var body struct {
|
||||
Issue Issue `json:"issue"`
|
||||
}
|
||||
if err := c.do(ctx, "GET", "/issues/"+strconv.Itoa(id)+".json", q, nil, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &body.Issue, nil
|
||||
}
|
||||
|
||||
// IssueParams is the WRITE shape for create/update. Zero fields are
|
||||
// omitted from the payload, so update touches only what you set
|
||||
// (partial-update discipline: never clobber untouched attributes).
|
||||
type IssueParams struct {
|
||||
Project string
|
||||
Subject string
|
||||
Description string
|
||||
TrackerID int
|
||||
StatusID int
|
||||
PriorityID int
|
||||
CategoryID int
|
||||
FixedVersionID int
|
||||
ParentIssueID int
|
||||
EstimatedHours *float64
|
||||
DoneRatio int
|
||||
DueDate string
|
||||
Notes string // journal note; one per call
|
||||
}
|
||||
|
||||
type issueWire struct {
|
||||
Project string `json:"project_id,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
TrackerID int `json:"tracker_id,omitempty"`
|
||||
StatusID int `json:"status_id,omitempty"`
|
||||
PriorityID int `json:"priority_id,omitempty"`
|
||||
CategoryID int `json:"category_id,omitempty"`
|
||||
FixedVersionID int `json:"fixed_version_id,omitempty"`
|
||||
ParentIssueID int `json:"parent_issue_id,omitempty"`
|
||||
EstimatedHours *float64 `json:"estimated_hours,omitempty"`
|
||||
DoneRatio int `json:"done_ratio,omitempty"`
|
||||
DueDate string `json:"due_date,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
func (p IssueParams) wire() issueWire {
|
||||
return issueWire{
|
||||
Project: p.Project,
|
||||
Subject: p.Subject,
|
||||
Description: p.Description,
|
||||
TrackerID: p.TrackerID,
|
||||
StatusID: p.StatusID,
|
||||
PriorityID: p.PriorityID,
|
||||
CategoryID: p.CategoryID,
|
||||
FixedVersionID: p.FixedVersionID,
|
||||
ParentIssueID: p.ParentIssueID,
|
||||
EstimatedHours: p.EstimatedHours,
|
||||
DoneRatio: p.DoneRatio,
|
||||
DueDate: p.DueDate,
|
||||
Notes: p.Notes,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateIssue creates an issue and returns the stored READ shape.
|
||||
func (c *Client) CreateIssue(ctx context.Context, p IssueParams) (*Issue, error) {
|
||||
var body struct {
|
||||
Issue Issue `json:"issue"`
|
||||
}
|
||||
payload := map[string]any{"issue": p.wire()}
|
||||
if err := c.do(ctx, "POST", "/issues.json", nil, payload, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &body.Issue, nil
|
||||
}
|
||||
|
||||
// UpdateIssue partially updates an issue (PUT /issues/N.json -> 204).
|
||||
func (c *Client) UpdateIssue(ctx context.Context, id int, p IssueParams) error {
|
||||
payload := map[string]any{"issue": p.wire()}
|
||||
return c.do(ctx, "PUT", "/issues/"+strconv.Itoa(id)+".json", nil, payload, nil)
|
||||
}
|
||||
Reference in New Issue
Block a user