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).
275 lines
8.3 KiB
Go
275 lines
8.3 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 runSearch(ctx context.Context, c *discourse.Client, args []string) (any, error) {
|
|
fs := flag.NewFlagSet("search", flag.ContinueOnError)
|
|
page := fs.Int("page", 1, "result page, 1-based")
|
|
if err := fs.Parse(flagsFirst(args)); err != nil {
|
|
return nil, err
|
|
}
|
|
if fs.NArg() != 1 {
|
|
return nil, fmt.Errorf("search needs exactly one TERM (quote it; @user, #category and order:latest operators pass through)")
|
|
}
|
|
return c.Search(ctx, discourse.SearchRequest{Term: fs.Arg(0), Page: *page})
|
|
}
|
|
|
|
func runMessages(ctx context.Context, c *discourse.Client, args []string) (any, error) {
|
|
if len(args) == 0 {
|
|
return nil, fmt.Errorf("messages needs a subcommand (send)")
|
|
}
|
|
switch args[0] {
|
|
case "send":
|
|
fs := flag.NewFlagSet("messages send", flag.ContinueOnError)
|
|
title := fs.String("title", "", "message title (required)")
|
|
to := fs.String("to", "", "recipient usernames, comma-separated (repeatable targets below)")
|
|
group := fs.String("group", "", "recipient group names, comma-separated")
|
|
email := fs.String("email", "", "recipient emails, comma-separated (invite external users)")
|
|
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.SendMessage(ctx, discourse.SendMessageRequest{
|
|
Title: *title,
|
|
Raw: body,
|
|
TargetUsernames: splitComma(*to),
|
|
TargetGroupNames: splitComma(*group),
|
|
TargetEmails: splitComma(*email),
|
|
})
|
|
}
|
|
return nil, fmt.Errorf("unknown messages subcommand %q", args[0])
|
|
}
|
|
|
|
// splitComma splits a comma-separated flag value, dropping empties.
|
|
func splitComma(s string) []string {
|
|
var out []string
|
|
for _, v := range strings.Split(s, ",") {
|
|
if v = strings.TrimSpace(v); v != "" {
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
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
|
|
}
|