diff --git a/internal/fakeredmine/fakeredmine.go b/internal/fakeredmine/fakeredmine.go new file mode 100644 index 0000000..cb932ca --- /dev/null +++ b/internal/fakeredmine/fakeredmine.go @@ -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) +} diff --git a/redmine/client.go b/redmine/client.go new file mode 100644 index 0000000..47774d8 --- /dev/null +++ b/redmine/client.go @@ -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) + } +} diff --git a/redmine/enums.go b/redmine/enums.go new file mode 100644 index 0000000..538ad45 --- /dev/null +++ b/redmine/enums.go @@ -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) +} diff --git a/redmine/issues.go b/redmine/issues.go new file mode 100644 index 0000000..ed61ff7 --- /dev/null +++ b/redmine/issues.go @@ -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) +} diff --git a/redmine/projects.go b/redmine/projects.go new file mode 100644 index 0000000..a06cbf5 --- /dev/null +++ b/redmine/projects.go @@ -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 +} diff --git a/redmine_test.go b/redmine_test.go new file mode 100644 index 0000000..c2f2015 --- /dev/null +++ b/redmine_test.go @@ -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: `<>`} + _, 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) }