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).
202 lines
6.0 KiB
Go
202 lines
6.0 KiB
Go
package redmine
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
)
|
|
|
|
// Status filter values for IssueFilter.Status.
|
|
const (
|
|
StatusOpen = "open" // Redmine default: open issues only
|
|
StatusClosed = "closed"
|
|
StatusAll = "*" // every issue regardless of status
|
|
)
|
|
|
|
// Ref is a nested {id, name} object in Redmine's read shape.
|
|
type Ref struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// String renders "Name (#ID)" or "#ID" when unnamed.
|
|
func (r Ref) String() string {
|
|
if r.Name != "" {
|
|
return fmt.Sprintf("%s (#%d)", r.Name, r.ID)
|
|
}
|
|
return fmt.Sprintf("#%d", r.ID)
|
|
}
|
|
|
|
// StatusRef adds the is_closed flag.
|
|
type StatusRef struct {
|
|
Ref
|
|
IsClosed bool `json:"is_closed"`
|
|
}
|
|
|
|
// Journal is one history entry (a note, with its author and timestamp).
|
|
type Journal struct {
|
|
ID int `json:"id"`
|
|
UserID int `json:"user_id"`
|
|
Notes string `json:"notes"`
|
|
CreatedOn string `json:"created_on"`
|
|
}
|
|
|
|
// Issue is Redmine's READ shape: nested objects for project, tracker,
|
|
// status, priority, category, fixed_version, parent.
|
|
type Issue struct {
|
|
ID int `json:"id"`
|
|
Subject string `json:"subject"`
|
|
Description string `json:"description"`
|
|
Project Ref `json:"project"`
|
|
Tracker Ref `json:"tracker"`
|
|
Status StatusRef `json:"status"`
|
|
Priority Ref `json:"priority"`
|
|
Category Ref `json:"category"`
|
|
FixedVersion Ref `json:"fixed_version"`
|
|
Parent Ref `json:"parent"`
|
|
AssignedTo Ref `json:"assigned_to"`
|
|
EstimatedHours *float64 `json:"estimated_hours"`
|
|
DoneRatio int `json:"done_ratio"`
|
|
DueDate string `json:"due_date"`
|
|
CreatedOn string `json:"created_on"`
|
|
UpdatedOn string `json:"updated_on"`
|
|
Journals []Journal `json:"journals"`
|
|
}
|
|
|
|
// IssueFilter narrows GET /issues.json. Zero fields list open issues
|
|
// across all projects, Redmine's own default behavior.
|
|
type IssueFilter struct {
|
|
Project string // project identifier (e.g. "MOPAC"); empty = all
|
|
Status string // "open" (default), "closed", "*", or a numeric id
|
|
FixedVersion int // version id; 0 = no filter
|
|
Limit int // page size; 0 = Redmine default (25)
|
|
Offset int
|
|
}
|
|
|
|
func (f IssueFilter) query() url.Values {
|
|
q := url.Values{}
|
|
if f.Project != "" {
|
|
q.Set("project_id", f.Project)
|
|
}
|
|
switch {
|
|
case f.Status == "":
|
|
q.Set("status_id", StatusOpen)
|
|
case f.Status == StatusOpen || f.Status == StatusClosed || f.Status == StatusAll:
|
|
q.Set("status_id", f.Status)
|
|
default:
|
|
if _, err := strconv.Atoi(f.Status); err == nil {
|
|
q.Set("status_id", f.Status)
|
|
}
|
|
}
|
|
if f.FixedVersion != 0 {
|
|
q.Set("fixed_version_id", strconv.Itoa(f.FixedVersion))
|
|
}
|
|
if f.Limit != 0 {
|
|
q.Set("limit", strconv.Itoa(f.Limit))
|
|
}
|
|
if f.Offset != 0 {
|
|
q.Set("offset", strconv.Itoa(f.Offset))
|
|
}
|
|
return q
|
|
}
|
|
|
|
// ListIssues returns issues matching the filter plus the server's total
|
|
// count (ignoring limit/offset pagination).
|
|
func (c *Client) ListIssues(ctx context.Context, f IssueFilter) ([]Issue, int, error) {
|
|
var body struct {
|
|
Issues []Issue `json:"issues"`
|
|
Total int `json:"total_count"`
|
|
}
|
|
if err := c.do(ctx, "GET", "/issues.json", f.query(), nil, &body); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return body.Issues, body.Total, nil
|
|
}
|
|
|
|
// GetIssue fetches one issue. withJournals adds ?include=journals.
|
|
func (c *Client) GetIssue(ctx context.Context, id int, withJournals bool) (*Issue, error) {
|
|
q := url.Values{}
|
|
if withJournals {
|
|
q.Set("include", "journals")
|
|
}
|
|
var body struct {
|
|
Issue Issue `json:"issue"`
|
|
}
|
|
if err := c.do(ctx, "GET", "/issues/"+strconv.Itoa(id)+".json", q, nil, &body); err != nil {
|
|
return nil, err
|
|
}
|
|
return &body.Issue, nil
|
|
}
|
|
|
|
// IssueParams is the WRITE shape for create/update. Zero fields are
|
|
// omitted from the payload, so update touches only what you set
|
|
// (partial-update discipline: never clobber untouched attributes).
|
|
type IssueParams struct {
|
|
Project string
|
|
Subject string
|
|
Description string
|
|
TrackerID int
|
|
StatusID int
|
|
PriorityID int
|
|
CategoryID int
|
|
FixedVersionID int
|
|
ParentIssueID int
|
|
EstimatedHours *float64
|
|
DoneRatio int
|
|
DueDate string
|
|
Notes string // journal note; one per call
|
|
}
|
|
|
|
type issueWire struct {
|
|
Project string `json:"project_id,omitempty"`
|
|
Subject string `json:"subject,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
TrackerID int `json:"tracker_id,omitempty"`
|
|
StatusID int `json:"status_id,omitempty"`
|
|
PriorityID int `json:"priority_id,omitempty"`
|
|
CategoryID int `json:"category_id,omitempty"`
|
|
FixedVersionID int `json:"fixed_version_id,omitempty"`
|
|
ParentIssueID int `json:"parent_issue_id,omitempty"`
|
|
EstimatedHours *float64 `json:"estimated_hours,omitempty"`
|
|
DoneRatio int `json:"done_ratio,omitempty"`
|
|
DueDate string `json:"due_date,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
}
|
|
|
|
func (p IssueParams) wire() issueWire {
|
|
return issueWire{
|
|
Project: p.Project,
|
|
Subject: p.Subject,
|
|
Description: p.Description,
|
|
TrackerID: p.TrackerID,
|
|
StatusID: p.StatusID,
|
|
PriorityID: p.PriorityID,
|
|
CategoryID: p.CategoryID,
|
|
FixedVersionID: p.FixedVersionID,
|
|
ParentIssueID: p.ParentIssueID,
|
|
EstimatedHours: p.EstimatedHours,
|
|
DoneRatio: p.DoneRatio,
|
|
DueDate: p.DueDate,
|
|
Notes: p.Notes,
|
|
}
|
|
}
|
|
|
|
// CreateIssue creates an issue and returns the stored READ shape.
|
|
func (c *Client) CreateIssue(ctx context.Context, p IssueParams) (*Issue, error) {
|
|
var body struct {
|
|
Issue Issue `json:"issue"`
|
|
}
|
|
payload := map[string]any{"issue": p.wire()}
|
|
if err := c.do(ctx, "POST", "/issues.json", nil, payload, &body); err != nil {
|
|
return nil, err
|
|
}
|
|
return &body.Issue, nil
|
|
}
|
|
|
|
// UpdateIssue partially updates an issue (PUT /issues/N.json -> 204).
|
|
func (c *Client) UpdateIssue(ctx context.Context, id int, p IssueParams) error {
|
|
payload := map[string]any{"issue": p.wire()}
|
|
return c.do(ctx, "PUT", "/issues/"+strconv.Itoa(id)+".json", nil, payload, nil)
|
|
}
|