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:
2026-08-29 06:12:22 -05:00
commit f9cf30bc72
16 changed files with 2559 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
// Command discourse-go is a thin CLI over the client library: enough to
// drive and verify a Discourse instance by hand (categories, topics,
// posts, raw passthrough). Credentials arrive via environment only
// (DISCOURSE_URL / DISCOURSE_API_KEY / DISCOURSE_API_USERNAME, see
// env.example) — never as flags, so they never land in shell history or
// process listings.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/signal"
"syscall"
"git.knownelement.com/ukrrs/mopac-discourse-go"
)
const usage = `discourse-go — thin Discourse client CLI (config via env)
Usage:
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]
Environment (0600 env file, sourced before the call):
DISCOURSE_URL instance root, e.g. https://community.turnsys.com
DISCOURSE_API_KEY API key (never a flag, never logged)
DISCOURSE_API_USERNAME the user the key acts as (default: system)
Perm levels: 1 = reply/see, 2 = create posts, 3 = full.
raw PATH is everything after the instance root, e.g. /groups.json.
Exit codes: 0 ok · 1 usage/config · 2 API/transport error (message says
which class: forbidden/unauthorized/not-found/rate-limited/server/
unreachable/malformed).`
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
if len(args) == 0 {
fmt.Fprint(os.Stderr, usage)
return 1
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
client, err := discourse.NewFromEnv()
if err != nil {
fmt.Fprintf(os.Stderr, "discourse-go: %v\n", err)
return 1
}
var out any
switch args[0] {
case "whoami":
if len(args) != 1 {
return usageErr()
}
out, err = client.CurrentUser(ctx)
case "categories":
out, err = runCategories(ctx, client, args[1:])
case "topics":
out, err = runTopics(ctx, client, args[1:])
case "posts":
out, err = runPosts(ctx, client, args[1:])
case "raw":
out, err = runRaw(ctx, client, args[1:])
case "help", "-h", "--help":
fmt.Print(usage)
return 0
default:
fmt.Fprintf(os.Stderr, "discourse-go: unknown command %q\n\n%s", args[0], usage)
return 1
}
if err != nil {
fmt.Fprintf(os.Stderr, "discourse-go: %v\n", err)
if errors.Is(err, discourse.ErrInvalidRequest) {
return 1
}
return 2
}
if out == nil {
return 0
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(out); err != nil {
fmt.Fprintf(os.Stderr, "discourse-go: encode output: %v\n", err)
return 1
}
return 0
}
func usageErr() int {
fmt.Fprint(os.Stderr, usage)
return 1
}