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 }