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,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
|
||||
}
|
||||
Reference in New Issue
Block a user