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,220 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.knownelement.com/ukrrs/mopac-discourse-go"
|
||||
)
|
||||
|
||||
func runCategories(ctx context.Context, c *discourse.Client, args []string) (any, error) {
|
||||
if len(args) == 0 {
|
||||
return nil, fmt.Errorf("categories needs a subcommand (list|create)")
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
if len(args) != 1 {
|
||||
return nil, fmt.Errorf("categories list takes no arguments")
|
||||
}
|
||||
return c.ListCategories(ctx)
|
||||
case "create":
|
||||
fs := flag.NewFlagSet("categories create", flag.ContinueOnError)
|
||||
color := fs.String("color", "", "hex color, 6 digits")
|
||||
textColor := fs.String("text-color", "", "hex text color, 6 digits")
|
||||
perms := multiFlag{}
|
||||
fs.Var(&perms, "perm", "GROUP=LEVEL (1 reply/see, 2 create, 3 full); repeatable")
|
||||
if err := fs.Parse(flagsFirst(args[1:])); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if fs.NArg() != 1 {
|
||||
return nil, fmt.Errorf("categories create needs exactly one NAME")
|
||||
}
|
||||
req := discourse.CreateCategoryRequest{
|
||||
Name: fs.Arg(0),
|
||||
Color: *color,
|
||||
TextColor: *textColor,
|
||||
}
|
||||
for _, p := range perms {
|
||||
parts := strings.SplitN(p, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("-perm %q must be GROUP=LEVEL", p)
|
||||
}
|
||||
lvl, err := strconv.Atoi(parts[1])
|
||||
if err != nil || lvl < 1 || lvl > 3 {
|
||||
return nil, fmt.Errorf("-perm %q level must be 1, 2 or 3", p)
|
||||
}
|
||||
if req.Permissions == nil {
|
||||
req.Permissions = map[string]int{}
|
||||
}
|
||||
req.Permissions[parts[0]] = lvl
|
||||
}
|
||||
return c.CreateCategory(ctx, req)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown categories subcommand %q", args[0])
|
||||
}
|
||||
|
||||
func runTopics(ctx context.Context, c *discourse.Client, args []string) (any, error) {
|
||||
if len(args) == 0 {
|
||||
return nil, fmt.Errorf("topics needs a subcommand (list|show|create)")
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
fs := flag.NewFlagSet("topics list", flag.ContinueOnError)
|
||||
catID := fs.Int("category", 0, "category id")
|
||||
slug := fs.String("slug", "", "category slug")
|
||||
latest := fs.Bool("latest", false, "instance-wide latest topics")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if *latest {
|
||||
return c.LatestTopics(ctx)
|
||||
}
|
||||
return c.ListTopics(ctx, *catID, *slug)
|
||||
case "show":
|
||||
if len(args) != 2 {
|
||||
return nil, fmt.Errorf("topics show needs exactly one ID")
|
||||
}
|
||||
id, err := strconv.Atoi(args[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("topic id %q is not a number", args[1])
|
||||
}
|
||||
return c.GetTopic(ctx, id)
|
||||
case "create":
|
||||
fs := flag.NewFlagSet("topics create", flag.ContinueOnError)
|
||||
title := fs.String("title", "", "topic title (required)")
|
||||
category := fs.Int("category", 0, "category id (required)")
|
||||
raw := fs.String("raw", "", "markdown body inline")
|
||||
file := fs.String("file", "", "markdown body from file (overrides -raw)")
|
||||
if err := fs.Parse(flagsFirst(args[1:])); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := bodyFrom(*file, *raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.CreateTopic(ctx, discourse.CreateTopicRequest{Title: *title, Raw: body, Category: *category})
|
||||
}
|
||||
return nil, fmt.Errorf("unknown topics subcommand %q", args[0])
|
||||
}
|
||||
|
||||
func runPosts(ctx context.Context, c *discourse.Client, args []string) (any, error) {
|
||||
if len(args) == 0 {
|
||||
return nil, fmt.Errorf("posts needs a subcommand (create|update)")
|
||||
}
|
||||
switch args[0] {
|
||||
case "create":
|
||||
fs := flag.NewFlagSet("posts create", flag.ContinueOnError)
|
||||
topic := fs.Int("topic", 0, "topic id (required)")
|
||||
raw := fs.String("raw", "", "markdown body inline")
|
||||
file := fs.String("file", "", "markdown body from file (overrides -raw)")
|
||||
if err := fs.Parse(flagsFirst(args[1:])); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := bodyFrom(*file, *raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.CreatePost(ctx, discourse.CreatePostRequest{TopicID: *topic, Raw: body})
|
||||
case "update":
|
||||
fs := flag.NewFlagSet("posts update", flag.ContinueOnError)
|
||||
raw := fs.String("raw", "", "markdown body inline")
|
||||
file := fs.String("file", "", "markdown body from file (overrides -raw)")
|
||||
reason := fs.String("reason", "", "edit reason")
|
||||
if err := fs.Parse(flagsFirst(args[1:])); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if fs.NArg() != 1 {
|
||||
return nil, fmt.Errorf("posts update needs exactly one ID")
|
||||
}
|
||||
id, err := strconv.Atoi(fs.Arg(0))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("post id %q is not a number", fs.Arg(0))
|
||||
}
|
||||
body, err := bodyFrom(*file, *raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.UpdatePost(ctx, id, body, *reason)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown posts subcommand %q", args[0])
|
||||
}
|
||||
|
||||
func runRaw(ctx context.Context, c *discourse.Client, args []string) (any, error) {
|
||||
if len(args) < 2 {
|
||||
return nil, fmt.Errorf("raw needs METHOD and PATH")
|
||||
}
|
||||
method := strings.ToUpper(args[0])
|
||||
path := args[1]
|
||||
fs := flag.NewFlagSet("raw", flag.ContinueOnError)
|
||||
data := fs.String("data", "", "JSON body inline or @file")
|
||||
if err := fs.Parse(args[2:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var body any
|
||||
if *data != "" {
|
||||
raw := []byte(*data)
|
||||
if strings.HasPrefix(*data, "@") {
|
||||
b, err := os.ReadFile(strings.TrimPrefix(*data, "@"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw = b
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
return nil, fmt.Errorf("-data is not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
var out any
|
||||
if err := c.Do(ctx, method, path, body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func bodyFrom(file, raw string) (string, error) {
|
||||
if file != "" {
|
||||
b, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("body required: -raw TEXT or -file PATH")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// flagsFirst lets flags appear after positional args ("create NAME -color
|
||||
// X"): every flag here takes a value, so tokens starting with "-" grab
|
||||
// their neighbor; the rest are positional. Reordered as flags-then-
|
||||
// positionals for flag.Parse.
|
||||
func flagsFirst(args []string) []string {
|
||||
var flags, pos []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
if strings.HasPrefix(args[i], "-") && args[i] != "-" {
|
||||
flags = append(flags, args[i])
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
flags = append(flags, args[i])
|
||||
}
|
||||
} else {
|
||||
pos = append(pos, args[i])
|
||||
}
|
||||
}
|
||||
return append(flags, pos...)
|
||||
}
|
||||
|
||||
// multiFlag collects repeatable string flags (-perm a=1 -perm b=2).
|
||||
type multiFlag []string
|
||||
|
||||
func (m *multiFlag) String() string { return strings.Join(*m, ",") }
|
||||
func (m *multiFlag) Set(v string) error {
|
||||
*m = append(*m, v)
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user