UpdateVersion issues PUT /versions/N.json sending only the fields the
caller set (untouched attributes never travel), and never parses the
empty 204 body Redmine answers with. GetVersion backs re-fetching one
milestone. The fake serves both endpoints with Redmine's semantics
(partial apply, 204 empty, 404 for unknown ids), and table tests pin
the exact request bodies, partiality, and error mapping.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
mopac-redmine-go
A 100% Go client library and CLI (mred) for the Redmine REST API.
Replaces the PMO's python glue scripts for every Redmine update: one
static binary, zero third-party modules (stdlib only), machine-parseable
JSON output, and an API key that never appears in flags, logs, or error
strings.
Status: 2026-08-29 — v0 complete and green: issues (list / show /
create / update with notes-as-journals), versions, categories,
relations, name-to-id resolution over server enumerations, 0600
env-file config, -o json on every command, exit codes 0/1/2 with
one-line API-error stderr. Built and tested entirely against a fake
Redmine (unit suite + end-to-end smoke); the live tracker
(projects.knownelement.com) attaches with zero code change via
MRED_URL/MRED_KEY.
Architecture
flowchart LR
subgraph host[Host]
M[mred CLI\nbin/mred] --config 0600 env --> M
S[smoke/smoke.sh]
end
subgraph docker[Digest-pinned Docker builder — ALL dev]
B[go build / vet / test]
F[smoke/fakeredmine\nstateful in-memory Redmine]
end
subgraph wire[Library: package redmine]
C[Client\nX-Redmine-API-Key header only]
end
M --> C
C --> F
C -.-> R[(real Redmine\nprojects.knownelement.com)]
S --> B
S --> F
All compilation, vetting, and testing runs inside the digest-pinned
golang:1.26-bookworm builder (./dev.sh); the host never runs a Go
toolchain. Tests and smoke talk only to the in-process/containerized
fake; the real tracker is never contacted by the test suite.
Quickstart (verified against the fake)
This is the exact flow the smoke run (./dev.sh smoke) executes:
./dev.sh check # build + vet + test inside the Docker builder
./dev.sh smoke # boots fake Redmine on 127.0.0.1:8601, drives bin/mred
# against a real tracker instead:
export MRED_URL=https://projects.knownelement.com
export MRED_KEY=<your API key> # or: --config ~/.config/mred/env (0600)
mred issue list -p MOPAC # open issues
mred issue list -p MOPAC --status all --limit 100
mred issue show 506 --with journals
mred issue create -p MOPAC -s "Subject" --tracker feature \
--priority immediate --category Secrets --version Beta \
--due 2026-08-31 --est 8 --desc - <<'EOF'
## Scope
- body
EOF
mred issue update 506 --status "In Progress" --note "REPORT delivered"
mred relation create 490 492 --type blocks
mred version create -p MOPAC -n "Beta" --due 2026-08-31 --status open
mred category create -p MOPAC -n "Secrets"
Add -o json to any command for machine output. Names (--tracker feature, --priority immediate, --status done, --category,
--version) resolve case-insensitively against the server's own
enumerations.
Command reference
| Command | Effect | Key flags |
|---|---|---|
issue list -p PROJ |
list issues (open by default) | --status open|all|closed|NAME, --version NAME, --limit N, -o json |
issue show ID |
one issue with description | --with journals |
issue create -p PROJ -s SUBJECT |
create; returns the new issue | --desc FILE|-, --tracker, --priority, --category, --version, --due YYYY-MM-DD, --parent ID, --est HOURS, --note TEXT |
issue update ID |
partial update (only set fields are sent) | --status, --priority, --category, --version, --due, --done-ratio 0-100, --desc FILE|-, --note TEXT (journal) |
version list -p PROJ |
roadmap milestones | -o json |
version create -p PROJ -n NAME |
create milestone (sharing=descendants) | --due, --status open|closed |
category list -p PROJ |
issue categories | -o json |
category create -p PROJ -n NAME |
create category | |
relation create FROM TO |
relate two issues | --type blocks|relates |
help |
usage |
Flags may appear before or after positionals (mred issue update 506 --note X and mred relation create 490 492 --type blocks both parse).
Configuration
| Source | Keys | Discipline |
|---|---|---|
| environment | MRED_URL, MRED_KEY |
env wins over file |
--config PATH (also $MRED_CONFIG, default ~/.config/mred/env) |
MRED_URL=..., MRED_KEY=... |
file must be 0600 or stricter; refused before any read; parsed in pure Go (no sourcing/expansion) |
The API key travels only in the X-Redmine-API-Key header. Response
bodies are never surfaced in error strings — the fake Redmine
deliberately echoes the presented key in error bodies, and the test
suite proves nothing leaks (see TestKeyNeverLeaks and the smoke
redaction pass).
Exit codes and stderr
| Code | Meaning | stderr shape |
|---|---|---|
| 0 | ok | — |
| 1 | usage / config error | one line, names the problem (never values) |
| 2 | API error | one line: mred: redmine: <sentinel>: http NNN — parseable |
Sentinels (library): redmine.ErrAuth, ErrNotFound, ErrValidation,
ErrServer, ErrUnreachable, ErrMalformedResponse — all comparable
with errors.Is.
Library surface (what the harness calls)
import "git.knownelement.com/ukrrs/mopac-redmine-go/redmine"
c := redmine.New(redmine.Config{BaseURL: url, APIKey: key})
issues, total, _ := c.ListIssues(ctx, redmine.IssueFilter{Project: "MOPAC"})
issue, _ := c.GetIssue(ctx, 506, true) // + journals
created, _ := c.CreateIssue(ctx, redmine.IssueParams{Project: "MOPAC", Subject: "…"})
_ = c.UpdateIssue(ctx, 506, redmine.IssueParams{StatusID: 3, Notes: "REPORT delivered"})
versions, _ := c.ListVersions(ctx, "MOPAC")
cats, _ := c.ListCategories(ctx, "MOPAC")
rel, _ := c.CreateRelation(ctx, 490, 492, "blocks")
// name resolution shared with the CLI:
id, _ := c.StatusIDByName(ctx, "done") // 3
id, _ = c.VersionIDByName(ctx, "MOPAC", "Beta") // milestone id
id, _ = c.CategoryIDByName(ctx, "MOPAC", "Secrets")
UpdateIssue sends only the fields you set (partial update — untouched
attributes are never clobbered). All methods take a context.Context.
Development
./dev.sh build|vet|test|check|smoke|shell # everything runs in the digest-pinned builder
make build|vet|test|check|smoke # same, Makefile front door
Test suite: table-driven round-trips against the stateful fake
(internal/fakeredmine): create/update/note/relations/versions,
list filters, name resolution, error mapping (401/403/404/422/5xx →
sentinels), unreachable/malformed handling, and key-redaction.
Status
| Area | State |
|---|---|
| Library (issues, versions, categories, relations, enums) | done, green |
| CLI surface (task spec in Redmine #506) | done, green |
| Fake-Redmine test suite | done, green (unit + smoke) |
| Key redaction | done, tested (fake echoes the key; nothing leaks) |
| Dev harness (Docker-only builds) | done (dev.sh/Makefile) |
| Live-tracker verification | reads verified 2026-08-29 (issue list text+json vs projects.knownelement.com, exit 0; live writes await PMO acceptance) |
| Uploads / attachments | not in scope for v0 |
License: AGPLv3 (see LICENSE). Repository:
https://git.knownelement.com/ukrrs/mopac-redmine-go