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).
This commit is contained in:
2026-08-29 16:50:31 -05:00
parent 5b95ac3c7e
commit f13302778b
3 changed files with 144 additions and 1 deletions
+54
View File
@@ -144,6 +144,60 @@ func runPosts(ctx context.Context, c *discourse.Client, args []string) (any, err
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")