Files
claude-plugins/plugins/redmine/skills/redmine/redmine.py
T
skoandClaude Opus 5 6613c9ef29 redmine: News und Versionen steuerbar (0.2.0)
Übernimmt den Stand aus inmedias.it/dokumentierer (7097383). Beide
Fassungen waren bis hierhin byte-identisch und sollen es bleiben.

`news` liest, legt an und ändert; `versions` listet und legt an,
`create`/`update` nehmen `--version`.

Der Hinweis in SKILL.md, Versionen seien "über redmine.py nicht
abgedeckt", stimmt damit nicht mehr und ist ersetzt - durch zwei
Eigenheiten, die beim Anlegen des Projekts devops aufgefallen sind:
Redmine begrenzt News-Titel auf 60 Zeichen (sonst HTTP 422), und die
Priorität eines Elterntickets wird aus den Unteraufgaben abgeleitet,
überschreibt also einen von Hand gesetzten Wert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 08:49:55 +02:00

404 lines
16 KiB
Python
Executable File

#!/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
uv run redmine.py versions --project devops
uv run redmine.py versions --project devops --name "Phase 1" --due 2026-10-15
uv run redmine.py create --project devops --subject "Titel" --version "Phase 1"
uv run redmine.py news --project devops
uv run redmine.py news --project devops --title "Kurz" --description-file text.md
uv run redmine.py news --id 1 --description-file korrigiert.md
"""
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 resolve_version(s, project, wert: str) -> int:
"""Version über Name oder ID auflösen.
Ein Name lässt sich nur mit `--project` auflösen, weil Versionen zu einem
Projekt gehören. Beim Ändern eines Tickets ist `--project` aber meist
nicht angegeben - dort ist die Versions-ID anzugeben.
"""
if str(wert).isdigit():
return int(wert)
if not project:
sys.exit("--version mit Namen braucht --project (oder gib die Versions-ID an)")
pid = resolve_project(s, project)
for v in api(s, "GET", f"/projects/{pid}/versions.json").get("versions", []):
if v["name"].lower() == str(wert).lower():
return v["id"]
sys.exit(f"Version '{wert}' nicht in Projekt {pid} gefunden")
def cmd_versions(s, a):
"""Versionen eines Projekts auflisten oder anlegen."""
pid = resolve_project(s, a.project)
if a.name:
f = {"name": a.name}
if a.due:
f["due_date"] = a.due
if a.description:
f["description"] = a.description
v = api(s, "POST", f"/projects/{pid}/versions.json",
data=json.dumps({"version": f}))["version"]
return print(f"Angelegt: Version #{v['id']} {v['name']}")
items = api(s, "GET", f"/projects/{pid}/versions.json").get("versions", [])
if a.json:
return print(json.dumps(items, ensure_ascii=False, indent=2))
if not items:
return print("— keine Versionen")
for v in items:
print(f"#{v['id']:>4} {v['status']:<8} {v.get('due_date') or '—':<12} {v['name']}")
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, "version", None):
f["fixed_version_id"] = resolve_version(s, getattr(a, "project", None), a.version)
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_news(s, a):
"""News lesen, anlegen oder ändern.
Drei Betriebsarten, unterschieden an den Angaben:
ohne --id, ohne --title auflisten
--title anlegen (--project ist Pflicht)
--id ändern (PUT, antwortet mit 204)
Der Titel darf höchstens 60 Zeichen haben; darüber antwortet Redmine
mit HTTP 422. Geprüft wird das hier schon vorab, damit der Fehler beim
Aufruf steht und nicht im Serverprotokoll.
"""
if getattr(a, "id", None):
fields = {}
if a.title:
fields["title"] = a.title
if a.summary is not None:
fields["summary"] = a.summary
body = read_text(a.description, a.description_file)
if body is not None:
fields["description"] = body
if not fields:
sys.exit("news: nichts zu ändern angegeben")
if len(fields.get("title", "")) > 60:
sys.exit("news: Titel darf höchstens 60 Zeichen haben")
api(s, "PUT", f"/news/{a.id}.json", data=json.dumps({"news": fields}))
return print(f"Geändert: News #{a.id} ({', '.join(fields)}) -> {s.base}/news/{a.id}")
if a.title is None:
pid = resolve_project(s, a.project) if a.project else None
path = f"/projects/{pid}/news.json" if pid else "/news.json"
items = api(s, "GET", path, params={"limit": a.limit}).get("news", [])
if a.json:
return print(json.dumps(items, ensure_ascii=False, indent=2))
if not items:
return print("— keine News")
for n in items:
print(f"#{n['id']:>4} {n.get('created_on', '')[:10]} {n['title']}")
return
if not a.project:
sys.exit("news: zum Anlegen ist --project Pflicht")
if len(a.title) > 60:
sys.exit("news: Titel darf höchstens 60 Zeichen haben")
body = read_text(a.description, a.description_file)
if body is None:
sys.exit("news: --description oder --description-file angeben")
pid = resolve_project(s, a.project)
fields = {"title": a.title, "description": body}
if a.summary:
fields["summary"] = a.summary
res = api(s, "POST", f"/projects/{pid}/news.json", data=json.dumps({"news": fields}))
n = res.get("news", {})
if n.get("id"):
print(f"Angelegt: News #{n['id']} {n.get('title', '')} -> {s.base}/news/{n['id']}")
else:
print(f"Angelegt in Projekt {pid} -> {s.base}/projects/{pid}/news")
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("--version", help="Zielversion (Name oder 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")
pn = sub.add_parser("news", help="News lesen, anlegen oder ändern")
pn.add_argument("--project", help="Projekt (ID/Identifier/Name); zum Anlegen Pflicht")
pn.add_argument("--id", help="News-ID - gesetzt heißt: ändern")
pn.add_argument("--title", help="Titel (max. 60 Zeichen); gesetzt heißt: anlegen statt auflisten")
pn.add_argument("--summary", help="Kurzfassung (optional, erscheint in der Übersicht)")
pn.add_argument("--description", help="Text")
pn.add_argument("--description-file", help="Datei oder '-' für stdin")
pn.add_argument("--limit", type=int, default=25)
pv = sub.add_parser("versions", help="Versionen auflisten oder anlegen")
pv.add_argument("--project", required=True, help="Projekt (ID/Identifier/Name)")
pv.add_argument("--name", help="Name - gesetzt heißt: anlegen statt auflisten")
pv.add_argument("--due", help="Fälligkeitsdatum (YYYY-MM-DD)")
pv.add_argument("--description", help="Beschreibung")
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,
"news": cmd_news, "versions": cmd_versions, "meta": cmd_meta,
}[a.cmd](s, a)
if __name__ == "__main__":
main()