v0: stdlib-only Discourse client — categories/topics/posts + raw passthrough
Covers the MOPAC briefing/report surface (Redmine 495 Part A): category
list/create (create needs the admin-scoped key; current key 403s, typed
as ErrForbidden), topic create/list/latest/get, post create/update/get,
current-user identity probe, and a Do() JSON passthrough so unmodeled
endpoints need no client release. Key is env/constructor-only, never a
flag, never logged; errors classify via errors.Is. Fake-server unit
tests + containerized end-to-end smoke (redaction sweep included); live
reads verified against community.turnsys.com.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
# 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)
|
||||
|
||||
```go
|
||||
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).
|
||||
```sh
|
||||
./dev.sh check # = go build + go vet + go test, inside golang:1.26-bookworm
|
||||
```
|
||||
Expected output (tail):
|
||||
```text
|
||||
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):
|
||||
```sh
|
||||
./dev.sh smoke
|
||||
```
|
||||
Expected output (tail):
|
||||
```text
|
||||
smoke: OK
|
||||
```
|
||||
|
||||
### Configure
|
||||
|
||||
Credentials NEVER arrive via flags or arguments:
|
||||
```sh
|
||||
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 list` with 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 to
|
||||
`ErrForbidden`; 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.
|
||||
Reference in New Issue
Block a user