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
221 lines
6.4 KiB
Go
221 lines
6.4 KiB
Go
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
|
|
}
|