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