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,586 @@
|
|||||||
|
// Package fakeredmine is a stateful in-memory Redmine REST fake used by
|
||||||
|
// the test suite and the smoke run. The real tracker is NEVER contacted.
|
||||||
|
// It implements exactly the endpoints the redmine client uses, enforces
|
||||||
|
// X-Redmine-API-Key auth on every one of them, and records every request
|
||||||
|
// so tests can assert round-trips. Error responses deliberately echo the
|
||||||
|
// presented key back in the body: any test that survives that proves the
|
||||||
|
// client never surfaces response bodies (key-redaction guarantee).
|
||||||
|
package fakeredmine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request is one recorded exchange (auth checked, body captured).
|
||||||
|
type Request struct {
|
||||||
|
Method string
|
||||||
|
Path string // path without query
|
||||||
|
Query string // raw query
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version is the roadmap-milestone shape.
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category is the issue-category shape.
|
||||||
|
type Category struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JournalEntry is one history note.
|
||||||
|
type JournalEntry struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
UserID int `json:"user_id"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
CreatedOn string `json:"created_on"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue is the stored issue (server side, write-friendly scalars).
|
||||||
|
type Issue struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
TrackerID int `json:"tracker_id"`
|
||||||
|
StatusID int `json:"status_id"`
|
||||||
|
PriorityID int `json:"priority_id"`
|
||||||
|
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"` // write-only: create/update note -> journal
|
||||||
|
CreatedOn string `json:"created_on"`
|
||||||
|
UpdatedOn string `json:"updated_on"`
|
||||||
|
Journals []JournalEntry `json:"journals,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relation is the issue-relation shape.
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server is the fake Redmine.
|
||||||
|
type Server struct {
|
||||||
|
URL string
|
||||||
|
APIKey string
|
||||||
|
apiKeySet bool
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
srv *httptest.Server
|
||||||
|
requests []Request
|
||||||
|
issues map[int]*Issue
|
||||||
|
versions map[int]Version
|
||||||
|
categories map[int]Category
|
||||||
|
relations map[int]Relation
|
||||||
|
nextID int
|
||||||
|
// Fail, when non-nil, is returned instead of normal handling; it is
|
||||||
|
// consumed by the first request that sees it.
|
||||||
|
Fail *FailSpec
|
||||||
|
|
||||||
|
Trackers []IDName
|
||||||
|
Statuses []Status
|
||||||
|
Priorities []IDName
|
||||||
|
}
|
||||||
|
|
||||||
|
// FailSpec pins the next response (error-mapping tests).
|
||||||
|
type FailSpec struct {
|
||||||
|
Status int
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDName is a bare enumeration entry.
|
||||||
|
type IDName struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status adds the is_closed flag to an enumeration entry.
|
||||||
|
type Status struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
IsClosed bool `json:"is_closed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// New starts a fake on a random port with Redmine's default
|
||||||
|
// enumerations and an empty MOPAC project.
|
||||||
|
func New(apiKey string) *Server {
|
||||||
|
s := &Server{
|
||||||
|
APIKey: apiKey,
|
||||||
|
apiKeySet: true,
|
||||||
|
issues: map[int]*Issue{},
|
||||||
|
versions: map[int]Version{},
|
||||||
|
categories: map[int]Category{},
|
||||||
|
relations: map[int]Relation{},
|
||||||
|
nextID: 100,
|
||||||
|
Trackers: []IDName{
|
||||||
|
{1, "Bug"}, {2, "Feature"}, {3, "Support"}, {4, "Task"},
|
||||||
|
},
|
||||||
|
Statuses: []Status{
|
||||||
|
{1, "New", false}, {2, "In Progress", false},
|
||||||
|
{3, "Done", true}, {4, "Rejected", true},
|
||||||
|
},
|
||||||
|
Priorities: []IDName{
|
||||||
|
{1, "Low"}, {2, "Normal"}, {3, "High"}, {4, "Urgent"}, {5, "Immediate"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/", s.handler)
|
||||||
|
s.srv = httptest.NewServer(mux)
|
||||||
|
s.URL = s.srv.URL
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close shuts the fake down.
|
||||||
|
func (s *Server) Close() { s.srv.Close() }
|
||||||
|
|
||||||
|
// Requests returns a copy of the recorded exchanges.
|
||||||
|
func (s *Server) Requests() []Request {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := make([]Request, len(s.requests))
|
||||||
|
copy(out, s.requests)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddIssue seeds an issue directly (bypassing REST) and returns its id.
|
||||||
|
func (s *Server) AddIssue(i Issue) int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.nextID++
|
||||||
|
i.ID = s.nextID
|
||||||
|
now := "2026-08-29T12:00:00Z"
|
||||||
|
if i.CreatedOn == "" {
|
||||||
|
i.CreatedOn = now
|
||||||
|
}
|
||||||
|
if i.UpdatedOn == "" {
|
||||||
|
i.UpdatedOn = now
|
||||||
|
}
|
||||||
|
s.issues[i.ID] = &i
|
||||||
|
return i.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue returns a deep-ish copy of a stored issue (for assertions).
|
||||||
|
func (s *Server) Issue(id int) (Issue, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
i, ok := s.issues[id]
|
||||||
|
if !ok {
|
||||||
|
return Issue{}, false
|
||||||
|
}
|
||||||
|
return *i, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddVersion / AddCategory seed enumerations directly.
|
||||||
|
func (s *Server) AddVersion(v Version) int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.nextID++
|
||||||
|
v.ID = s.nextID
|
||||||
|
s.versions[v.ID] = v
|
||||||
|
return v.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) AddCategory(name string) int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.nextID++
|
||||||
|
s.categories[s.nextID] = Category{ID: s.nextID, Name: name}
|
||||||
|
return s.nextID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
key := ""
|
||||||
|
if s.apiKeySet {
|
||||||
|
key = r.Header.Get("X-Redmine-API-Key")
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.Fail != nil {
|
||||||
|
spec := *s.Fail
|
||||||
|
s.Fail = nil
|
||||||
|
s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, string(body)})
|
||||||
|
s.mu.Unlock()
|
||||||
|
// The body deliberately embeds the presented key: surviving this
|
||||||
|
// proves the client drops response bodies from error strings.
|
||||||
|
w.WriteHeader(spec.Status)
|
||||||
|
fmt.Fprintf(w, spec.Body, key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.apiKeySet && key != s.APIKey {
|
||||||
|
s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, ""})
|
||||||
|
s.mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
fmt.Fprintf(w, `{"errors":["Invalid HTTP auth: key %s was rejected"]}`, key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, string(body)})
|
||||||
|
|
||||||
|
var (
|
||||||
|
respStatus = http.StatusOK
|
||||||
|
respBody string
|
||||||
|
)
|
||||||
|
path := r.URL.Path
|
||||||
|
q := r.URL.Query()
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && path == "/issues.json":
|
||||||
|
respStatus, respBody = s.listIssues(q)
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/issues/") && strings.HasSuffix(path, ".json"):
|
||||||
|
id, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(path, "/issues/"), ".json"))
|
||||||
|
respStatus, respBody = s.getIssue(id, q.Get("include"))
|
||||||
|
case r.Method == http.MethodPost && path == "/issues.json":
|
||||||
|
respStatus, respBody = s.createIssue(body)
|
||||||
|
case r.Method == http.MethodPut && strings.HasPrefix(path, "/issues/") && strings.HasSuffix(path, ".json"):
|
||||||
|
id, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(path, "/issues/"), ".json"))
|
||||||
|
respStatus, respBody = s.updateIssue(id, body)
|
||||||
|
case r.Method == http.MethodPost && strings.HasSuffix(path, "/relations.json"):
|
||||||
|
from, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(path, "/issues/"), "/relations.json"))
|
||||||
|
respStatus, respBody = s.createRelation(from, body)
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/projects/") && strings.HasSuffix(path, "/versions.json"):
|
||||||
|
respStatus, respBody = s.listVersions()
|
||||||
|
case r.Method == http.MethodPost && strings.HasPrefix(path, "/projects/") && strings.HasSuffix(path, "/versions.json"):
|
||||||
|
respStatus, respBody = s.createVersion(body)
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/projects/") && strings.HasSuffix(path, "/issue_categories.json"):
|
||||||
|
respStatus, respBody = s.listCategories()
|
||||||
|
case r.Method == http.MethodPost && strings.HasPrefix(path, "/projects/") && strings.HasSuffix(path, "/issue_categories.json"):
|
||||||
|
respStatus, respBody = s.createCategory(body)
|
||||||
|
case r.Method == http.MethodGet && path == "/trackers.json":
|
||||||
|
respStatus, respBody = jsonReply(map[string]any{"trackers": s.Trackers})
|
||||||
|
case r.Method == http.MethodGet && path == "/issue_statuses.json":
|
||||||
|
respStatus, respBody = jsonReply(map[string]any{"issue_statuses": s.Statuses})
|
||||||
|
case r.Method == http.MethodGet && path == "/enumerations/issue_priorities.json":
|
||||||
|
respStatus, respBody = jsonReply(map[string]any{"issue_priorities": s.Priorities})
|
||||||
|
default:
|
||||||
|
respStatus, respBody = http.StatusNotFound, `{"errors":["Not found"]}`
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(respStatus)
|
||||||
|
if respBody != "" {
|
||||||
|
io.WriteString(w, respBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonReply(v any) (int, string) {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, `{"errors":["marshal"]}`
|
||||||
|
}
|
||||||
|
return http.StatusOK, string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listIssues(q url.Values) (int, string) {
|
||||||
|
// Redmine defaults to OPEN issues only; status_id=* lifts it.
|
||||||
|
wantStatus := "open"
|
||||||
|
if v := q.Get("status_id"); v != "" {
|
||||||
|
wantStatus = v
|
||||||
|
}
|
||||||
|
var proj, ver string
|
||||||
|
proj = q.Get("project_id")
|
||||||
|
ver = q.Get("fixed_version_id")
|
||||||
|
limit := 25
|
||||||
|
if v := q.Get("limit"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type out struct {
|
||||||
|
Issues []Issue `json:"issues"`
|
||||||
|
Total int `json:"total_count"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
o := out{Limit: limit}
|
||||||
|
for _, i := range s.issues {
|
||||||
|
if proj != "" && i.ProjectID != proj {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if wantStatus == "open" && s.statusIsClosed(i.StatusID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if wantStatus == "closed" && !s.statusIsClosed(i.StatusID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ver != "" && strconv.Itoa(i.FixedVersionID) != ver {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
o.Issues = append(o.Issues, *i)
|
||||||
|
}
|
||||||
|
o.Total = len(o.Issues)
|
||||||
|
if len(o.Issues) > limit {
|
||||||
|
o.Issues = o.Issues[:limit]
|
||||||
|
}
|
||||||
|
return jsonReply(o)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) statusIsClosed(id int) bool {
|
||||||
|
for _, st := range s.Statuses {
|
||||||
|
if st.ID == id {
|
||||||
|
return st.IsClosed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getIssue(id int, include string) (int, string) {
|
||||||
|
i, ok := s.issues[id]
|
||||||
|
if !ok {
|
||||||
|
return http.StatusNotFound, `{"errors":["Issue not found"]}`
|
||||||
|
}
|
||||||
|
out := map[string]any{"issue": issueView(s, *i, include == "journals")}
|
||||||
|
return jsonReply(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createIssue(body []byte) (int, string) {
|
||||||
|
var p struct {
|
||||||
|
Issue Issue `json:"issue"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &p); err != nil {
|
||||||
|
return http.StatusBadRequest, `{"errors":["bad json"]}`
|
||||||
|
}
|
||||||
|
if p.Issue.Subject == "" || p.Issue.ProjectID == "" {
|
||||||
|
return http.StatusUnprocessableEntity, `{"errors":["Subject and project cannot be blank"]}`
|
||||||
|
}
|
||||||
|
s.nextID++
|
||||||
|
i := p.Issue
|
||||||
|
i.ID = s.nextID
|
||||||
|
i.CreatedOn = "2026-08-29T12:00:00Z"
|
||||||
|
i.UpdatedOn = i.CreatedOn
|
||||||
|
if i.Notes != "" { // notes on create become the first journal
|
||||||
|
i.Journals = append(i.Journals, JournalEntry{ID: 1, UserID: 5, Notes: i.Notes, CreatedOn: i.CreatedOn})
|
||||||
|
}
|
||||||
|
s.issues[i.ID] = &i
|
||||||
|
return http.StatusCreated, `{"issue":` + mustJSON(issueView(s, i, false)) + `}`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateIssue(id int, body []byte) (int, string) {
|
||||||
|
i, ok := s.issues[id]
|
||||||
|
if !ok {
|
||||||
|
return http.StatusNotFound, `{"errors":["Issue not found"]}`
|
||||||
|
}
|
||||||
|
var p struct {
|
||||||
|
Issue Issue `json:"issue"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &p); err != nil {
|
||||||
|
return http.StatusBadRequest, `{"errors":["bad json"]}`
|
||||||
|
}
|
||||||
|
if p.Issue.Subject != "" {
|
||||||
|
i.Subject = p.Issue.Subject
|
||||||
|
}
|
||||||
|
if p.Issue.Description != "" {
|
||||||
|
i.Description = p.Issue.Description
|
||||||
|
}
|
||||||
|
if p.Issue.TrackerID != 0 {
|
||||||
|
i.TrackerID = p.Issue.TrackerID
|
||||||
|
}
|
||||||
|
if p.Issue.StatusID != 0 {
|
||||||
|
i.StatusID = p.Issue.StatusID
|
||||||
|
}
|
||||||
|
if p.Issue.PriorityID != 0 {
|
||||||
|
i.PriorityID = p.Issue.PriorityID
|
||||||
|
}
|
||||||
|
if p.Issue.CategoryID != 0 {
|
||||||
|
i.CategoryID = p.Issue.CategoryID
|
||||||
|
}
|
||||||
|
if p.Issue.FixedVersionID != 0 {
|
||||||
|
i.FixedVersionID = p.Issue.FixedVersionID
|
||||||
|
}
|
||||||
|
if p.Issue.EstimatedHours != nil {
|
||||||
|
i.EstimatedHours = p.Issue.EstimatedHours
|
||||||
|
}
|
||||||
|
if p.Issue.DoneRatio != 0 {
|
||||||
|
i.DoneRatio = p.Issue.DoneRatio
|
||||||
|
}
|
||||||
|
if p.Issue.DueDate != "" {
|
||||||
|
i.DueDate = p.Issue.DueDate
|
||||||
|
}
|
||||||
|
if p.Issue.Notes != "" {
|
||||||
|
i.Journals = append(i.Journals, JournalEntry{
|
||||||
|
ID: len(i.Journals) + 1, UserID: 5, Notes: p.Issue.Notes, CreatedOn: "2026-08-29T13:00:00Z",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
i.UpdatedOn = "2026-08-29T13:00:00Z"
|
||||||
|
return http.StatusNoContent, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createRelation(from int, body []byte) (int, string) {
|
||||||
|
var p struct {
|
||||||
|
Relation Relation `json:"relation"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &p); err != nil {
|
||||||
|
return http.StatusBadRequest, `{"errors":["bad json"]}`
|
||||||
|
}
|
||||||
|
if _, ok := s.issues[from]; !ok {
|
||||||
|
return http.StatusNotFound, `{"errors":["Issue not found"]}`
|
||||||
|
}
|
||||||
|
if _, ok := s.issues[p.Relation.IssueToID]; !ok {
|
||||||
|
return http.StatusUnprocessableEntity, `{"errors":["Issue not found"]}`
|
||||||
|
}
|
||||||
|
t := p.Relation.RelationType
|
||||||
|
if t != "blocks" && t != "relates" && t != "precedes" && t != "copied_to" && t != "duplicates" {
|
||||||
|
return http.StatusUnprocessableEntity, `{"errors":["relation_type is not included in the list"]}`
|
||||||
|
}
|
||||||
|
s.nextID++
|
||||||
|
rel := Relation{ID: s.nextID, IssueID: from, IssueToID: p.Relation.IssueToID, RelationType: t}
|
||||||
|
s.relations[rel.ID] = rel
|
||||||
|
return http.StatusCreated, `{"relation":` + mustJSON(rel) + `}`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listVersions() (int, string) {
|
||||||
|
var vs []Version
|
||||||
|
for _, v := range s.versions {
|
||||||
|
vs = append(vs, v)
|
||||||
|
}
|
||||||
|
return jsonReply(map[string]any{"versions": vs})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createVersion(body []byte) (int, string) {
|
||||||
|
var p struct {
|
||||||
|
Version Version `json:"version"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &p); err != nil {
|
||||||
|
return http.StatusBadRequest, `{"errors":["bad json"]}`
|
||||||
|
}
|
||||||
|
if p.Version.Name == "" {
|
||||||
|
return http.StatusUnprocessableEntity, `{"errors":["Name cannot be blank"]}`
|
||||||
|
}
|
||||||
|
for _, v := range s.versions {
|
||||||
|
if v.Name == p.Version.Name {
|
||||||
|
return http.StatusUnprocessableEntity, `{"errors":["Name has already been taken"]}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.nextID++
|
||||||
|
v := p.Version
|
||||||
|
v.ID = s.nextID
|
||||||
|
if v.Status == "" {
|
||||||
|
v.Status = "open"
|
||||||
|
}
|
||||||
|
s.versions[v.ID] = v
|
||||||
|
return http.StatusCreated, `{"version":` + mustJSON(v) + `}`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listCategories() (int, string) {
|
||||||
|
var cs []Category
|
||||||
|
for _, c := range s.categories {
|
||||||
|
cs = append(cs, c)
|
||||||
|
}
|
||||||
|
return jsonReply(map[string]any{"issue_categories": cs})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createCategory(body []byte) (int, string) {
|
||||||
|
var p struct {
|
||||||
|
Category Category `json:"issue_category"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &p); err != nil {
|
||||||
|
return http.StatusBadRequest, `{"errors":["bad json"]}`
|
||||||
|
}
|
||||||
|
if p.Category.Name == "" {
|
||||||
|
return http.StatusUnprocessableEntity, `{"errors":["Name cannot be blank"]}`
|
||||||
|
}
|
||||||
|
for _, c := range s.categories {
|
||||||
|
if c.Name == p.Category.Name {
|
||||||
|
return http.StatusUnprocessableEntity, `{"errors":["Name has already been taken"]}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.nextID++
|
||||||
|
c := Category{ID: s.nextID, Name: p.Category.Name}
|
||||||
|
s.categories[c.ID] = c
|
||||||
|
return http.StatusCreated, `{"issue_category":` + mustJSON(c) + `}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueView projects the stored issue into Redmine's READ shape
|
||||||
|
// (nested objects for project/tracker/status/priority/category/version).
|
||||||
|
func issueView(s *Server, i Issue, withJournals bool) map[string]any {
|
||||||
|
v := map[string]any{
|
||||||
|
"id": i.ID,
|
||||||
|
"subject": i.Subject,
|
||||||
|
"description": i.Description,
|
||||||
|
"created_on": i.CreatedOn,
|
||||||
|
"updated_on": i.UpdatedOn,
|
||||||
|
}
|
||||||
|
v["project"] = map[string]any{"id": 1, "name": i.ProjectID, "identifier": i.ProjectID}
|
||||||
|
if n := enumName(s.Trackers, i.TrackerID); n != "" {
|
||||||
|
v["tracker"] = map[string]any{"id": i.TrackerID, "name": n}
|
||||||
|
}
|
||||||
|
if st := statusByID(s.Statuses, i.StatusID); st != nil {
|
||||||
|
v["status"] = map[string]any{"id": st.ID, "name": st.Name, "is_closed": st.IsClosed}
|
||||||
|
}
|
||||||
|
if n := enumName(s.Priorities, i.PriorityID); n != "" {
|
||||||
|
v["priority"] = map[string]any{"id": i.PriorityID, "name": n}
|
||||||
|
}
|
||||||
|
if i.CategoryID != 0 {
|
||||||
|
if c, ok := s.categories[i.CategoryID]; ok {
|
||||||
|
v["category"] = map[string]any{"id": c.ID, "name": c.Name}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i.FixedVersionID != 0 {
|
||||||
|
if ver, ok := s.versions[i.FixedVersionID]; ok {
|
||||||
|
v["fixed_version"] = map[string]any{"id": ver.ID, "name": ver.Name}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i.ParentIssueID != 0 {
|
||||||
|
v["parent"] = map[string]any{"id": i.ParentIssueID}
|
||||||
|
}
|
||||||
|
if i.EstimatedHours != nil {
|
||||||
|
v["estimated_hours"] = *i.EstimatedHours
|
||||||
|
}
|
||||||
|
if i.DoneRatio != 0 {
|
||||||
|
v["done_ratio"] = i.DoneRatio
|
||||||
|
}
|
||||||
|
if i.DueDate != "" {
|
||||||
|
v["due_date"] = i.DueDate
|
||||||
|
}
|
||||||
|
if withJournals && len(i.Journals) > 0 {
|
||||||
|
v["journals"] = i.Journals
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func enumName(list []IDName, id int) string {
|
||||||
|
for _, e := range list {
|
||||||
|
if e.ID == id {
|
||||||
|
return e.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusByID(list []Status, id int) *Status {
|
||||||
|
for i := range list {
|
||||||
|
if list[i].ID == id {
|
||||||
|
return &list[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustJSON(v any) string {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Package redmine is a stdlib-only client for the Redmine REST API
|
||||||
|
// (JSON). It exists so the PMO, the harness, and every vertical talk to
|
||||||
|
// Redmine through one Go library instead of python glue: issue
|
||||||
|
// create/update/notes, versions, categories, relations, and name-to-id
|
||||||
|
// resolution over the enumeration endpoints.
|
||||||
|
//
|
||||||
|
// Security discipline: the API key travels ONLY in the X-Redmine-API-Key
|
||||||
|
// header. Response bodies are never surfaced in error strings (a server
|
||||||
|
// echo is assumed to be able to carry the key), so errors are one-line,
|
||||||
|
// parseable "sentinel: http NNN" shapes.
|
||||||
|
package redmine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel errors. Wrap-check with errors.Is; every API failure maps to
|
||||||
|
// exactly one of these plus an "http NNN" code in the message.
|
||||||
|
var (
|
||||||
|
ErrUnreachable = errors.New("redmine: unreachable")
|
||||||
|
ErrMalformedResponse = errors.New("redmine: malformed response")
|
||||||
|
ErrAuth = errors.New("redmine: auth failed")
|
||||||
|
ErrNotFound = errors.New("redmine: not found")
|
||||||
|
ErrValidation = errors.New("redmine: validation failed")
|
||||||
|
ErrServer = errors.New("redmine: server error")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config configures a Client.
|
||||||
|
type Config struct {
|
||||||
|
BaseURL string // e.g. https://projects.knownelement.com (no trailing slash needed)
|
||||||
|
APIKey string // Redmine API key; header-only, never logged
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is a Redmine REST client. Safe for concurrent use.
|
||||||
|
type Client struct {
|
||||||
|
cfg Config
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a client. A zero Timeout means 30s.
|
||||||
|
func New(cfg Config) *Client {
|
||||||
|
if cfg.Timeout == 0 {
|
||||||
|
cfg.Timeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
|
||||||
|
return &Client{cfg: cfg, http: &http.Client{Timeout: cfg.Timeout}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// do performs one JSON exchange. out may be nil for empty bodies (204).
|
||||||
|
func (c *Client) do(ctx context.Context, method, path string, query url.Values, in any, out any) error {
|
||||||
|
var body io.Reader
|
||||||
|
if in != nil {
|
||||||
|
b, err := json.Marshal(in)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: cannot encode request", ErrMalformedResponse)
|
||||||
|
}
|
||||||
|
body = strings.NewReader(string(b))
|
||||||
|
}
|
||||||
|
u := c.cfg.BaseURL + path
|
||||||
|
if len(query) > 0 {
|
||||||
|
u += "?" + query.Encode()
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, u, body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: bad endpoint", ErrUnreachable)
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Redmine-API-Key", c.cfg.APIKey) // key lives here and only here
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if in != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// Transport errors embed URLs and peer text; drop them all.
|
||||||
|
return ErrUnreachable
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
if err != nil {
|
||||||
|
return ErrUnreachable
|
||||||
|
}
|
||||||
|
return handleResponse(resp.StatusCode, raw, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResponse maps one HTTP exchange to typed errors. Response BODIES
|
||||||
|
// are never surfaced: a server echo is assumed to be able to contain the
|
||||||
|
// API key (see the fakeredmine package, which deliberately echoes it).
|
||||||
|
func handleResponse(status int, body []byte, out any) error {
|
||||||
|
switch {
|
||||||
|
case status >= 200 && status < 300:
|
||||||
|
if out == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(body) == 0 {
|
||||||
|
return fmt.Errorf("%w: empty body", ErrMalformedResponse)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, out); err != nil {
|
||||||
|
return fmt.Errorf("%w: body is not valid json", ErrMalformedResponse)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return statusError(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusError maps a non-2xx status to a sentinel + parseable one-liner.
|
||||||
|
func statusError(status int) error {
|
||||||
|
switch {
|
||||||
|
case status == http.StatusUnauthorized || status == http.StatusForbidden:
|
||||||
|
return fmt.Errorf("%w: http %d", ErrAuth, status)
|
||||||
|
case status == http.StatusNotFound:
|
||||||
|
return fmt.Errorf("%w: http %d", ErrNotFound, status)
|
||||||
|
case status == http.StatusUnprocessableEntity || status == http.StatusBadRequest || status == http.StatusConflict:
|
||||||
|
return fmt.Errorf("%w: http %d", ErrValidation, status)
|
||||||
|
case status >= 500:
|
||||||
|
return fmt.Errorf("%w: http %d", ErrServer, status)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: http %d", ErrServer, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package redmine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// enumeration endpoints: trackers, statuses, priorities. The CLI maps
|
||||||
|
// human names ("done", "feature", "immediate") onto ids through these;
|
||||||
|
// the harness gets the same surface so there is no copy-paste temptation.
|
||||||
|
|
||||||
|
type trackerList struct {
|
||||||
|
Trackers []Ref `json:"trackers"`
|
||||||
|
}
|
||||||
|
type statusList struct {
|
||||||
|
Statuses []StatusRef `json:"issue_statuses"`
|
||||||
|
}
|
||||||
|
type priorityList struct {
|
||||||
|
Priorities []Ref `json:"issue_priorities"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTrackers returns the server's issue trackers.
|
||||||
|
func (c *Client) ListTrackers(ctx context.Context) ([]Ref, error) {
|
||||||
|
var body trackerList
|
||||||
|
if err := c.do(ctx, "GET", "/trackers.json", nil, nil, &body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return body.Trackers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListStatuses returns the server's issue statuses (with is_closed).
|
||||||
|
func (c *Client) ListStatuses(ctx context.Context) ([]StatusRef, error) {
|
||||||
|
var body statusList
|
||||||
|
if err := c.do(ctx, "GET", "/issue_statuses.json", nil, nil, &body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return body.Statuses, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPriorities returns the server's issue priorities.
|
||||||
|
func (c *Client) ListPriorities(ctx context.Context) ([]Ref, error) {
|
||||||
|
var body priorityList
|
||||||
|
if err := c.do(ctx, "GET", "/enumerations/issue_priorities.json", nil, nil, &body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return body.Priorities, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusIDByName resolves a status name case-insensitively ("done" ->
|
||||||
|
// Done's id). Unknown names are ErrNotFound; the name, never any server
|
||||||
|
// body, appears in the message.
|
||||||
|
func (c *Client) StatusIDByName(ctx context.Context, name string) (int, error) {
|
||||||
|
list, err := c.ListStatuses(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, s := range list {
|
||||||
|
if strings.EqualFold(s.Name, name) {
|
||||||
|
return s.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("%w: status %q", ErrNotFound, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackerIDByName resolves a tracker name case-insensitively.
|
||||||
|
func (c *Client) TrackerIDByName(ctx context.Context, name string) (int, error) {
|
||||||
|
list, err := c.ListTrackers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, t := range list {
|
||||||
|
if strings.EqualFold(t.Name, name) {
|
||||||
|
return t.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("%w: tracker %q", ErrNotFound, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PriorityIDByName resolves a priority name case-insensitively.
|
||||||
|
func (c *Client) PriorityIDByName(ctx context.Context, name string) (int, error) {
|
||||||
|
list, err := c.ListPriorities(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, p := range list {
|
||||||
|
if strings.EqualFold(p.Name, name) {
|
||||||
|
return p.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("%w: priority %q", ErrNotFound, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VersionIDByName resolves a version name within a project,
|
||||||
|
// case-insensitively.
|
||||||
|
func (c *Client) VersionIDByName(ctx context.Context, project, name string) (int, error) {
|
||||||
|
list, err := c.ListVersions(ctx, project)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, v := range list {
|
||||||
|
if strings.EqualFold(v.Name, name) {
|
||||||
|
return v.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("%w: version %q in %s", ErrNotFound, name, project)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoryIDByName resolves a category name within a project,
|
||||||
|
// case-insensitively.
|
||||||
|
func (c *Client) CategoryIDByName(ctx context.Context, project, name string) (int, error) {
|
||||||
|
list, err := c.ListCategories(ctx, project)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, cat := range list {
|
||||||
|
if strings.EqualFold(cat.Name, name) {
|
||||||
|
return cat.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("%w: category %q in %s", ErrNotFound, name, project)
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+442
@@ -0,0 +1,442 @@
|
|||||||
|
package redmine_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.knownelement.com/ukrrs/mopac-redmine-go/internal/fakeredmine"
|
||||||
|
"git.knownelement.com/ukrrs/mopac-redmine-go/redmine"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fakeKey = "fake-redmine-key-0123456789"
|
||||||
|
|
||||||
|
func newClient(t *testing.T) (*redmine.Client, *fakeredmine.Server) {
|
||||||
|
t.Helper()
|
||||||
|
srv := fakeredmine.New(fakeKey)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
c := redmine.New(redmine.Config{BaseURL: srv.URL, APIKey: fakeKey, Timeout: 5 * time.Second})
|
||||||
|
return c, srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func f64(v float64) *float64 { return &v }
|
||||||
|
|
||||||
|
// --- issue create/read round-trip -----------------------------------------
|
||||||
|
|
||||||
|
func TestCreateIssueRoundTrip(t *testing.T) {
|
||||||
|
c, srv := newClient(t)
|
||||||
|
est := 8.0
|
||||||
|
created, err := c.CreateIssue(context.Background(), redmine.IssueParams{
|
||||||
|
Project: "MOPAC",
|
||||||
|
Subject: "Quota: per-identity usage accounting",
|
||||||
|
Description: "## Scope\n- usage accounting per acting identity",
|
||||||
|
TrackerID: 2, // Feature
|
||||||
|
PriorityID: 5, // Immediate
|
||||||
|
CategoryID: srv.AddCategory("Quota & Backpressure"),
|
||||||
|
FixedVersionID: srv.AddVersion(fakeredmine.Version{Name: "Beta", Status: "open"}),
|
||||||
|
ParentIssueID: srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "Parent", StatusID: 1, TrackerID: 4, PriorityID: 2}),
|
||||||
|
EstimatedHours: f64(est),
|
||||||
|
DueDate: "2026-08-31",
|
||||||
|
Notes: "initial note",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateIssue: %v", err)
|
||||||
|
}
|
||||||
|
if created.ID == 0 || created.Subject != "Quota: per-identity usage accounting" {
|
||||||
|
t.Fatalf("created = %+v", created)
|
||||||
|
}
|
||||||
|
if created.Tracker.Name != "Feature" || created.Priority.Name != "Immediate" {
|
||||||
|
t.Errorf("enums not projected: tracker=%+v priority=%+v", created.Tracker, created.Priority)
|
||||||
|
}
|
||||||
|
if created.EstimatedHours == nil || *created.EstimatedHours != 8 {
|
||||||
|
t.Errorf("estimated_hours = %v", created.EstimatedHours)
|
||||||
|
}
|
||||||
|
if created.FixedVersion.Name != "Beta" || created.Category.Name != "Quota & Backpressure" {
|
||||||
|
t.Errorf("category/version not projected: %+v %+v", created.Category, created.FixedVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := c.GetIssue(context.Background(), created.ID, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetIssue: %v", err)
|
||||||
|
}
|
||||||
|
if got.Description != "## Scope\n- usage accounting per acting identity" || got.DueDate != "2026-08-31" {
|
||||||
|
t.Errorf("round-trip mismatch: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The POST body must carry scalar ids (Redmine write shape).
|
||||||
|
var post map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(srv.Requests()[0].Body), &post); err != nil {
|
||||||
|
t.Fatalf("POST body not json: %v", err)
|
||||||
|
}
|
||||||
|
issue := post["issue"].(map[string]any)
|
||||||
|
for _, k := range []string{"project_id", "tracker_id", "priority_id", "category_id", "fixed_version_id", "parent_issue_id", "estimated_hours", "due_date", "notes"} {
|
||||||
|
if _, ok := issue[k]; !ok {
|
||||||
|
t.Errorf("POST body missing %q: %v", k, issue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if issue["project_id"] != "MOPAC" {
|
||||||
|
t.Errorf("project_id = %v, want identifier string", issue["project_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- issue list filters ----------------------------------------------------
|
||||||
|
|
||||||
|
func TestListIssuesFilters(t *testing.T) {
|
||||||
|
c, srv := newClient(t)
|
||||||
|
open := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "open MOPAC", StatusID: 1, TrackerID: 2, PriorityID: 2})
|
||||||
|
done := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "done MOPAC", StatusID: 3, TrackerID: 2, PriorityID: 2})
|
||||||
|
other := srv.AddIssue(fakeredmine.Issue{ProjectID: "OTHER", Subject: "elsewhere", StatusID: 1, TrackerID: 2, PriorityID: 2})
|
||||||
|
_ = done
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filter redmine.IssueFilter
|
||||||
|
wantIDs []int
|
||||||
|
wantQ string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "project default open",
|
||||||
|
filter: redmine.IssueFilter{Project: "MOPAC"},
|
||||||
|
wantIDs: []int{open},
|
||||||
|
wantQ: "project_id=MOPAC&status_id=open",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status all",
|
||||||
|
filter: redmine.IssueFilter{Project: "MOPAC", Status: redmine.StatusAll},
|
||||||
|
wantIDs: []int{open, done},
|
||||||
|
wantQ: "project_id=MOPAC&status_id=%2A",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status closed",
|
||||||
|
filter: redmine.IssueFilter{Project: "MOPAC", Status: "closed"},
|
||||||
|
wantIDs: []int{done},
|
||||||
|
wantQ: "project_id=MOPAC&status_id=closed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no project lists across projects",
|
||||||
|
filter: redmine.IssueFilter{Status: redmine.StatusAll},
|
||||||
|
wantIDs: []int{open, done, other},
|
||||||
|
wantQ: "status_id=%2A",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, total, err := c.ListIssues(context.Background(), tt.filter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListIssues: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != len(tt.wantIDs) || total != len(tt.wantIDs) {
|
||||||
|
t.Fatalf("got %d issues (total %d), want %v", len(got), total, tt.wantIDs)
|
||||||
|
}
|
||||||
|
for i, id := range tt.wantIDs {
|
||||||
|
if got[i].ID != id {
|
||||||
|
t.Errorf("issue[%d] = %d, want %d", i, got[i].ID, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last := srv.Requests()[len(srv.Requests())-1]
|
||||||
|
if last.Query != tt.wantQ {
|
||||||
|
t.Errorf("query = %q, want %q", last.Query, tt.wantQ)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// limit is passed through
|
||||||
|
if _, _, err := c.ListIssues(context.Background(), redmine.IssueFilter{Project: "MOPAC", Limit: 5}); err != nil {
|
||||||
|
t.Fatalf("ListIssues limit: %v", err)
|
||||||
|
}
|
||||||
|
last := srv.Requests()[len(srv.Requests())-1]
|
||||||
|
if !strings.Contains(last.Query, "limit=5") {
|
||||||
|
t.Errorf("query %q missing limit=5", last.Query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- update + note-as-journal ---------------------------------------------
|
||||||
|
|
||||||
|
func TestUpdateIssueNoteBecomesJournal(t *testing.T) {
|
||||||
|
c, srv := newClient(t)
|
||||||
|
id := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "seeded", StatusID: 1, TrackerID: 2, PriorityID: 2})
|
||||||
|
|
||||||
|
if err := c.UpdateIssue(context.Background(), id, redmine.IssueParams{
|
||||||
|
StatusID: 3, // Done
|
||||||
|
DoneRatio: 100,
|
||||||
|
Notes: "REPORT delivered: see inbox",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpdateIssue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT body: notes + status_id only (omitempty zero fields).
|
||||||
|
reqs := srv.Requests()
|
||||||
|
put := reqs[len(reqs)-1]
|
||||||
|
var p struct {
|
||||||
|
Issue struct {
|
||||||
|
StatusID int `json:"status_id"`
|
||||||
|
DoneRatio int `json:"done_ratio"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
} `json:"issue"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(put.Body), &p); err != nil {
|
||||||
|
t.Fatalf("PUT body: %v", err)
|
||||||
|
}
|
||||||
|
if p.Issue.StatusID != 3 || p.Issue.DoneRatio != 100 || p.Issue.Notes != "REPORT delivered: see inbox" {
|
||||||
|
t.Errorf("PUT body = %+v", p.Issue)
|
||||||
|
}
|
||||||
|
if p.Issue.Subject != "" {
|
||||||
|
t.Errorf("PUT body carried subject; partial update must omit untouched fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := c.GetIssue(context.Background(), id, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetIssue: %v", err)
|
||||||
|
}
|
||||||
|
if got.Status.Name != "Done" || got.Status.IsClosed != true {
|
||||||
|
t.Errorf("status = %+v", got.Status)
|
||||||
|
}
|
||||||
|
if len(got.Journals) != 1 || got.Journals[0].Notes != "REPORT delivered: see inbox" {
|
||||||
|
t.Errorf("journals = %+v, want the note recorded", got.Journals)
|
||||||
|
}
|
||||||
|
if got, _ := srv.Issue(id); got.DoneRatio != 100 {
|
||||||
|
t.Errorf("server done_ratio = %d", got.DoneRatio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetIssueWithoutJournalsOmitsThem(t *testing.T) {
|
||||||
|
c, srv := newClient(t)
|
||||||
|
id := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "s", StatusID: 1, TrackerID: 2, PriorityID: 2,
|
||||||
|
Journals: []fakeredmine.JournalEntry{{ID: 1, Notes: "old"}}})
|
||||||
|
got, err := c.GetIssue(context.Background(), id, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetIssue: %v", err)
|
||||||
|
}
|
||||||
|
if len(got.Journals) != 0 {
|
||||||
|
t.Errorf("journals fetched without include: %+v", got.Journals)
|
||||||
|
}
|
||||||
|
last := srv.Requests()[len(srv.Requests())-1]
|
||||||
|
if strings.Contains(last.Query, "include") {
|
||||||
|
t.Errorf("query %q must not request include for plain show", last.Query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- versions and categories ----------------------------------------------
|
||||||
|
|
||||||
|
func TestVersionRoundTrip(t *testing.T) {
|
||||||
|
c, _ := newClient(t)
|
||||||
|
v, err := c.CreateVersion(context.Background(), "MOPAC", redmine.VersionParams{
|
||||||
|
Name: "Beta", DueDate: "2026-08-31", Status: "open", Sharing: "descendants",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateVersion: %v", err)
|
||||||
|
}
|
||||||
|
if v.ID == 0 || v.Status != "open" {
|
||||||
|
t.Fatalf("version = %+v", v)
|
||||||
|
}
|
||||||
|
list, err := c.ListVersions(context.Background(), "MOPAC")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListVersions: %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, got := range list {
|
||||||
|
if got.ID == v.ID && got.Name == "Beta" && got.DueDate == "2026-08-31" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Beta not in list: %+v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCategoryRoundTrip(t *testing.T) {
|
||||||
|
c, _ := newClient(t)
|
||||||
|
cat, err := c.CreateCategory(context.Background(), "MOPAC", "Secrets")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCategory: %v", err)
|
||||||
|
}
|
||||||
|
if cat.ID == 0 || cat.Name != "Secrets" {
|
||||||
|
t.Fatalf("category = %+v", cat)
|
||||||
|
}
|
||||||
|
list, err := c.ListCategories(context.Background(), "MOPAC")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListCategories: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) == 0 || list[0].Name != "Secrets" {
|
||||||
|
t.Errorf("list = %+v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- relations --------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestCreateRelation(t *testing.T) {
|
||||||
|
c, srv := newClient(t)
|
||||||
|
from := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "quota", StatusID: 1, TrackerID: 2, PriorityID: 2})
|
||||||
|
to := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "dispatcher", StatusID: 1, TrackerID: 2, PriorityID: 2})
|
||||||
|
|
||||||
|
rel, err := c.CreateRelation(context.Background(), from, to, "blocks")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateRelation: %v", err)
|
||||||
|
}
|
||||||
|
if rel.IssueID != from || rel.IssueToID != to || rel.RelationType != "blocks" {
|
||||||
|
t.Errorf("relation = %+v", rel)
|
||||||
|
}
|
||||||
|
last := srv.Requests()[len(srv.Requests())-1]
|
||||||
|
if last.Method != "POST" || last.Path != "/issues/"+itoa(from)+"/relations.json" {
|
||||||
|
t.Errorf("request = %+v", last)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Relation struct {
|
||||||
|
IssueToID int `json:"issue_to_id"`
|
||||||
|
RelationType string `json:"relation_type"`
|
||||||
|
} `json:"relation"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(last.Body), &body); err != nil {
|
||||||
|
t.Fatalf("body: %v", err)
|
||||||
|
}
|
||||||
|
if body.Relation.IssueToID != to || body.Relation.RelationType != "blocks" {
|
||||||
|
t.Errorf("body = %+v", body.Relation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateRelationRejectsUnknownType(t *testing.T) {
|
||||||
|
c, srv := newClient(t)
|
||||||
|
a := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "a", StatusID: 1, TrackerID: 2, PriorityID: 2})
|
||||||
|
b := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "b", StatusID: 1, TrackerID: 2, PriorityID: 2})
|
||||||
|
_, err := c.CreateRelation(context.Background(), a, b, "destroys")
|
||||||
|
if !errors.Is(err, redmine.ErrValidation) {
|
||||||
|
t.Fatalf("err = %v, want ErrValidation", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- name resolution ---------------------------------------------------------
|
||||||
|
|
||||||
|
func TestNameResolution(t *testing.T) {
|
||||||
|
c, srv := newClient(t)
|
||||||
|
verID := srv.AddVersion(fakeredmine.Version{Name: "Phase 3 - Integrations", Status: "open"})
|
||||||
|
catID := srv.AddCategory("Quota & Backpressure")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fn func() (int, error)
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"status done", func() (int, error) { return c.StatusIDByName(context.Background(), "done") }, 3},
|
||||||
|
{"status case-insensitive", func() (int, error) { return c.StatusIDByName(context.Background(), "In Progress") }, 2},
|
||||||
|
{"tracker feature", func() (int, error) { return c.TrackerIDByName(context.Background(), "feature") }, 2},
|
||||||
|
{"priority immediate", func() (int, error) { return c.PriorityIDByName(context.Background(), "immediate") }, 5},
|
||||||
|
{"version by name", func() (int, error) { return c.VersionIDByName(context.Background(), "MOPAC", "phase 3 - integrations") }, verID},
|
||||||
|
{"category by name", func() (int, error) { return c.CategoryIDByName(context.Background(), "MOPAC", "quota & backpressure") }, catID},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := tt.fn()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve: %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("id = %d, want %d", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := c.StatusIDByName(context.Background(), "Nonexistent"); !errors.Is(err, redmine.ErrNotFound) {
|
||||||
|
t.Fatalf("unknown status err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- error mapping ------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestErrorMapping(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fail fakeredmine.FailSpec
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"401", fakeredmine.FailSpec{Status: 401, Body: `{"errors":["bad key %s"]}`}, redmine.ErrAuth},
|
||||||
|
{"403", fakeredmine.FailSpec{Status: 403, Body: `{"errors":["forbidden %s"]}`}, redmine.ErrAuth},
|
||||||
|
{"404", fakeredmine.FailSpec{Status: 404, Body: `{"errors":["missing %s"]}`}, redmine.ErrNotFound},
|
||||||
|
{"422", fakeredmine.FailSpec{Status: 422, Body: `{"errors":["Name has already been taken","%s"]}`}, redmine.ErrValidation},
|
||||||
|
{"500", fakeredmine.FailSpec{Status: 500, Body: `boom %s`}, redmine.ErrServer},
|
||||||
|
{"502", fakeredmine.FailSpec{Status: 502, Body: `bad gateway %s`}, redmine.ErrServer},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
c, srv := newClient(t)
|
||||||
|
srv.Fail = &tt.fail
|
||||||
|
_, err := c.GetIssue(context.Background(), 42, false)
|
||||||
|
if !errors.Is(err, tt.want) {
|
||||||
|
t.Fatalf("err = %v, want %v", err, tt.want)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "http "+strings.TrimPrefix(itoa(tt.fail.Status), "")) {
|
||||||
|
// status code must appear for machine parsing
|
||||||
|
t.Fatalf("err = %q, want embedded http status %d", err.Error(), tt.fail.Status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnreachable(t *testing.T) {
|
||||||
|
srv := fakeredmine.New(fakeKey)
|
||||||
|
url := srv.URL
|
||||||
|
srv.Close() // port now dead
|
||||||
|
c := redmine.New(redmine.Config{BaseURL: url, APIKey: fakeKey, Timeout: 2 * time.Second})
|
||||||
|
_, err := c.GetIssue(context.Background(), 1, false)
|
||||||
|
if !errors.Is(err, redmine.ErrUnreachable) {
|
||||||
|
t.Fatalf("err = %v, want ErrUnreachable", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "http") {
|
||||||
|
t.Fatalf("unreachable err should not fake an http status: %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMalformedResponse(t *testing.T) {
|
||||||
|
c, srv := newClient(t)
|
||||||
|
srv.Fail = &fakeredmine.FailSpec{Status: 200, Body: `<<not json>>`}
|
||||||
|
_, err := c.GetIssue(context.Background(), 1, false)
|
||||||
|
if !errors.Is(err, redmine.ErrMalformedResponse) {
|
||||||
|
t.Fatalf("err = %v, want ErrMalformedResponse", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- key redaction ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// The fake embeds the PRESENTED key in every error body (see FailSpec and
|
||||||
|
// the 401 path). If any error string or recorded request ever leaks the
|
||||||
|
// key, these assertions fail. The API key must exist only in the
|
||||||
|
// X-Redmine-API-Key header.
|
||||||
|
func TestKeyNeverLeaks(t *testing.T) {
|
||||||
|
t.Run("wrong key rejected without echo", func(t *testing.T) {
|
||||||
|
srv := fakeredmine.New(fakeKey)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
c := redmine.New(redmine.Config{BaseURL: srv.URL, APIKey: "wrong-key-abcdef", Timeout: 2 * time.Second})
|
||||||
|
_, err := c.GetIssue(context.Background(), 1, false)
|
||||||
|
if !errors.Is(err, redmine.ErrAuth) {
|
||||||
|
t.Fatalf("err = %v, want ErrAuth", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "wrong-key-abcdef") || strings.Contains(err.Error(), fakeKey) {
|
||||||
|
t.Fatalf("error leaks a key: %q", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("error bodies never surfaced", func(t *testing.T) {
|
||||||
|
c, srv := newClient(t)
|
||||||
|
srv.Fail = &fakeredmine.FailSpec{Status: 500, Body: `crashed while holding key %s`}
|
||||||
|
_, err := c.GetIssue(context.Background(), 1, false)
|
||||||
|
if strings.Contains(err.Error(), "crashed") || strings.Contains(err.Error(), fakeKey) {
|
||||||
|
t.Fatalf("err = %q, want sanitized one-liner", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("key travels only in the header", func(t *testing.T) {
|
||||||
|
c, srv := newClient(t)
|
||||||
|
if _, _, err := c.ListIssues(context.Background(), redmine.IssueFilter{Project: "MOPAC"}); err != nil {
|
||||||
|
t.Fatalf("ListIssues: %v", err)
|
||||||
|
}
|
||||||
|
for _, req := range srv.Requests() {
|
||||||
|
if strings.Contains(req.Body, fakeKey) || strings.Contains(req.Query, fakeKey) || strings.Contains(req.Path, fakeKey) {
|
||||||
|
t.Fatalf("key outside header: %+v", req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int) string { return strconv.Itoa(n) }
|
||||||
Reference in New Issue
Block a user