mrcharles 5b95ac3c7e feat: accept DISCOURSE_KEY as env alias for the API key
NewFromEnv now falls back to DISCOURSE_KEY when DISCOURSE_API_KEY
is unset (the explicit name still wins), matching the shorter
DISCOURSE_URL/DISCOURSE_KEY convention used across the MOPAC fleet
scripts. Behavioral test proves both the alias and the precedence
against the fake server.

Part of Redmine 507 (Discourse Go client).
2026-08-29 16:47:17 -05:00

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.jsontopic_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 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.

S
Description
Stdlib-only Go client for the Discourse API (categories/topics/posts + raw passthrough). MOPAC supporting cast; AGPLv3.
Readme AGPL-3.0
126 KiB
Languages
Go 91%
Shell 8.6%
Makefile 0.4%