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)
|
||||
}
|
||||
Reference in New Issue
Block a user