diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 30def55..5da1c4f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -33,6 +33,11 @@ "source": "./plugins/mediawiki", "description": "MediaWiki-Seiten/Templates lesen, rendern und schreiben via wiki.py (Instanzen wiki & devwiki; Zugangsdaten lokal)." }, + { + "name": "redmine", + "source": "./plugins/redmine", + "description": "Redmine via REST-API: Projekte/Tickets auflisten, anzeigen, anlegen, aktualisieren und kommentieren via redmine.py (API-Key lokal)." + }, { "name": "grill-me", "source": "./plugins/grill-me", diff --git a/README.md b/README.md index a7a44c4..9b53c6a 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,22 @@ self-contained `wiki.py` (`fetch`/`render`/`search`/`update`). Instanzen `wiki` /plugin install mediawiki@inmedias-claude-plugins ``` +### redmine + +Redmine (redmine.inmedias.it) über die REST-API: Projekte und Tickets auflisten, +anzeigen, anlegen, aktualisieren und kommentieren — via mitgelieferter, +self-contained `redmine.py` (`me`/`projects`/`issues`/`show`/`create`/`update`/ +`comment`/`meta`). Namen (Projekt/Tracker/Status/Priorität) werden auf IDs +aufgelöst. + +- Voraussetzung: `REDMINE_URL` und persönlicher `REDMINE_API_KEY` als + Umgebungsvariable bzw. dotenv-Datei (kein Key im Plugin). API-Key unter + https://redmine.inmedias.it/my/account. + +```bash +/plugin install redmine@inmedias-claude-plugins +``` + ### grill-me Löchert dich gnadenlos mit Fragen zu einem Plan, einer Entscheidung oder Idee — diff --git a/plugins/redmine/.claude-plugin/plugin.json b/plugins/redmine/.claude-plugin/plugin.json new file mode 100644 index 0000000..b1e7164 --- /dev/null +++ b/plugins/redmine/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "redmine", + "version": "0.1.0", + "description": "Redmine via REST-API: Projekte/Tickets auflisten, anlegen, aktualisieren, kommentieren (redmine.py).", + "author": { + "name": "inmedias.it" + } +} diff --git a/plugins/redmine/skills/redmine/.env.example b/plugins/redmine/skills/redmine/.env.example new file mode 100644 index 0000000..78c4bfe --- /dev/null +++ b/plugins/redmine/skills/redmine/.env.example @@ -0,0 +1,5 @@ +# Redmine REST-API — Zugangsdaten (Kopie nach .env, Werte eintragen) +# API-Key: https://redmine.inmedias.it/my/account → rechts "API-Zugriffsschlüssel" + +REDMINE_URL=https://redmine.inmedias.it +REDMINE_API_KEY= diff --git a/plugins/redmine/skills/redmine/SKILL.md b/plugins/redmine/skills/redmine/SKILL.md new file mode 100644 index 0000000..800c146 --- /dev/null +++ b/plugins/redmine/skills/redmine/SKILL.md @@ -0,0 +1,76 @@ +--- +name: redmine +description: Redmine-Projekte und -Tickets lesen/anlegen/aktualisieren/kommentieren via REST-API. Use when the user mentions "Redmine", "Ticket", "Issue in Redmine", "Meilenstein/Milestone" or das Projekt "pg-wissen". +version: 0.1.0 +--- + +# Redmine (redmine.py) + +Interagiert mit Redmine (https://redmine.inmedias.it) über die REST-API: +Projekte und Tickets auflisten, anzeigen, anlegen, aktualisieren und +kommentieren — über die mitgelieferte, self-contained `redmine.py`. Namen +werden auf IDs aufgelöst (Projekt, Tracker, Status, Priorität). + +## Ausführen + +`redmine.py` ist ein eigenständiges `uv run --script` (Inline-Deps: `requests`, +`python-dotenv`). Aus dem Skill-Verzeichnis: + +```bash +./redmine.py ... +``` + +`--json` (vor dem Befehl) gibt jeweils die Rohdaten aus. + +### Kontext & Stammdaten + +```bash +./redmine.py me # aktueller Benutzer (id, admin?, mail) +./redmine.py projects # sichtbare Projekte (id, identifier, name) +./redmine.py meta trackers # Stammdaten: trackers | statuses | priorities +``` + +### Tickets auflisten / anzeigen + +```bash +./redmine.py issues --project pg-wissen # offene Tickets (Default) +./redmine.py issues --project pg-wissen --status '*' # alle (open|closed|*|Status-ID) +./redmine.py issues --assigned me --sort priority:desc +./redmine.py issues --query "Wiki" --limit 20 # Volltext im Betreff (~-Suche) +./redmine.py show 290 [--notes] # Ticket (optional mit Journalen) +``` + +### Anlegen / Aktualisieren / Kommentieren + +`--project` und `--subject` sind bei `create` Pflicht. Beschreibung inline +(`--description`) oder aus Datei/stdin (`--description-file -`) übergeben — nicht +als langes Shell-Argument (Sonderzeichen). Weitere Felder: `--tracker`, +`--status`, `--priority`, `--assigned me`, `--parent `, `--done <0-100>`. + +```bash +./redmine.py create --project pg-wissen --subject "Titel" \ + --tracker Feature --description-file - < body.md +./redmine.py update 290 --status Erledigt --done 100 --notes "erledigt" +./redmine.py comment 290 "kurzer Kommentar" +``` + +## Zugangsdaten + +`redmine.py` bringt **keinen** Schlüssel mit. Jede/r hinterlegt einen eigenen +API-Key als Umgebungsvariable oder in einer dotenv-Datei (Reihenfolge: +`~/.config/dokumentierer/.env`, dann Datei im Arbeitsverzeichnis, dann bereits +gesetzte Env-Variablen). Siehe `.env.example`: + +- `REDMINE_URL` — Basis-URL (z.B. `https://redmine.inmedias.it`) +- `REDMINE_API_KEY` — persönlicher API-Zugriffsschlüssel unter + https://redmine.inmedias.it/my/account (rechts „API-Zugriffsschlüssel"). Fehlt + der Schlüssel dort, ist der REST-Webservice noch nicht aktiviert + (Administration → Konfiguration → API). + +## Hinweise + +- Schreibzugriff nur in Projekten mit „Add/Edit issues"-Recht; ein Nicht-Admin + kann keine Tickets in fremden Projekten ändern. +- Ein neues Milestone (Version) anzulegen erfordert das Recht *manage_versions* + und ist über `redmine.py` nicht abgedeckt — dafür die Weboberfläche oder die + REST-API (`POST /projects//versions.json`) nutzen. diff --git a/plugins/redmine/skills/redmine/redmine.py b/plugins/redmine/skills/redmine/redmine.py new file mode 100755 index 0000000..593f087 --- /dev/null +++ b/plugins/redmine/skills/redmine/redmine.py @@ -0,0 +1,280 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "requests", +# "python-dotenv", +# ] +# /// +"""CLI für Redmine via REST-API: Projekte/Tickets auflisten, anlegen, aktualisieren, kommentieren. + +Zugangsdaten aus .env: REDMINE_URL, REDMINE_API_KEY. + +Beispiele: + uv run redmine.py me + uv run redmine.py projects + uv run redmine.py issues --project pg-wissen --status open + uv run redmine.py show 42 --notes + uv run redmine.py create --project pg-wissen --subject "Titel" --description - < body.md + uv run redmine.py update 42 --status Erledigt --notes "gefixt" + uv run redmine.py comment 42 "kurzer Kommentar" + uv run redmine.py meta trackers +""" + +import argparse +import json +import os +import sys +from pathlib import Path + +import requests +from dotenv import load_dotenv + + +def session() -> tuple[requests.Session, str]: + load_dotenv(Path.home() / ".config/dokumentierer/.env") + load_dotenv() # CWD/Projektverzeichnis + url = os.environ.get("REDMINE_URL", "").rstrip("/") + key = os.environ.get("REDMINE_API_KEY", "") + if not url or not key: + sys.exit("Fehler: REDMINE_URL und/oder REDMINE_API_KEY fehlen in der .env") + s = requests.Session() + s.headers.update({"X-Redmine-API-Key": key, "Content-Type": "application/json"}) + s.base = url + return s, url + + +def api(s, method: str, path: str, **kw) -> dict: + r = s.request(method, f"{s.base}{path}", **kw) + if not r.ok: + sys.exit(f"Fehler {r.status_code} bei {method} {path}: {r.text[:400]}") + if r.status_code == 204 or not r.content: + return {} + return r.json() + + +def paged(s, path: str, root: str, params: dict | None = None, cap: int = 1000) -> list: + """Alle Seiten einer Redmine-Collection einsammeln.""" + params = dict(params or {}) + out, offset = [], 0 + while True: + params.update({"limit": 100, "offset": offset}) + d = api(s, "GET", path, params=params) + items = d.get(root, []) + out += items + total = d.get("total_count", len(out)) + offset += len(items) + if not items or offset >= total or len(out) >= cap: + break + return out + + +# ---- Auflösen von Namen/Identifiern auf IDs ----------------------------------- + +def resolve_project(s, value: str) -> int: + if value.isdigit(): + return int(value) + for p in paged(s, "/projects.json", "projects"): + if value in (p.get("identifier"), p.get("name")): + return p["id"] + sys.exit(f"Projekt nicht gefunden: {value}") + + +def resolve_enum(s, path: str, root: str, value: str, label: str) -> int: + if value.isdigit(): + return int(value) + items = api(s, "GET", path).get(root, []) + for it in items: + if it.get("name", "").lower() == value.lower(): + return it["id"] + names = ", ".join(i.get("name", "") for i in items) + sys.exit(f"{label} nicht gefunden: {value} (verfügbar: {names})") + + +def resolve_assigned(s, value: str) -> int: + if value in ("me", "@me"): + return api(s, "GET", "/users/current.json")["user"]["id"] + if value.isdigit(): + return int(value) + sys.exit("--assigned erwartet 'me' oder eine numerische User-ID") + + +def read_text(value: str | None, file: str | None) -> str | None: + if file is not None: + return sys.stdin.read() if file == "-" else Path(file).read_text(encoding="utf-8") + return value + + +# ---- Befehle ------------------------------------------------------------------ + +def cmd_me(s, a): + u = api(s, "GET", "/users/current.json")["user"] + if a.json: + return print(json.dumps(u, ensure_ascii=False, indent=2)) + print(f"{u['login']} (id={u['id']}, {u.get('firstname','')} {u.get('lastname','')}, " + f"admin={u.get('admin', False)}, mail={u.get('mail','?')})") + + +def cmd_projects(s, a): + ps = paged(s, "/projects.json", "projects") + if a.json: + return print(json.dumps(ps, ensure_ascii=False, indent=2)) + for p in sorted(ps, key=lambda x: x["id"]): + print(f"#{p['id']:>3} {p['identifier']:<32} {p['name']}") + print(f"— {len(ps)} Projekte") + + +def cmd_issues(s, a): + params = {"status_id": a.status, "sort": a.sort} + if a.project: + params["project_id"] = resolve_project(s, a.project) + if a.assigned: + params["assigned_to_id"] = resolve_assigned(s, a.assigned) + if a.query: + params["subject"] = "~" + a.query + issues = paged(s, "/issues.json", "issues", params, cap=a.limit)[: a.limit] + if a.json: + return print(json.dumps(issues, ensure_ascii=False, indent=2)) + for i in issues: + who = (i.get("assigned_to") or {}).get("name", "—") + print(f"#{i['id']:>5} [{i['status']['name']:<12}] {i['tracker']['name']:<8} " + f"{i['subject'][:70]:<70} ({who})") + print(f"— {len(issues)} Tickets") + + +def cmd_show(s, a): + inc = "journals,attachments,relations,children" if a.notes else "attachments,relations" + i = api(s, "GET", f"/issues/{a.id}.json", params={"include": inc})["issue"] + if a.json: + return print(json.dumps(i, ensure_ascii=False, indent=2)) + print(f"#{i['id']} {i['subject']}") + print(f" Projekt : {i['project']['name']}") + print(f" Status : {i['status']['name']} | Tracker: {i['tracker']['name']} | " + f"Priorität: {i['priority']['name']}") + print(f" Autor : {i['author']['name']} | Zugewiesen: {(i.get('assigned_to') or {}).get('name','—')}") + print(f" Erstellt: {i.get('created_on','')} | Aktualisiert: {i.get('updated_on','')}") + if i.get("description"): + print(" ---\n" + "\n".join(" " + ln for ln in i["description"].splitlines())) + if a.notes: + for j in i.get("journals", []): + if j.get("notes"): + print(f" --- {j['user']['name']} @ {j.get('created_on','')}:\n" + + "\n".join(" " + ln for ln in j["notes"].splitlines())) + + +def build_issue_fields(s, a) -> dict: + f = {} + if getattr(a, "project", None): + f["project_id"] = resolve_project(s, a.project) + if getattr(a, "subject", None) is not None: + f["subject"] = a.subject + desc = read_text(getattr(a, "description", None), getattr(a, "description_file", None)) + if desc is not None: + f["description"] = desc + if getattr(a, "tracker", None): + f["tracker_id"] = resolve_enum(s, "/trackers.json", "trackers", a.tracker, "Tracker") + if getattr(a, "status", None): + f["status_id"] = resolve_enum(s, "/issue_statuses.json", "issue_statuses", a.status, "Status") + if getattr(a, "priority", None): + f["priority_id"] = resolve_enum(s, "/enumerations/issue_priorities.json", + "issue_priorities", a.priority, "Priorität") + if getattr(a, "assigned", None): + f["assigned_to_id"] = resolve_assigned(s, a.assigned) + if getattr(a, "parent", None): + f["parent_issue_id"] = int(a.parent) + if getattr(a, "done", None) is not None: + f["done_ratio"] = int(a.done) + if getattr(a, "notes", None): + f["notes"] = a.notes + return f + + +def cmd_create(s, a): + fields = build_issue_fields(s, a) + if "project_id" not in fields or "subject" not in fields: + sys.exit("create: --project und --subject sind Pflicht") + res = api(s, "POST", "/issues.json", data=json.dumps({"issue": fields})) + i = res["issue"] + print(f"Angelegt: #{i['id']} {i['subject']} -> {s.base}/issues/{i['id']}") + + +def cmd_update(s, a): + fields = build_issue_fields(s, a) + if not fields: + sys.exit("update: nichts zu ändern angegeben") + api(s, "PUT", f"/issues/{a.id}.json", data=json.dumps({"issue": fields})) + print(f"Aktualisiert: #{a.id} ({', '.join(fields)})") + + +def cmd_comment(s, a): + api(s, "PUT", f"/issues/{a.id}.json", data=json.dumps({"issue": {"notes": a.text}})) + print(f"Kommentar an #{a.id} hinzugefügt.") + + +def cmd_meta(s, a): + spec = { + "trackers": ("/trackers.json", "trackers"), + "statuses": ("/issue_statuses.json", "issue_statuses"), + "priorities": ("/enumerations/issue_priorities.json", "issue_priorities"), + }[a.kind] + items = api(s, "GET", spec[0]).get(spec[1], []) + if a.json: + return print(json.dumps(items, ensure_ascii=False, indent=2)) + for it in items: + extra = " (default)" if it.get("is_default") else "" + print(f"#{it['id']:>3} {it['name']}{extra}") + + +def main(): + p = argparse.ArgumentParser(description="Redmine REST-API CLI") + p.add_argument("--json", action="store_true", help="Rohdaten als JSON ausgeben") + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser("me", help="Aktuellen Benutzer anzeigen") + sub.add_parser("projects", help="Projekte auflisten") + + pi = sub.add_parser("issues", help="Tickets auflisten") + pi.add_argument("--project", help="Projekt (ID, Identifier oder Name)") + pi.add_argument("--status", default="open", help="open (Default) | closed | * | Status-ID") + pi.add_argument("--assigned", help="me | User-ID") + pi.add_argument("--query", help="Volltext im Betreff (~-Suche)") + pi.add_argument("--sort", default="updated_on:desc", help="z.B. updated_on:desc, priority:desc") + pi.add_argument("--limit", type=int, default=50) + + ps = sub.add_parser("show", help="Ticket anzeigen") + ps.add_argument("id") + ps.add_argument("--notes", action="store_true", help="Journale/Kommentare mit anzeigen") + + for name, help_ in (("create", "Ticket anlegen"), ("update", "Ticket aktualisieren")): + c = sub.add_parser(name, help=help_) + if name == "update": + c.add_argument("id") + c.add_argument("--project", help="Projekt (ID/Identifier/Name)" + (" [Pflicht]" if name == "create" else "")) + c.add_argument("--subject") + c.add_argument("--description", help="Beschreibungstext") + c.add_argument("--description-file", help="Datei oder '-' für stdin") + c.add_argument("--tracker", help="Tracker (Name oder ID)") + c.add_argument("--status", help="Status (Name oder ID)") + c.add_argument("--priority", help="Priorität (Name oder ID)") + c.add_argument("--assigned", help="me | User-ID") + c.add_argument("--parent", help="übergeordnetes Ticket (ID)") + c.add_argument("--done", type=int, help="Fortschritt in %% (0-100)") + if name == "update": + c.add_argument("--notes", help="Kommentar zur Änderung") + + pc = sub.add_parser("comment", help="Kommentar an ein Ticket hängen") + pc.add_argument("id"); pc.add_argument("text") + + pm = sub.add_parser("meta", help="Stammdaten auflisten") + pm.add_argument("kind", choices=["trackers", "statuses", "priorities"]) + + a = p.parse_args() + s, _ = session() + { + "me": cmd_me, "projects": cmd_projects, "issues": cmd_issues, "show": cmd_show, + "create": cmd_create, "update": cmd_update, "comment": cmd_comment, "meta": cmd_meta, + }[a.cmd](s, a) + + +if __name__ == "__main__": + main()