diff --git a/README.md b/README.md new file mode 100644 index 0000000..b188373 --- /dev/null +++ b/README.md @@ -0,0 +1,169 @@ +# 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 + +```mermaid +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: + +```sh +./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= # 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: : http NNN` — parseable | + +Sentinels (library): `redmine.ErrAuth`, `ErrNotFound`, `ErrValidation`, +`ErrServer`, `ErrUnreachable`, `ErrMalformedResponse` — all comparable +with `errors.Is`. + +## Library surface (what the harness calls) + +```go +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 + +```sh +./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 | pending first PMO switch (credentials via `~/.creds/redmine.env`) | +| Uploads / attachments | not in scope for v0 | + +License: AGPLv3 (see `LICENSE`). Repository: +https://git.knownelement.com/ukrrs/mopac-redmine-go diff --git a/env.example b/env.example new file mode 100644 index 0000000..c4c0a2b --- /dev/null +++ b/env.example @@ -0,0 +1,5 @@ +# mred connection env file (copy to ~/.config/mred/env and chmod 600) +# Only MRED_URL and MRED_KEY are used; both are required. The file must +# be 0600 or stricter — mred refuses looser files before reading them. +MRED_URL=https://projects.knownelement.com +MRED_KEY=your-redmine-api-key