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