Files
mrcharles 20dbcf604d 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).
2026-08-29 06:35:20 -05:00

202 lines
6.0 KiB
Go

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)
}