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) }