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
+102
View File
@@ -0,0 +1,102 @@
// Categories: list (GET /categories.json) and create (POST /categories.json).
// Creation needs an admin-scoped key — the current MOPAC key gets 403
// there (ErrForbidden); listing works with a standard key.
package discourse
import (
"context"
"fmt"
"net/url"
"strings"
)
// Category is the subset of the Discourse category payload callers use.
type Category struct {
ID int `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Color string `json:"color"`
TextColor string `json:"text_color"`
Position int `json:"position"`
TopicCount int `json:"topic_count"`
}
// CreateCategoryRequest is the POST /categories.json body. Permissions
// maps a GROUP NAME to an access level: 1 = reply/see, 2 = create posts,
// 3 = full (the security group numbers Discourse's admin UI uses). Empty
// means "default: everyone full" — for restricted categories set it
// explicitly, e.g. {"staff": 3, "trust_level_0": 1}.
type CreateCategoryRequest struct {
Name string `json:"name"`
Color string `json:"color"` // 6 hex digits, no '#'
TextColor string `json:"text_color"` // 6 hex digits, no '#'
Permissions map[string]int `json:"permissions,omitempty"`
Position int `json:"position,omitempty"`
ParentCategoryID int `json:"parent_category_id,omitempty"`
Description string `json:"description,omitempty"`
}
// ListCategories returns the instance's categories (one request; the
// endpoint returns the full list).
func (c *Client) ListCategories(ctx context.Context) ([]Category, error) {
var payload struct {
CategoryList struct {
Categories []Category `json:"categories"`
} `json:"category_list"`
}
if err := c.Get(ctx, "/categories.json", &payload); err != nil {
return nil, err
}
return payload.CategoryList.Categories, nil
}
// FindCategory resolves a category by id or by slug (case-insensitive).
// One of the two must be non-zero/non-empty.
func (c *Client) FindCategory(ctx context.Context, id int, slug string) (*Category, error) {
cats, err := c.ListCategories(ctx)
if err != nil {
return nil, err
}
for i := range cats {
if (id != 0 && cats[i].ID == id) ||
(slug != "" && strings.EqualFold(cats[i].Slug, slug)) {
return &cats[i], nil
}
}
return nil, &APIError{
Method: "GET",
Path: "/categories.json",
Status: 404,
Errors: []string{fmt.Sprintf("category id=%d slug=%q not found", id, slug)},
}
}
// CreateCategory creates a category and returns the created record.
// Colors are normalized (uppercased, '#' stripped); Discourse requires
// valid hex or it 422s.
func (c *Client) CreateCategory(ctx context.Context, req CreateCategoryRequest) (*Category, error) {
if strings.TrimSpace(req.Name) == "" {
return nil, fmt.Errorf("%w: category name is required", ErrInvalidRequest)
}
req.Color = normHex(req.Color, "3AB54A")
req.TextColor = normHex(req.TextColor, "FFFFFF")
var created Category
if err := c.Post(ctx, "/categories.json", req, &created); err != nil {
return nil, err
}
return &created, nil
}
func normHex(v, fallback string) string {
v = strings.TrimPrefix(strings.TrimSpace(strings.ToUpper(v)), "#")
if v == "" {
return fallback
}
return v
}
// quotePathSegment escapes a path segment (slugs in /c/<slug>/<id>.json).
func quotePathSegment(s string) string {
return url.PathEscape(s)
}