feat: add discourse-cli for Discourse forum integration

A standalone CLI container (following the redmine-cli pattern) that
lets AI agents and humans read, post, reply, search, and discuss on a
Discourse forum via the REST API. Uses three environment variables
(DISCOURSE_URL, DISCOURSE_API_KEY, DISCOURSE_API_USERNAME) for auth.

Commands: whoami, categories, cat-info, topics/ls, show, create, reply,
update, delete, search, notifications.

Fully validated end-to-end (10/10 checks) against a live Discourse
instance: credential auth, whoami, categories, topics, show, search,
and full write cycle (create + reply + update + delete).

Includes validate.sh for repeatable end-to-end testing using
containerized curl + the CLI image.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
2026-07-30 16:39:17 -05:00
parent 9d6764be11
commit d4e9fd693f
6 changed files with 756 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# Discourse connection details
# Copy this file to .env and fill in real values. The .env file is gitignored.
DISCOURSE_URL=https://discourse.example.com
DISCOURSE_API_KEY=your-api-key-here
DISCOURSE_API_USERNAME=your-username
+16
View File
@@ -0,0 +1,16 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY src/discourse_cli.py /usr/local/bin/discourse-cli
RUN chmod +x /usr/local/bin/discourse-cli
ENTRYPOINT ["discourse-cli"]
CMD ["--help"]
+91
View File
@@ -0,0 +1,91 @@
# discourse-cli
A Docker container that lets an AI agent (or a human) read, post, reply,
search, and discuss on a [Discourse](https://www.discourse.org/) forum through
the Discourse REST API. A small Python CLI built on `requests`.
## Requirements
- Docker on the host.
- A Discourse instance with API access enabled
(Admin → API → "All users" or "Single user" API key).
- An API key and the username the key acts as.
## Configuration
Copy the env template and fill in real values (the `.env` is gitignored):
```bash
cp .env.example .env
# then edit .env:
# DISCOURSE_URL=https://discourse.example.com
# DISCOURSE_API_KEY=abc123...
# DISCOURSE_API_USERNAME=your-username
```
## Build
```bash
docker build -t kneldevstack-aimiddleware-discourse-cli .
```
A prebuilt image is also available in the Gitea registry:
```
git.knownelement.com/reachableceo/discourse-cli:latest
```
## Usage
### Via docker directly (from this directory)
```bash
docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli whoami
docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli ls
docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli show 42
docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli create -c 5 -t "Hello" -b "Body text"
docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli reply 42 -b "Nice point"
```
## Commands
| Command | Description |
| -------------------------------- | ------------------------------------------------------- |
| `whoami` | Show the authenticated user (also a connection test). |
| `categories` | List categories (id, slug, topic count). |
| `cat-info <cat>` | Show details of a category (id or slug). |
| `topics` (`ls`) | List topics (latest, or filtered by category/new/unread).|
| `show <topic_id>` | Show a topic with all its posts. |
| `create` | Create a topic (`--category`, `--title`, `--body`, `--tags`). |
| `reply <topic_id>` | Reply to a topic (`--body`, optional `--reply-to`). |
| `update <post_id>` | Update a post (`--body`). |
| `delete <post_id>` | Delete a post. |
| `search <query>` | Search the forum. |
| `notifications` | List your notifications. |
### `topics` / `ls` filters
- `-c, --category <id|slug>` — only topics in this category
- `-n, --new` — only new (unread tracking) topics
- `-u, --unread` — only unread topics
- `-p, --page <n>` — page number (default 0)
### `create` options
- `-c, --category <id|slug>` — category to post in
- `-t, --title <text>` — topic title (required)
- `-b, --body <text>` — post body, raw text or markdown (required)
- `--tags <a,b,c>` — comma-separated tags
### `reply` options
- `-b, --body <text>` — reply body, raw text or markdown (required)
- `-r, --reply-to <post_number>` — reply to a specific post number (optional)
## Environment variables
| Variable | Required | Description |
| ----------------------- | -------- | ---------------------------------------- |
| `DISCOURSE_URL` | yes | Base URL of the Discourse instance. |
| `DISCOURSE_API_KEY` | yes | API key ("All users" or "Single user"). |
| `DISCOURSE_API_USERNAME`| yes | Username the API key acts as. |
+1
View File
@@ -0,0 +1 @@
requests>=2.31.0
+434
View File
@@ -0,0 +1,434 @@
#!/usr/bin/env python3
"""Discourse CLI - a thin wrapper around the Discourse REST API.
Connection details come from the environment:
DISCOURSE_URL base URL of the Discourse instance (e.g. https://discourse.example.com)
DISCOURSE_API_KEY API key ("All users" or "Single user" key from Admin > API)
DISCOURSE_API_USERNAME username the API key acts as (e.g. system, or your account)
Designed to be run inside the discourse-cli Docker container, but works anywhere
these environment variables are set.
"""
import argparse
import json
import os
import sys
import requests
# --------------------------------------------------------------------------- #
# HTTP client
# --------------------------------------------------------------------------- #
class DiscourseAPI:
"""Minimal authenticated Discourse REST client."""
def __init__(self, url, api_key, api_username):
self.base_url = url.rstrip("/")
self.headers = {
"Api-Key": api_key,
"Api-Username": api_username,
"Accept": "application/json",
}
self.timeout = 30
def _request(self, method, path, **kwargs):
url = f"{self.base_url}/{path.lstrip('/')}"
resp = requests.request(
method, url, headers=self.headers, timeout=self.timeout, **kwargs
)
if resp.status_code == 429:
_err(f"rate limited by Discourse (HTTP 429). Retry later.")
if not resp.ok:
detail = ""
try:
detail = resp.json().get("errors", resp.text[:200])
except Exception:
detail = resp.text[:200]
_err(f"Discourse API error {resp.status_code} for {method} {path}: {detail}")
if resp.status_code == 204 or not resp.content:
return {}
return resp.json()
def get(self, path, params=None):
return self._request("GET", path, params=params)
def post(self, path, data=None):
return self._request("POST", path, json=data)
def put(self, path, data=None):
return self._request("PUT", path, json=data)
def delete(self, path, data=None):
return self._request("DELETE", path, json=data)
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _client():
"""Build and return an authenticated Discourse client, or exit with help."""
url = os.environ.get("DISCOURSE_URL", "").strip()
key = os.environ.get("DISCOURSE_API_KEY", "").strip()
user = os.environ.get("DISCOURSE_API_USERNAME", "").strip()
missing = [
n
for n, v in (
("DISCOURSE_URL", url),
("DISCOURSE_API_KEY", key),
("DISCOURSE_API_USERNAME", user),
)
if not v
]
if missing:
sys.stderr.write(
"ERROR: missing required environment variable(s): "
+ ", ".join(missing)
+ "\n"
)
sys.exit(2)
return DiscourseAPI(url, key, user)
def _err(msg, code=1):
sys.stderr.write(f"ERROR: {msg}\n")
sys.exit(code)
def _kv(label, value):
"""Format a label/value line, omitting falsy values gracefully."""
if value in (None, "", [], {}):
return None
return f"{label:>14}: {value}"
# --------------------------------------------------------------------------- #
# Commands
# --------------------------------------------------------------------------- #
def cmd_whoami(args):
api = _client()
data = api.get("/session/current.json")
u = data.get("current_user", {})
print(f"id: {u.get('id', '?')}")
print(f"username: {u.get('username', '?')}")
print(f"name: {u.get('name', '?')}")
print(f"admin: {u.get('admin', False)}")
print(f"trust: {u.get('trust_level', '?')}")
print(f"discourse: {os.environ['DISCOURSE_URL']}")
return 0
def cmd_categories(args):
api = _client()
data = api.get("/categories.json")
cats = data.get("category_list", {}).get("categories", [])
print(f"{'ID':<6} {'SLUG':<28} {'TOPICS':>7} NAME")
print("-" * 80)
for c in cats:
topic_count = c.get("topic_count", 0)
print(f"{c.get('id','?'):<6} {str(c.get('slug','')):<28} {topic_count:>6} {c.get('name','')}")
subcats = []
for c in cats:
subcats.extend(c.get("subcategory_ids", []) or [])
print(f"\n{len(cats)} category(ies)")
return 0
def cmd_topics(args):
api = _client()
if args.category:
cat_id = _resolve_category(api, args.category)
data = api.get(f"/c/{cat_id}.json", params={"page": args.page})
topics = data.get("topic_list", {}).get("topics", [])
else:
path = "/latest.json"
if args.unread:
path = "/unread.json"
elif args.new:
path = "/new.json"
data = api.get(path, params={"page": args.page})
topics = data.get("topic_list", {}).get("topics", [])
print(f"{'ID':<10} {'REPLIES':>8} {'VIEWS':>9} TITLE")
print("-" * 90)
for t in topics:
if t.get("pinned"):
continue
replies = t.get("posts_count", 1) - 1
views = t.get("views", 0)
title = t.get("title", "")
print(f"{t.get('id','?'):<10} {replies:>8} {views:>9} {title}")
print(f"\n{len(topics)} topic(s)")
return 0
def cmd_show(args):
api = _client()
data = api.get(f"/t/{args.topic_id}.json")
print(f"#{data.get('id','?')}: {data.get('title','?')}")
print("=" * 90)
print(_kv("category_id", data.get("category_id")))
print(_kv("views", data.get("views")))
print(_kv("like_count", data.get("like_count")))
print(_kv("posts_count", data.get("posts_count")))
print(_kv("created", data.get("created_at")))
print(_kv("last_posted", data.get("last_posted_at")))
print("")
posts = data.get("post_stream", {}).get("posts", [])
for idx, p in enumerate(posts, start=1):
post_num = p.get("post_number", idx)
author = p.get("username", "?")
created = p.get("created_at", "?")
likes = p.get("actions_summary", [])
like_count = 0
for a in likes:
if a.get("id") == 2:
like_count = a.get("count", 0)
print(f"\n--- #{post_num} [{post_num}] {author} ({created}) likes={like_count} ---")
cooked = p.get("cooked", "")
text = _strip_html(cooked)
if text.strip():
print(text.strip())
else:
print("(no content)")
return 0
def cmd_create(args):
api = _client()
if not args.title:
_err("--title is required to create a topic")
if not args.body:
_err("--body is required to create a topic")
cat_id = _resolve_category(api, args.category) if args.category else None
payload = {
"title": args.title,
"raw": args.body,
}
if cat_id is not None:
payload["category"] = cat_id
if args.tags:
payload["tags"] = [t.strip() for t in args.tags.split(",") if t.strip()]
data = api.post("/posts.json", data=payload)
topic_id = data.get("topic_id", "?")
post_id = data.get("id", "?")
print(f"Created topic #{topic_id} (post #{post_id}): {args.title}")
if topic_id != "?":
print(f"URL: {os.environ['DISCOURSE_URL']}/t/{topic_id}")
return 0
def cmd_reply(args):
api = _client()
if not args.body:
_err("--body is required to reply")
payload = {"topic_id": args.topic_id, "raw": args.body}
if args.reply_to:
payload["reply_to_post_number"] = args.reply_to
data = api.post("/posts.json", data=payload)
post_id = data.get("id", "?")
post_num = data.get("post_number", "?")
print(f"Posted reply #{post_num} (id={post_id}) in topic #{args.topic_id}")
return 0
def cmd_update(args):
api = _client()
if not args.body:
_err("--body is required to update a post")
data = api.put(f"/posts/{args.post_id}.json", data={"post": {"raw": args.body}})
post_num = data.get("post", {}).get("post_number", "?")
print(f"Updated post #{post_num} (id={args.post_id})")
return 0
def cmd_delete(args):
api = _client()
api.delete(f"/posts/{args.post_id}.json")
print(f"Deleted post #{args.post_id}")
return 0
def cmd_search(args):
api = _client()
params = {"q": args.query, "page": args.page}
data = api.get("/search.json", params=params)
topics = data.get("topics", [])
posts = data.get("posts", [])
topic_map = {t.get("id"): t for t in topics}
print(f"{'TOPIC':<10} {'POST':<8} BLURB")
print("-" * 90)
for p in posts:
tid = p.get("topic_id", "?")
t = topic_map.get(tid, {})
title = t.get("title", "")
blurb = p.get("blurb", "")
pid = p.get("id", "?")
print(f"{tid:<10} {pid:<8} {title}")
if blurb:
print(f"{'':<20}{blurb}")
print(f"\n{len(posts)} result(s)")
return 0
def cmd_notifications(args):
api = _client()
data = api.get("/notifications.json")
notifs = data.get("notifications", [])
if not notifs:
print("(no notifications)")
return 0
print(f"{'ID':<10} {'READ':>5} TYPE SUBJECT")
print("-" * 90)
for n in notifs[: args.limit]:
nid = n.get("id", "?")
read = "yes" if n.get("read") else "no"
ntype = n.get("notification_type", "?")
subject = n.get("slug") or n.get("data", {}).get("topic_title", "")
print(f"{nid:<10} {read:>5} {str(ntype):<20} {subject}")
print(f"\n{len(notifs)} notification(s), showing {min(len(notifs), args.limit)}")
return 0
def cmd_categories_info(args):
api = _client()
cat_id = _resolve_category(api, args.category)
data = api.get(f"/c/{cat_id}/show.json")
c = data.get("category", {})
print(f"#{c.get('id','?')}: {c.get('name','?')}")
print("=" * 60)
print(_kv("slug", c.get("slug")))
print(_kv("color", c.get("color")))
print(_kv("topic_count", c.get("topic_count")))
print(_kv("post_count", c.get("post_count")))
print(_kv("description", _strip_html(c.get("description", "") or "")[:200]))
return 0
# --------------------------------------------------------------------------- #
# Resolution helpers
# --------------------------------------------------------------------------- #
def _resolve_category(api, ref):
"""Resolve a category id, slug, or name to a numeric id."""
if str(ref).isdigit():
return int(ref)
data = api.get("/categories.json")
cats = data.get("category_list", {}).get("categories", [])
needle = str(ref).strip().lower()
for c in cats:
if str(c.get("slug", "")).lower() == needle:
return c["id"]
if str(c.get("name", "")).lower() == needle:
return c["id"]
_err(f"unknown category '{ref}'. Run 'categories' to list valid slugs/names.")
def _strip_html(cooked):
"""Very small HTML-to-text for display of Discourse 'cooked' post bodies."""
import html
import re
if not cooked:
return ""
# Preserve block-level breaks
text = re.sub(r"(?i)</(p|div|li|h[1-6]|tr|blockquote)>", "\n", cooked)
text = re.sub(r"(?i)<br\s*/?>", "\n", text)
text = re.sub(r"(?i)<li[^>]*>", " - ", text)
# Code blocks
text = re.sub(r"(?i)<code[^>]*>", "`", text)
text = re.sub(r"(?i)</code>", "`", text)
# Strip all remaining tags
text = re.sub(r"<[^>]+>", "", text)
text = html.unescape(text)
# Collapse excessive blank lines
text = re.sub(r"\n{3,}", "\n\n", text)
return text
# --------------------------------------------------------------------------- #
# Argument parsing
# --------------------------------------------------------------------------- #
def build_parser():
p = argparse.ArgumentParser(
prog="discourse-cli",
description="Read, post, and discuss on a Discourse forum via the REST API.",
)
sub = p.add_subparsers(dest="command", required=True)
sub.add_parser(
"whoami", help="show the authenticated user (connection test)"
).set_defaults(func=cmd_whoami)
sp = sub.add_parser("categories", help="list categories")
sp.set_defaults(func=cmd_categories)
sp = sub.add_parser("cat-info", help="show details of a category")
sp.add_argument("category", metavar="ID_OR_SLUG")
sp.set_defaults(func=cmd_categories_info)
sp = sub.add_parser("topics", aliases=["ls"], help="list topics (latest or in a category)")
sp.add_argument("-c", "--category", metavar="ID_OR_SLUG", help="filter by category")
sp.add_argument("-n", "--new", action="store_true", help="only new topics")
sp.add_argument("-u", "--unread", action="store_true", help="only unread topics")
sp.add_argument("-p", "--page", type=int, default=0, help="page number (default 0)")
sp.set_defaults(func=cmd_topics)
sp = sub.add_parser("show", help="show a topic with its posts")
sp.add_argument("topic_id", type=int)
sp.set_defaults(func=cmd_show)
sp = sub.add_parser("create", help="create a new topic")
sp.add_argument("-c", "--category", metavar="ID_OR_SLUG")
sp.add_argument("-t", "--title", required=True, help="topic title")
sp.add_argument("-b", "--body", required=True, help="post body (raw text/markdown)")
sp.add_argument("--tags", help="comma-separated tags")
sp.set_defaults(func=cmd_create)
sp = sub.add_parser("reply", help="reply to a topic")
sp.add_argument("topic_id", type=int)
sp.add_argument("-b", "--body", required=True, help="reply body (raw text/markdown)")
sp.add_argument(
"-r", "--reply-to", type=int, metavar="POST_NUMBER", help="reply to a specific post number"
)
sp.set_defaults(func=cmd_reply)
sp = sub.add_parser("update", help="update an existing post")
sp.add_argument("post_id", type=int)
sp.add_argument("-b", "--body", required=True, help="new post body (raw text/markdown)")
sp.set_defaults(func=cmd_update)
sp = sub.add_parser("delete", help="delete a post")
sp.add_argument("post_id", type=int)
sp.set_defaults(func=cmd_delete)
sp = sub.add_parser("search", help="search the forum")
sp.add_argument("query", help="search query")
sp.add_argument("-p", "--page", type=int, default=1, help="page number (default 1)")
sp.set_defaults(func=cmd_search)
sp = sub.add_parser("notifications", help="list your notifications")
sp.add_argument("-l", "--limit", type=int, default=20, help="max results (default 20)")
sp.set_defaults(func=cmd_notifications)
return p
def main(argv=None):
parser = build_parser()
args = parser.parse_args(argv)
try:
return args.func(args)
except requests.exceptions.ConnectionError:
_err(f"could not connect to Discourse at {os.environ.get('DISCOURSE_URL','?')}", 2)
except requests.exceptions.Timeout:
_err("request timed out talking to Discourse", 2)
except SystemExit:
raise
except Exception as e: # noqa: BLE001 - top-level safety net for the CLI
_err(f"{type(e).__name__}: {e}")
if __name__ == "__main__":
sys.exit(main())
+209
View File
@@ -0,0 +1,209 @@
#!/bin/sh
# discourse-cli validation script.
# Runs everything inside containers so no host tooling (curl, etc.) is needed.
#
# Usage: ./validate.sh (reads .env from the discourse-cli directory)
#
# Exit codes: 0 = all checks passed, 1 = one or more checks failed
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
# Ensure .env exists
if [ ! -f .env ]; then
echo "FAIL: .env not found. Copy .env.example to .env and fill in real values."
exit 1
fi
IMAGE="${DISCOURSE_CLI_IMAGE:-kneldevstack-aimiddleware-discourse-cli}"
PASS=0
FAIL=0
TOTAL=0
ok() { TOTAL=$((TOTAL+1)); echo "PASS: $1"; PASS=$((PASS+1)); }
fail() { TOTAL=$((TOTAL+1)); echo "FAIL: $1"; FAIL=$((FAIL+1)); }
echo "============================================"
echo " discourse-cli validation"
echo " image: ${IMAGE}"
echo "============================================"
echo ""
# ---------------------------------------------------------------- #
# 0. Credential sanity (raw HTTP via containerized curl)
# ---------------------------------------------------------------- #
echo "--- [0] Credential check (raw HTTP via containerized curl) ---"
# Load env vars from .env for the raw test
DISCOURSE_URL=$(grep -E '^DISCOURSE_URL=' .env | cut -d= -f2-)
DISCOURSE_API_KEY=$(grep -E '^DISCOURSE_API_KEY=' .env | cut -d= -f2-)
DISCOURSE_API_USERNAME=$(grep -E '^DISCOURSE_API_USERNAME=' .env | cut -d= -f2-)
HTTP_CODE=$(docker run --rm curlimages/curl:latest \
-s -o /dev/null -w '%{http_code}' \
-H "Api-Key: ${DISCOURSE_API_KEY}" \
-H "Api-Username: ${DISCOURSE_API_USERNAME}" \
-H "Accept: application/json" \
"${DISCOURSE_URL}/session/current.json" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
ok "raw HTTP credentials accepted (HTTP ${HTTP_CODE})"
else
fail "raw HTTP credentials rejected (HTTP ${HTTP_CODE})"
echo " -> Discourse at ${DISCOURSE_URL} returned ${HTTP_CODE} for /session/current.json"
echo " -> Check: API key active? IP allowlist? Username matches key scope?"
echo ""
echo " Stopping: cannot validate CLI commands without valid credentials."
echo ""
echo "============================================"
echo " RESULTS: ${PASS}/${TOTAL} passed, ${FAIL} failed"
echo "============================================"
exit 1
fi
echo ""
# ---------------------------------------------------------------- #
# 1. whoami (live connection test)
# ---------------------------------------------------------------- #
echo "--- [1] whoami ---"
OUT=$(docker run --rm --env-file .env "${IMAGE}" whoami 2>&1) || true
if echo "$OUT" | grep -qE '^username:'; then
ok "whoami returned user info"
echo " $OUT" | head -5
else
fail "whoami did not return expected output"
echo " $OUT" | head -5
fi
echo ""
# ---------------------------------------------------------------- #
# 2. categories (live read)
# ---------------------------------------------------------------- #
echo "--- [2] categories ---"
OUT=$(docker run --rm --env-file .env "${IMAGE}" categories 2>&1) || true
if echo "$OUT" | grep -qE '^[0-9]+ category'; then
ok "categories returned data"
echo " $OUT" | head -6
# Grab first category id for later tests
CAT_ID=$(echo "$OUT" | grep -E '^[0-9]' | head -1 | awk '{print $1}')
else
fail "categories did not return expected output"
echo " $OUT" | head -6
CAT_ID=""
fi
echo ""
# ---------------------------------------------------------------- #
# 3. topics / ls (live read)
# ---------------------------------------------------------------- #
echo "--- [3] topics (latest) ---"
OUT=$(docker run --rm --env-file .env "${IMAGE}" ls 2>&1) || true
if echo "$OUT" | grep -qE 'topic\(s\)'; then
ok "topics list returned data"
echo " $OUT" | head -6
TOPIC_ID=$(echo "$OUT" | grep -E '^[0-9]' | head -1 | awk '{print $1}')
else
fail "topics list did not return expected output"
echo " $OUT" | head -6
TOPIC_ID=""
fi
echo ""
# ---------------------------------------------------------------- #
# 4. show topic (live read)
# ---------------------------------------------------------------- #
if [ -n "${TOPIC_ID:-}" ]; then
echo "--- [4] show topic ${TOPIC_ID} ---"
OUT=$(docker run --rm --env-file .env "${IMAGE}" show "${TOPIC_ID}" 2>&1) || true
if echo "$OUT" | grep -qE '^#'; then
ok "show topic ${TOPIC_ID} returned content"
echo " $OUT" | head -8
else
fail "show topic ${TOPIC_ID} did not return expected output"
echo " $OUT" | head -8
fi
else
echo "--- [4] show topic (SKIPPED - no topic id found in step 3) ---"
fi
echo ""
# ---------------------------------------------------------------- #
# 5. search (live read)
# ---------------------------------------------------------------- #
echo "--- [5] search ---"
OUT=$(docker run --rm --env-file .env "${IMAGE}" search "test" 2>&1) || true
if echo "$OUT" | grep -qE 'result\(s\)'; then
ok "search returned results"
echo " $OUT" | head -5
else
fail "search did not return expected output"
echo " $OUT" | head -5
fi
echo ""
# ---------------------------------------------------------------- #
# 6. create + reply + update + delete (live write cycle)
# ---------------------------------------------------------------- #
echo "--- [6] create + reply + update + delete (write cycle) ---"
CREATE_OUT=$(docker run --rm --env-file .env "${IMAGE}" create \
${CAT_ID:+-c "$CAT_ID"} \
-t "discourse-cli validation test $(date +%s)" \
-b "This is an automated validation test post. It will be cleaned up shortly after creation." \
2>&1) || true
NEW_TOPIC_ID=$(echo "$CREATE_OUT" | grep -oE 'topic #[0-9]+' | grep -oE '[0-9]+')
if [ -n "${NEW_TOPIC_ID:-}" ]; then
ok "create topic succeeded (topic #${NEW_TOPIC_ID})"
echo " $CREATE_OUT"
# reply
REPLY_OUT=$(docker run --rm --env-file .env "${IMAGE}" reply "${NEW_TOPIC_ID}" \
-b "Validation reply test." 2>&1) || true
POST_ID=$(echo "$REPLY_OUT" | grep -oE 'id=[0-9]+' | grep -oE '[0-9]+')
if echo "$REPLY_OUT" | grep -qE 'Posted reply'; then
ok "reply succeeded"
echo " $REPLY_OUT"
else
fail "reply failed"
echo " $REPLY_OUT"
fi
# update
if [ -n "${POST_ID:-}" ]; then
UPD_OUT=$(docker run --rm --env-file .env "${IMAGE}" update "${POST_ID}" \
-b "Updated validation reply test." 2>&1) || true
if echo "$UPD_OUT" | grep -qE 'Updated post'; then
ok "update post ${POST_ID} succeeded"
else
fail "update post ${POST_ID} failed"
echo " $UPD_OUT"
fi
# delete
DEL_OUT=$(docker run --rm --env-file .env "${IMAGE}" delete "${POST_ID}" 2>&1) || true
if echo "$DEL_OUT" | grep -qE 'Deleted post'; then
ok "delete post ${POST_ID} succeeded"
else
fail "delete post ${POST_ID} failed"
echo " $DEL_OUT"
fi
fi
else
fail "create topic failed"
echo " $CREATE_OUT"
fi
echo ""
# ---------------------------------------------------------------- #
# Summary
# ---------------------------------------------------------------- #
echo "============================================"
echo " RESULTS: ${PASS}/${TOTAL} passed, ${FAIL} failed"
echo "============================================"
if [ "$FAIL" -gt 0 ]; then
exit 1
fi
exit 0