From b69e70e0a1eb8e66949315f848c46798ac1362f2 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Thu, 30 Jul 2026 12:09:44 -0500 Subject: [PATCH] feat: add redmine-cli containerized CLI tool Merge the redmine-cli work from the EnableAI repo into KNEL-AIMiddleware as the canonical home for AI middleware. This is a standalone CLI (python-redmine 2.5.0) that lists, shows, creates, updates, and closes Redmine issues via docker run. Complements the existing mcp-redmine MCP server (protocol-native) with a direct-invocation tool for interactive use. Verified end-to-end against projects.knownelement.com: whoami, projects, statuses, list, show, create, update, and close all tested successfully. Also records host-cleanliness conventions (docker/tea only on host; custom images to Gitea registry) in AGENTS.md, and adds a CLI Tools section to STATUS.md. --- AGENTS.md | 8 + JOURNAL.md | 26 +++ STATUS.md | 8 + redmine-cli/.env.example | 4 + redmine-cli/Dockerfile | 16 ++ redmine-cli/README.md | 97 ++++++++ redmine-cli/requirements.txt | 1 + redmine-cli/src/redmine_cli.py | 389 +++++++++++++++++++++++++++++++++ 8 files changed, 549 insertions(+) create mode 100644 redmine-cli/.env.example create mode 100644 redmine-cli/Dockerfile create mode 100644 redmine-cli/README.md create mode 100644 redmine-cli/requirements.txt create mode 100644 redmine-cli/src/redmine_cli.py diff --git a/AGENTS.md b/AGENTS.md index 3dd144a..ea0edc5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,14 @@ ALL work performed on this project MUST be documented in [JOURNAL.md](JOURNAL.md - `vendor/` directory is gitignored (cloned repos not committed) - Custom Dockerfiles saved in `dockerfiles/` with subdirectory structure - KiCAD MCP is host-only - requires KiCAD installed on host machine +- **Keep the host clean**: only `docker` and `tea` (Gitea CLI) commands run + directly on the host. Everything else lives in containers. No host installs + of runtimes, CLIs, or packages. +- **Custom images**: publish to the Gitea container registry at + `git.knownelement.com/reachableceo/:` (Docker is pre-authenticated). +- **Standalone CLI tools**: containerized CLI tools (not MCP/LSP servers) live + in their own top-level directory (e.g. `redmine-cli/`). Invoked on-demand via + `docker run --env-file .env `, not as long-running services. ## Crush Configuration diff --git a/JOURNAL.md b/JOURNAL.md index fbd1340..aa16c3e 100644 --- a/JOURNAL.md +++ b/JOURNAL.md @@ -1898,3 +1898,29 @@ Now has 3 working MCP servers: When npm package exists and works better than source build, prefer npm package installation for reliability and simplicity. --- + +## 2026-07-30 — Merged redmine-cli from EnableAI repo + +**Context**: The EnableAI repo (`/home/reachableceo/projects/EnableAI`) was a +meta-project for enabling AI access to SaaS systems. Its first deliverable was a +containerized Redmine CLI. That work is now merged into KNEL-AIMiddleware, which +is the canonical home for all AI middleware going forward. + +**What was merged**: +- `redmine-cli/` — a self-contained Python CLI (python-redmine 2.5.0) that + lists, shows, creates, updates, and closes Redmine issues. Built as a Docker + image (`kneldevstack-aimiddleware-redmine-cli`), verified end-to-end against + the live Redmine at `projects.knownelement.com` (whoami, list, show, create, + update, close all tested successfully). + +**Relationship to existing mcp-redmine**: +- `mcp-redmine` (dockerfiles/mcp-redmine) is the upstream runekaagaard MCP + server — a protocol-native tool provider for OpenWebUI/Crush via MCP stdio. +- `redmine-cli` is a complementary standalone CLI for direct invocation from + the shell (useful when working interactively in Crush). Both share the same + `REDMINE_URL` / `REDMINE_API_KEY` environment variables. + +**Host-cleanliness rule established**: The host stays clean — only `docker` and +`tea` commands run on the host; everything else is containerized. Custom images +are published to the Gitea container registry at +`git.knownelement.com/reachableceo/`. diff --git a/STATUS.md b/STATUS.md index 39100ad..2ad25c1 100644 --- a/STATUS.md +++ b/STATUS.md @@ -210,3 +210,11 @@ docker compose build --no-cache ``` Note: Use lowercase service names from compose file. Container names will be prefixed with kneldevstack-aimiddleware-. + +## CLI Tools + +Standalone containerized CLI tools (not MCP/LSP servers). Invoked on-demand via `docker run` rather than long-running services. + +| Tool | Image | Status | Notes | +|------|-------|--------|-------| +| redmine-cli | kneldevstack-aimiddleware-redmine-cli | Working | Custom CLI (python-redmine) to list/show/create/update/close Redmine issues. Verified end-to-end against projects.knownelement.com. Requires REDMINE_URL and REDMINE_API_KEY. Source in `redmine-cli/`. | diff --git a/redmine-cli/.env.example b/redmine-cli/.env.example new file mode 100644 index 0000000..a12f0af --- /dev/null +++ b/redmine-cli/.env.example @@ -0,0 +1,4 @@ +# Redmine connection details +# Copy this file to .env and fill in real values. The .env file is gitignored. +REDMINE_URL=https://redmine.example.com +REDMINE_API_KEY=your-api-key-here diff --git a/redmine-cli/Dockerfile b/redmine-cli/Dockerfile new file mode 100644 index 0000000..6ba1584 --- /dev/null +++ b/redmine-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/redmine_cli.py /usr/local/bin/redmine-cli +RUN chmod +x /usr/local/bin/redmine-cli + +ENTRYPOINT ["redmine-cli"] +CMD ["--help"] diff --git a/redmine-cli/README.md b/redmine-cli/README.md new file mode 100644 index 0000000..35fc234 --- /dev/null +++ b/redmine-cli/README.md @@ -0,0 +1,97 @@ +# redmine-cli + +A Docker container that lets an AI agent (or a human) access, edit, and close +Redmine issues through the Redmine REST API. Built on the +[`python-redmine`](https://python-redmine.com) library with a small command-line +wrapper. + +## Requirements + +- Docker on the host. +- A Redmine instance with REST web services enabled + (Administration → Settings → API → Enable REST API). +- A Redmine API key for the user the agent will act as + (My account → API access key → Show / Reset). + +## Configuration + +Copy the env template and fill in real values (the `.env` is gitignored): + +```bash +cp .env.example .env +# then edit .env: +# REDMINE_URL=https://your-redmine.example.com +# REDMINE_API_KEY=abc123... +``` + +## Build + +```bash +docker build -t kneldevstack-aimiddleware-redmine-cli . +``` + +A prebuilt image is also available in the Gitea registry: + +``` +git.knownelement.com/reachableceo/redmine-cli:latest +``` + +## Usage + +### Via the repo wrapper (loads `.env` automatically) + +```bash +bin/redmine whoami +bin/redmine list --assigned-to-me +bin/redmine show 123 +bin/redmine close 123 --notes "work complete" +``` + +### Via docker directly (from the repo root) + +```bash +docker run --rm --env-file .env kneldevstack-aimiddleware-redmine-cli whoami +docker run --rm --env-file .env kneldevstack-aimiddleware-redmine-cli list --assigned-to-me +``` + +## Commands + +| Command | Description | +| -------------------------------- | ------------------------------------------------------ | +| `whoami` | Show the authenticated user (also a connection test). | +| `projects` | List projects (id, identifier, name). | +| `statuses` | List issue statuses and which are "closed". | +| `list` (`ls`) | List issues. Filters below. | +| `show ` | Show full issue detail incl. note history. | +| `create` | Create an issue (`--project`, `--subject`, ...). | +| `update ` | Update an issue (status, notes, assignee, done, ...). | +| `close ` | Close an issue (first closed status, done ratio 100%). | + +### `list` filters + +- `-m, --assigned-to-me` — only issues assigned to the current user +- `-a, --assigned-to ` — filter by assignee user id +- `-p, --project ` — filter by project +- `-s, --status ` — filter by status +- `-l, --limit ` — max results (default 50) +- `--sort ` — Redmine sort spec + (default `priority:desc,updated_on:desc`) + +### `update` / `create` options + +- `--status ` — status name (case-insensitive) or numeric id +- `-n, --notes ` — add a journal note +- `-a, --assigned-to ` — assignee user id +- `--done-ratio <0-100>` — percent complete +- `--subject ` — change the subject (`update`/`create`) +- `--priority ` — priority id +- `-d, --description ` — description (`create` only) +- `-t, --tracker ` — tracker id (`create` only) + +## Environment variables + +| Variable | Required | Description | +| ----------------- | -------- | ------------------------------------ | +| `REDMINE_URL` | yes | Base URL of the Redmine instance. | +| `REDMINE_API_KEY` | yes | API key of the acting user. | +| `REDMINE_CLI_IMAGE` | no | Override the image tag used by `bin/redmine` (default `git.knownelement.com/reachableceo/redmine-cli:latest`). | diff --git a/redmine-cli/requirements.txt b/redmine-cli/requirements.txt new file mode 100644 index 0000000..d27565f --- /dev/null +++ b/redmine-cli/requirements.txt @@ -0,0 +1 @@ +python-redmine>=2.5.0 diff --git a/redmine-cli/src/redmine_cli.py b/redmine-cli/src/redmine_cli.py new file mode 100644 index 0000000..b6ae419 --- /dev/null +++ b/redmine-cli/src/redmine_cli.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +"""Redmine CLI - a thin wrapper around the Redmine REST API. + +Connection details come from the environment: + REDMINE_URL base URL of the Redmine instance (e.g. https://redmine.example.com) + REDMINE_API_KEY API key of an authenticated user + +Designed to be run inside the redmine-cli Docker container, but works anywhere +these environment variables are set. +""" + +import argparse +import os +import sys + +from redminelib import Redmine +from redminelib.exceptions import ( + AuthError, + ResourceNotFoundError, + ServerError, + ValidationError, + ConflictError, + NoFileError, +) + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # +def _client(): + """Build and return an authenticated Redmine client, or exit with help.""" + url = os.environ.get("REDMINE_URL", "").strip().rstrip("/") + key = os.environ.get("REDMINE_API_KEY", "").strip() + missing = [n for n, v in (("REDMINE_URL", url), ("REDMINE_API_KEY", key)) if not v] + if missing: + sys.stderr.write( + "ERROR: missing required environment variable(s): " + + ", ".join(missing) + + "\n" + ) + sys.exit(2) + return Redmine(url, key=key) + + +def _current_user(rm): + """Return the Redmine user behind the current API key.""" + return rm.user.get("current") + + +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}" + + +def _name(obj): + """Best-effort human name for a user object.""" + if obj is None: + return "(unassigned)" + # python-redmine user objects expose these attributes + fname = getattr(obj, "firstname", "") or "" + lname = getattr(obj, "lastname", "") or "" + full = f"{fname} {lname}".strip() + login = getattr(obj, "login", "") or "" + mail = getattr(obj, "mail", "") or "" + if full: + return f"{full} ({login})" if login else full + return login or mail or str(obj) + + +# --------------------------------------------------------------------------- # +# Commands +# --------------------------------------------------------------------------- # +def cmd_whoami(args): + rm = _client() + try: + u = _current_user(rm) + except AuthError: + _err("authentication failed - check REDMINE_API_KEY", 2) + print(f"id: {u.id}") + print(f"login: {getattr(u, 'login', '?')}") + print(f"name: {_name(u)}") + print(f"mail: {getattr(u, 'mail', '?')}") + print(f"admin: {getattr(u, 'admin', False)}") + print(f"redmine: {os.environ['REDMINE_URL']}") + return 0 + + +def cmd_projects(args): + rm = _client() + projects = rm.project.all() + print(f"{'ID':<6} {'IDENTIFIER':<24} {'NAME'}") + print("-" * 60) + for p in projects: + print(f"{p.id:<6} {str(p.identifier):<24} {p.name}") + print(f"\n{len(projects)} project(s)") + return 0 + + +def cmd_statuses(args): + rm = _client() + statuses = rm.issue_status.all() + print(f"{'ID':<6} {'NAME':<24} IS_CLOSED") + print("-" * 40) + for s in statuses: + print(f"{s.id:<6} {str(s.name):<24} {bool(s.is_closed)}") + print(f"\n{len(statuses)} status(es)") + return 0 + + +def _resolve_status_id(rm, status_ref): + """Resolve a status name (case-insensitive) or id to a numeric id.""" + if status_ref is None: + return None + if status_ref.isdigit(): + return int(status_ref) + needle = status_ref.strip().lower() + for s in rm.issue_status.all(): + if str(s.name).lower() == needle: + return s.id + _err(f"unknown status '{status_ref}'. Run 'statuses' to list valid names.") + + +def _resolve_project_id(rm, project_ref): + """Resolve a project id or identifier to a numeric id.""" + if project_ref is None: + return None + if project_ref.isdigit(): + return int(project_ref) + needle = project_ref.strip().lower() + for p in rm.project.all(): + if str(p.identifier).lower() == needle: + return p.id + _err(f"unknown project '{project_ref}'. Run 'projects' to list identifiers.") + + +def cmd_list(args): + rm = _client() + filters = {} + if args.assigned_to_me: + me = _current_user(rm) + filters["assigned_to_id"] = me.id + elif args.assigned_to is not None: + filters["assigned_to_id"] = args.assigned_to + if args.project is not None: + filters["project_id"] = _resolve_project_id(rm, args.project) + if args.status is not None: + # status can be a name or id + if args.status.lower() in ("open", "open*"): + filters["status_id"] = "open" + elif args.status.lower() in ("closed", "closed*"): + filters["status_id"] = "closed" + else: + filters["status_id"] = _resolve_status_id(rm, args.status) + filters["limit"] = args.limit + filters["sort"] = args.sort + + issues = rm.issue.filter(**filters) + print(f"{'ID':<8} {'STATUS':<14} {'PRJ':<14} {'DONE':>5} SUBJECT") + print("-" * 90) + for i in issues: + proj = getattr(i, "project", None) + proj_id = str(getattr(proj, "identifier", getattr(proj, "id", ""))) if proj else "" + status = getattr(i, "status", None) + status_name = str(getattr(status, "name", "")) if status else "" + done = getattr(i, "done_ratio", 0) or 0 + print(f"{i.id:<8} {status_name:<14} {proj_id:<14} {done:>4}% {i.subject}") + print(f"\n{len(issues)} issue(s)") + return 0 + + +def cmd_show(args): + rm = _client() + try: + i = rm.issue.get(args.issue_id) + except ResourceNotFoundError: + _err(f"issue #{args.issue_id} not found") + lines = [] + lines.append(f"#{i.id}: {i.subject}") + lines.append("=" * 90) + proj = getattr(i, "project", None) + status = getattr(i, "status", None) + tracker = getattr(i, "tracker", None) + priority = getattr(i, "priority", None) + lines.append(_kv("project", str(getattr(proj, "name", "")) if proj else None)) + lines.append(_kv("tracker", str(getattr(tracker, "name", "")) if tracker else None)) + lines.append(_kv("status", str(getattr(status, "name", "")) if status else None)) + lines.append(_kv("priority", str(getattr(priority, "name", "")) if priority else None)) + lines.append(_kv("author", _name(getattr(i, "author", None)))) + lines.append(_kv("assigned", _name(getattr(i, "assigned_to", None)))) + lines.append(_kv("done", f"{getattr(i, 'done_ratio', 0) or 0}%")) + lines.append(_kv("created", getattr(i, "created_on", None))) + lines.append(_kv("updated", getattr(i, "updated_on", None))) + lines.append(_kv("start", getattr(i, "start_date", None))) + lines.append(_kv("due", getattr(i, "due_date", None))) + lines.append(_kv("estimated", getattr(i, "estimated_hours", None))) + lines.append(_kv("spent", getattr(i, "spent_hours", None))) + for ln in (l for l in lines if l): + print(ln) + + desc = getattr(i, "description", "") or "" + if desc.strip(): + print("\n--- description ---") + print(desc.strip()) + + if not args.no_journals: + journals = getattr(i, "journals", []) or [] + notes = [j for j in journals if getattr(j, "notes", "") and str(j.notes).strip()] + if notes: + print("\n--- history (notes) ---") + for j in notes: + who = _name(getattr(j, "user", None)) + when = getattr(j, "created_on", "?") + print(f"\n[{when}] {who}:") + print(str(j.notes).strip()) + return 0 + + +def cmd_create(args): + rm = _client() + if not args.project: + _err("--project is required to create an issue") + if not args.subject: + _err("--subject is required to create an issue") + fields = { + "project_id": _resolve_project_id(rm, args.project), + "subject": args.subject, + } + if args.description: + fields["description"] = args.description + if args.assigned_to is not None: + fields["assigned_to_id"] = args.assigned_to + if args.tracker is not None: + fields["tracker_id"] = args.tracker + if args.priority is not None: + fields["priority_id"] = args.priority + if args.status is not None: + sid = _resolve_status_id(rm, args.status) + if sid: + fields["status_id"] = sid + try: + issue = rm.issue.create(**fields) + except ValidationError as e: + _err(f"validation failed: {e}") + print(f"Created issue #{issue.id}: {issue.subject}") + return 0 + + +def cmd_update(args): + rm = _client() + fields = {} + if args.status is not None: + sid = _resolve_status_id(rm, args.status) + if sid: + fields["status_id"] = sid + if args.notes: + fields["notes"] = args.notes + if args.assigned_to is not None: + fields["assigned_to_id"] = args.assigned_to + if args.done_ratio is not None: + fields["done_ratio"] = args.done_ratio + if args.subject is not None: + fields["subject"] = args.subject + if args.priority is not None: + fields["priority_id"] = args.priority + if not fields: + _err("no fields to update; pass at least one of --status/--notes/--assigned-to/--done-ratio/--subject/--priority") + try: + rm.issue.update(args.issue_id, **fields) + except ResourceNotFoundError: + _err(f"issue #{args.issue_id} not found") + except ValidationError as e: + _err(f"validation failed: {e}") + print(f"Updated issue #{args.issue_id}") + return 0 + + +def cmd_close(args): + rm = _client() + close_id = None + for s in rm.issue_status.all(): + if getattr(s, "is_closed", False): + close_id = s.id + break + if close_id is None: + _err("no closed status found on this Redmine instance") + fields = {"status_id": close_id} + if args.notes: + fields["notes"] = args.notes + if args.done_ratio is not None: + fields["done_ratio"] = args.done_ratio + else: + fields["done_ratio"] = 100 + try: + rm.issue.update(args.issue_id, **fields) + except ResourceNotFoundError: + _err(f"issue #{args.issue_id} not found") + except ValidationError as e: + _err(f"validation failed: {e}") + print(f"Closed issue #{args.issue_id} (status_id={close_id}, done=100%)") + return 0 + + +# --------------------------------------------------------------------------- # +# Argument parsing +# --------------------------------------------------------------------------- # +def build_parser(): + p = argparse.ArgumentParser( + prog="redmine-cli", + description="Access, edit, and close Redmine issues 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("projects", help="list projects") + sp.set_defaults(func=cmd_projects) + + sp = sub.add_parser("statuses", help="list issue statuses") + sp.set_defaults(func=cmd_statuses) + + sp = sub.add_parser("list", aliases=["ls"], help="list issues") + sp.add_argument("-m", "--assigned-to-me", action="store_true", help="only issues assigned to the current user") + sp.add_argument("-a", "--assigned-to", metavar="USER_ID", help="filter by assignee user id") + sp.add_argument("-p", "--project", metavar="ID_OR_IDENTIFIER", help="filter by project") + sp.add_argument("-s", "--status", metavar="NAME_OR_ID", help="filter by status (name/id, or 'open'/'closed')") + sp.add_argument("-l", "--limit", type=int, default=50, help="max issues to return (default 50)") + sp.add_argument("--sort", default="priority:desc,updated_on:desc", help="sort order") + sp.set_defaults(func=cmd_list) + + sp = sub.add_parser("show", help="show details of an issue") + sp.add_argument("issue_id", type=int) + sp.add_argument("--no-journals", action="store_true", help="omit note history") + sp.set_defaults(func=cmd_show) + + sp = sub.add_parser("create", help="create a new issue") + sp.add_argument("-p", "--project", required=True, metavar="ID_OR_IDENTIFIER") + sp.add_argument("-s", "--subject", required=True) + sp.add_argument("-d", "--description") + sp.add_argument("-a", "--assigned-to", metavar="USER_ID") + sp.add_argument("-t", "--tracker", metavar="TRACKER_ID") + sp.add_argument("--priority", metavar="PRIORITY_ID") + sp.add_argument("--status", metavar="NAME_OR_ID") + sp.set_defaults(func=cmd_create) + + sp = sub.add_parser("update", help="update an issue") + sp.add_argument("issue_id", type=int) + sp.add_argument("-s", "--status", metavar="NAME_OR_ID", help="new status") + sp.add_argument("-n", "--notes", help="add a note/journal comment") + sp.add_argument("-a", "--assigned-to", metavar="USER_ID") + sp.add_argument("--done-ratio", type=int, metavar="0-100") + sp.add_argument("--subject") + sp.add_argument("--priority", metavar="PRIORITY_ID") + sp.set_defaults(func=cmd_update) + + sp = sub.add_parser("close", help="close an issue (set to first closed status, done ratio 100%%)") + sp.add_argument("issue_id", type=int) + sp.add_argument("-n", "--notes", help="add a note/journal comment") + sp.add_argument("--done-ratio", type=int, metavar="0-100", help="override done ratio (default 100)") + sp.set_defaults(func=cmd_close) + + return p + + +def main(argv=None): + parser = build_parser() + args = parser.parse_args(argv) + try: + return args.func(args) + except AuthError: + _err("authentication failed - check REDMINE_API_KEY", 2) + except ServerError as e: + _err(f"server error: {e}") + except ConflictError as e: + _err(f"conflict: {e}") + except (NoFileError, ResourceNotFoundError) as e: + _err(str(e)) + 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())