diff --git a/discourse-cli/.env.example b/discourse-cli/.env.example new file mode 100644 index 0000000..5ecce90 --- /dev/null +++ b/discourse-cli/.env.example @@ -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 diff --git a/discourse-cli/Dockerfile b/discourse-cli/Dockerfile new file mode 100644 index 0000000..990b133 --- /dev/null +++ b/discourse-cli/Dockerfile @@ -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"] diff --git a/discourse-cli/README.md b/discourse-cli/README.md new file mode 100644 index 0000000..5e992d9 --- /dev/null +++ b/discourse-cli/README.md @@ -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 ` | Show details of a category (id or slug). | +| `topics` (`ls`) | List topics (latest, or filtered by category/new/unread).| +| `show ` | Show a topic with all its posts. | +| `create` | Create a topic (`--category`, `--title`, `--body`, `--tags`). | +| `reply ` | Reply to a topic (`--body`, optional `--reply-to`). | +| `update ` | Update a post (`--body`). | +| `delete ` | Delete a post. | +| `search ` | Search the forum. | +| `notifications` | List your notifications. | + +### `topics` / `ls` filters + +- `-c, --category ` — only topics in this category +- `-n, --new` — only new (unread tracking) topics +- `-u, --unread` — only unread topics +- `-p, --page ` — page number (default 0) + +### `create` options + +- `-c, --category ` — category to post in +- `-t, --title ` — topic title (required) +- `-b, --body ` — post body, raw text or markdown (required) +- `--tags ` — comma-separated tags + +### `reply` options + +- `-b, --body ` — reply body, raw text or markdown (required) +- `-r, --reply-to ` — 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. | diff --git a/discourse-cli/requirements.txt b/discourse-cli/requirements.txt new file mode 100644 index 0000000..0eb8cae --- /dev/null +++ b/discourse-cli/requirements.txt @@ -0,0 +1 @@ +requests>=2.31.0 diff --git a/discourse-cli/src/discourse_cli.py b/discourse-cli/src/discourse_cli.py new file mode 100644 index 0000000..80debc8 --- /dev/null +++ b/discourse-cli/src/discourse_cli.py @@ -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)", "\n", cooked) + text = re.sub(r"(?i)", "\n", text) + text = re.sub(r"(?i)]*>", " - ", text) + # Code blocks + text = re.sub(r"(?i)]*>", "`", text) + text = re.sub(r"(?i)", "`", 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()) diff --git a/discourse-cli/validate.sh b/discourse-cli/validate.sh new file mode 100755 index 0000000..93d4d84 --- /dev/null +++ b/discourse-cli/validate.sh @@ -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