mrcharles f13302778b feat(cli): search, messages send, webhook verify, sso verify
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).
2026-08-29 16:50:31 -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%