Files
mrcharles 26824c3715 feat(redmine): partial UpdateVersion and GetVersion with fake endpoints
UpdateVersion issues PUT /versions/N.json sending only the fields the
caller set (untouched attributes never travel), and never parses the
empty 204 body Redmine answers with. GetVersion backs re-fetching one
milestone. The fake serves both endpoints with Redmine's semantics
(partial apply, 204 empty, 404 for unknown ids), and table tests pin
the exact request bodies, partiality, and error mapping.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-29 08:12:16 -05:00

174 lines
5.2 KiB
Go

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/UpdateVersion. On
// update, zero fields are omitted so only what you set travels.
type VersionParams struct {
Name string
DueDate string
Status string // "" defaults to open on create; omitted on update
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
}
// UpdateVersion partially updates a version (PUT /versions/N.json).
// The server answers 204 with an EMPTY body — it is never parsed. Only
// the fields set in p are sent; untouched attributes are not clobbered.
func (c *Client) UpdateVersion(ctx context.Context, id int, p VersionParams) error {
fields := map[string]any{}
if p.Name != "" {
fields["name"] = p.Name
}
if p.DueDate != "" {
fields["due_date"] = p.DueDate
}
if p.Status != "" {
fields["status"] = p.Status
}
if p.Sharing != "" {
fields["sharing"] = p.Sharing
}
if len(fields) == 0 {
return fmt.Errorf("%w: nothing to update", ErrValidation)
}
payload := map[string]any{"version": fields}
return c.do(ctx, "PUT", "/versions/"+strconv.Itoa(id)+".json", nil, payload, nil)
}
// GetVersion fetches one version by id.
func (c *Client) GetVersion(ctx context.Context, id int) (*Version, error) {
var body struct {
Version Version `json:"version"`
}
if err := c.do(ctx, "GET", "/versions/"+strconv.Itoa(id)+".json", nil, nil, &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
}