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 }