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:
2026-08-29 06:35:20 -05:00
parent a3c83bd464
commit 20dbcf604d
6 changed files with 1618 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
// Package redmine is a stdlib-only client for the Redmine REST API
// (JSON). It exists so the PMO, the harness, and every vertical talk to
// Redmine through one Go library instead of python glue: issue
// create/update/notes, versions, categories, relations, and name-to-id
// resolution over the enumeration endpoints.
//
// Security discipline: the API key travels ONLY in the X-Redmine-API-Key
// header. Response bodies are never surfaced in error strings (a server
// echo is assumed to be able to carry the key), so errors are one-line,
// parseable "sentinel: http NNN" shapes.
package redmine
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Sentinel errors. Wrap-check with errors.Is; every API failure maps to
// exactly one of these plus an "http NNN" code in the message.
var (
ErrUnreachable = errors.New("redmine: unreachable")
ErrMalformedResponse = errors.New("redmine: malformed response")
ErrAuth = errors.New("redmine: auth failed")
ErrNotFound = errors.New("redmine: not found")
ErrValidation = errors.New("redmine: validation failed")
ErrServer = errors.New("redmine: server error")
)
// Config configures a Client.
type Config struct {
BaseURL string // e.g. https://projects.knownelement.com (no trailing slash needed)
APIKey string // Redmine API key; header-only, never logged
Timeout time.Duration
}
// Client is a Redmine REST client. Safe for concurrent use.
type Client struct {
cfg Config
http *http.Client
}
// New builds a client. A zero Timeout means 30s.
func New(cfg Config) *Client {
if cfg.Timeout == 0 {
cfg.Timeout = 30 * time.Second
}
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
return &Client{cfg: cfg, http: &http.Client{Timeout: cfg.Timeout}}
}
// do performs one JSON exchange. out may be nil for empty bodies (204).
func (c *Client) do(ctx context.Context, method, path string, query url.Values, in any, out any) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return fmt.Errorf("%w: cannot encode request", ErrMalformedResponse)
}
body = strings.NewReader(string(b))
}
u := c.cfg.BaseURL + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, u, body)
if err != nil {
return fmt.Errorf("%w: bad endpoint", ErrUnreachable)
}
req.Header.Set("X-Redmine-API-Key", c.cfg.APIKey) // key lives here and only here
req.Header.Set("Accept", "application/json")
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
// Transport errors embed URLs and peer text; drop them all.
return ErrUnreachable
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return ErrUnreachable
}
return handleResponse(resp.StatusCode, raw, out)
}
// handleResponse maps one HTTP exchange to typed errors. Response BODIES
// are never surfaced: a server echo is assumed to be able to contain the
// API key (see the fakeredmine package, which deliberately echoes it).
func handleResponse(status int, body []byte, out any) error {
switch {
case status >= 200 && status < 300:
if out == nil {
return nil
}
if len(body) == 0 {
return fmt.Errorf("%w: empty body", ErrMalformedResponse)
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("%w: body is not valid json", ErrMalformedResponse)
}
return nil
default:
return statusError(status)
}
}
// statusError maps a non-2xx status to a sentinel + parseable one-liner.
func statusError(status int) error {
switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden:
return fmt.Errorf("%w: http %d", ErrAuth, status)
case status == http.StatusNotFound:
return fmt.Errorf("%w: http %d", ErrNotFound, status)
case status == http.StatusUnprocessableEntity || status == http.StatusBadRequest || status == http.StatusConflict:
return fmt.Errorf("%w: http %d", ErrValidation, status)
case status >= 500:
return fmt.Errorf("%w: http %d", ErrServer, status)
default:
return fmt.Errorf("%w: http %d", ErrServer, status)
}
}
+122
View File
@@ -0,0 +1,122 @@
package redmine
import (
"context"
"fmt"
"strings"
)
// enumeration endpoints: trackers, statuses, priorities. The CLI maps
// human names ("done", "feature", "immediate") onto ids through these;
// the harness gets the same surface so there is no copy-paste temptation.
type trackerList struct {
Trackers []Ref `json:"trackers"`
}
type statusList struct {
Statuses []StatusRef `json:"issue_statuses"`
}
type priorityList struct {
Priorities []Ref `json:"issue_priorities"`
}
// ListTrackers returns the server's issue trackers.
func (c *Client) ListTrackers(ctx context.Context) ([]Ref, error) {
var body trackerList
if err := c.do(ctx, "GET", "/trackers.json", nil, nil, &body); err != nil {
return nil, err
}
return body.Trackers, nil
}
// ListStatuses returns the server's issue statuses (with is_closed).
func (c *Client) ListStatuses(ctx context.Context) ([]StatusRef, error) {
var body statusList
if err := c.do(ctx, "GET", "/issue_statuses.json", nil, nil, &body); err != nil {
return nil, err
}
return body.Statuses, nil
}
// ListPriorities returns the server's issue priorities.
func (c *Client) ListPriorities(ctx context.Context) ([]Ref, error) {
var body priorityList
if err := c.do(ctx, "GET", "/enumerations/issue_priorities.json", nil, nil, &body); err != nil {
return nil, err
}
return body.Priorities, nil
}
// StatusIDByName resolves a status name case-insensitively ("done" ->
// Done's id). Unknown names are ErrNotFound; the name, never any server
// body, appears in the message.
func (c *Client) StatusIDByName(ctx context.Context, name string) (int, error) {
list, err := c.ListStatuses(ctx)
if err != nil {
return 0, err
}
for _, s := range list {
if strings.EqualFold(s.Name, name) {
return s.ID, nil
}
}
return 0, fmt.Errorf("%w: status %q", ErrNotFound, name)
}
// TrackerIDByName resolves a tracker name case-insensitively.
func (c *Client) TrackerIDByName(ctx context.Context, name string) (int, error) {
list, err := c.ListTrackers(ctx)
if err != nil {
return 0, err
}
for _, t := range list {
if strings.EqualFold(t.Name, name) {
return t.ID, nil
}
}
return 0, fmt.Errorf("%w: tracker %q", ErrNotFound, name)
}
// PriorityIDByName resolves a priority name case-insensitively.
func (c *Client) PriorityIDByName(ctx context.Context, name string) (int, error) {
list, err := c.ListPriorities(ctx)
if err != nil {
return 0, err
}
for _, p := range list {
if strings.EqualFold(p.Name, name) {
return p.ID, nil
}
}
return 0, fmt.Errorf("%w: priority %q", ErrNotFound, name)
}
// VersionIDByName resolves a version name within a project,
// case-insensitively.
func (c *Client) VersionIDByName(ctx context.Context, project, name string) (int, error) {
list, err := c.ListVersions(ctx, project)
if err != nil {
return 0, err
}
for _, v := range list {
if strings.EqualFold(v.Name, name) {
return v.ID, nil
}
}
return 0, fmt.Errorf("%w: version %q in %s", ErrNotFound, name, project)
}
// CategoryIDByName resolves a category name within a project,
// case-insensitively.
func (c *Client) CategoryIDByName(ctx context.Context, project, name string) (int, error) {
list, err := c.ListCategories(ctx, project)
if err != nil {
return 0, err
}
for _, cat := range list {
if strings.EqualFold(cat.Name, name) {
return cat.ID, nil
}
}
return 0, fmt.Errorf("%w: category %q in %s", ErrNotFound, name, project)
}
+201
View File
@@ -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)
}
+137
View File
@@ -0,0 +1,137 @@
package redmine
import (
"context"
"fmt"
"net/url"
"strconv"
)
// Version is a roadmap milestone ("target version") of a project.
type Version struct {
ID int `json:"id"`
Name string `json:"name"`
DueDate string `json:"due_date,omitempty"`
Status string `json:"status"` // open|locked|closed
Sharing string `json:"sharing,omitempty"`
}
// VersionParams is the write shape for CreateVersion.
type VersionParams struct {
Name string
DueDate string
Status string // "" defaults to open on the server
Sharing string // "" omits; "descendants" mirrors the PMO roadmap scripts
}
// ListVersions lists the versions of a project (by identifier or id).
func (c *Client) ListVersions(ctx context.Context, project string) ([]Version, error) {
var body struct {
Versions []Version `json:"versions"`
}
if err := c.do(ctx, "GET", "/projects/"+esc(project)+"/versions.json", nil, nil, &body); err != nil {
return nil, err
}
return body.Versions, nil
}
// CreateVersion creates a roadmap milestone in a project.
func (c *Client) CreateVersion(ctx context.Context, project string, p VersionParams) (*Version, error) {
if p.Name == "" {
return nil, fmt.Errorf("%w: version name required", ErrValidation)
}
payload := map[string]any{"version": map[string]any{
"name": p.Name,
"due_date": optStr(p.DueDate),
"status": optStr(p.Status),
"sharing": optStr(p.Sharing),
}}
var body struct {
Version Version `json:"version"`
}
if err := c.do(ctx, "POST", "/projects/"+esc(project)+"/versions.json", nil, payload, &body); err != nil {
return nil, err
}
return &body.Version, nil
}
// Category is an issue category within a project.
type Category struct {
ID int `json:"id"`
Name string `json:"name"`
}
// ListCategories lists the issue categories of a project.
func (c *Client) ListCategories(ctx context.Context, project string) ([]Category, error) {
var body struct {
Categories []Category `json:"issue_categories"`
}
if err := c.do(ctx, "GET", "/projects/"+esc(project)+"/issue_categories.json", nil, nil, &body); err != nil {
return nil, err
}
return body.Categories, nil
}
// CreateCategory creates an issue category in a project.
func (c *Client) CreateCategory(ctx context.Context, project, name string) (*Category, error) {
if name == "" {
return nil, fmt.Errorf("%w: category name required", ErrValidation)
}
payload := map[string]any{"issue_category": map[string]any{"name": name}}
var body struct {
Category Category `json:"issue_category"`
}
if err := c.do(ctx, "POST", "/projects/"+esc(project)+"/issue_categories.json", nil, payload, &body); err != nil {
return nil, err
}
return &body.Category, nil
}
// Relation ties two issues together.
type Relation struct {
ID int `json:"id"`
IssueID int `json:"issue_id"`
IssueToID int `json:"issue_to_id"`
RelationType string `json:"relation_type"`
Delay int `json:"delay,omitempty"`
}
// ValidRelationType reports whether t is a Redmine relation type.
func ValidRelationType(t string) bool {
switch t {
case "relates", "duplicates", "duplicated", "blocks", "blocked", "precedes", "follows", "copied_to", "copied_from":
return true
}
return false
}
// CreateRelation creates issue_from -> issue_to of the given type
// ("blocks", "relates", ...).
func (c *Client) CreateRelation(ctx context.Context, from, to int, relationType string) (*Relation, error) {
if !ValidRelationType(relationType) {
return nil, fmt.Errorf("%w: relation type %q not allowed", ErrValidation, relationType)
}
payload := map[string]any{"relation": map[string]any{
"issue_to_id": to,
"relation_type": relationType,
}}
var body struct {
Relation Relation `json:"relation"`
}
path := "/issues/" + strconv.Itoa(from) + "/relations.json"
if err := c.do(ctx, "POST", path, nil, payload, &body); err != nil {
return nil, err
}
return &body.Relation, nil
}
// esc path-escapes a project identifier or id string.
func esc(s string) string { return url.PathEscape(s) }
// optStr maps "" to nil for omittable JSON string fields.
func optStr(s string) any {
if s == "" {
return nil
}
return s
}