The CLI now covers the whole Redmine 507 surface: `search TERM [-page N]` (operators pass through quoted), `messages send -title -to user1,user2 [-group/-email] [-file|-raw]`, plus the two secret helpers `webhook verify SIG` (delivery body on stdin, exit 0 only on a valid HMAC) and `sso verify SSO SIG` (prints the decoded Discourse Connect payload as JSON). The helpers run before client construction so they work with only their secret env set, no instance credentials needed. Usage text lists the new env vars. Part of Redmine 507 (Discourse Go client).
mopac-discourse-go
A 100% Go, stdlib-only client for the Discourse REST API (admin API key auth). One static binary, zero third-party modules, no official SDK — the MOPAC outbound-CLI for Discourse (SPEC-20260829 "supporting cast": Discourse is where briefings, usage reports and agent documentation land). AGPLv3.
Status: 2026-08-29 — v0 complete and green: categories (list/create), topics (create/list/latest/get), posts (create/update/get), current-user identity probe, raw JSON passthrough for everything else, typed error classes, thin CLI. Built and tested entirely against a fake Discourse (no live writes). Live reads verified against community.turnsys.com; category creation 403s with the current key (needs the admin-scoped key) — see "Live verification" below.
What it implements
The wire protocol, in plain REST with stdlib:
| Surface | Wire |
|---|---|
| whoami | GET /session/current.json — cheapest url+key+username check |
| categories list | GET /categories.json |
| categories create | POST /categories.json — name, color, text_color, permissions (group name -> 1/2/3); needs an admin-scoped key |
| topics create | POST /posts.json — first post with title+raw+category; response carries id (first post), topic_id, topic_slug |
| topics list | GET /c/<id>.json, GET /c/<slug>.json, GET /c/<slug>/<id>.json — category topic lists |
| topics latest | GET /latest.json |
| topics get | GET /t/<id>.json |
| posts create | POST /posts.json — topic_id+raw |
| posts update | PUT /posts/<id>.json — {post: {raw, edit_reason}} |
| posts get | GET /posts/<id>.json |
| raw anything | Do(ctx, METHOD, path, body, out) — JSON in, JSON out; unmodeled endpoints never block on a client release |
Auth is the header pair Api-Key + Api-Username on every request.
Library surface (what harness brief calls)
import "git.knownelement.com/ukrrs/mopac-discourse-go"
c, err := discourse.New(baseURL, apiKey, apiUsername) // or NewFromEnv()
cats, _ := c.ListCategories(ctx)
cat, _ := c.FindCategory(ctx, 0, "mopac-briefings") // id OR slug
cat, err := c.CreateCategory(ctx, discourse.CreateCategoryRequest{
Name: "MOPAC Briefings", Color: "3AB54A", TextColor: "FFFFFF",
Permissions: map[string]int{"staff": 3},
}) // ErrForbidden until the admin-scoped key lands
res, _ := c.CreateTopic(ctx, discourse.CreateTopicRequest{
Title: "MOPAC briefing 2026-09-01", Raw: markdown, Category: cat.ID,
})
res.URL(baseURL) // https://forum/t/<slug>/<topic_id>
p, _ := c.CreatePost(ctx, discourse.CreatePostRequest{TopicID: res.TopicID, Raw: "reply"})
_, _ = c.UpdatePost(ctx, p.PostID, "edited", "typo")
Errors classify via errors.Is: ErrForbidden (403 — key lacks scope),
ErrUnauthorized (401), ErrNotFound, ErrRateLimited (429, with
APIError.RetryAfter), ErrServer, ErrUnreachable,
ErrMalformedResponse, ErrInvalidRequest. Every server failure is
also an *APIError carrying method, path, status and the
Discourse-reported reasons.
The key is constructor/env-only, never a flag, never logged:
Client.String() renders base url + acting username, and error strings
carry only paths and status codes (the redaction sweep in the smoke
greps every captured output for the key).
Quickstart (verified 2026-08-29, all against the fake server)
All dev work happens inside a Docker builder (host stays toolchain-free).
./dev.sh check # = go build + go vet + go test, inside golang:1.26-bookworm
Expected output (tail):
ok git.knownelement.com/ukrrs/mopac-discourse-go
End-to-end smoke — builds the CLI, boots the fake Discourse in a
container on 127.0.0.1:8610, drives the real binary from the host
through a 0600 env file (whoami / category list+create / topic create /
topic list by id and slug / post reply / post update / raw passthrough /
typed 404 / redaction sweep / bad-key exit code):
./dev.sh smoke
Expected output (tail):
smoke: OK
Configure
Credentials NEVER arrive via flags or arguments:
mkdir -p ~/.config/discourse-go && umask 077
cp env.example ~/.config/discourse-go/env
# fill in DISCOURSE_URL / DISCOURSE_API_KEY / DISCOURSE_API_USERNAME
Then set -a; . ~/.config/discourse-go/env; set +a before the CLI (or
source it in the service env). For the harness, the key resolves through
its key_ref mechanism (env:/file:/mpk:) exactly like the Redmine key.
CLI reference
discourse-go whoami
discourse-go categories list
discourse-go categories create NAME [-color HEX] [-text-color HEX] [-perm GROUP=LEVEL]...
discourse-go topics list [-category ID] [-slug SLUG] [-latest]
discourse-go topics show ID
discourse-go topics create -title TITLE -category ID [-file PATH | -raw TEXT]
discourse-go posts create -topic ID [-file PATH | -raw TEXT]
discourse-go posts update ID [-file PATH | -raw TEXT] [-reason TEXT]
discourse-go raw METHOD PATH [-data JSON|@file]
Perm levels: 1 = reply/see, 2 = create posts, 3 = full. Output is JSON. Exit codes: 0 ok, 1 usage/config, 2 API/transport error (the message names the class).
Live verification (community.turnsys.com, 2026-08-29)
whoami+categories listwith the current user-scoped key: OK (HTTP 200).categories create: HTTP 403 with the current key — category creation is admin-scoped. The client fully supports it and maps it toErrForbidden; live creation waits for the admin key (Charles's 1900 list). The harness briefing pipeline is unaffected: it posts TOPICS into an existing category, which the current key allows.
License
AGPLv3 (see LICENSE) — maximally viral, per the MOPAC spec.