Files
claude-plugins/plugins/redmine/skills/redmine/redmine.py
T
skoandClaude Opus 4.8 dc3e6a0c68 redmine: Plugin für Redmine via REST-API hinzufügen
Self-contained redmine.py (me/projects/issues/show/create/update/comment/
meta) mit Namensaufloesung (Projekt/Tracker/Status/Prioritaet). SKILL.md,
plugin.json, Beispiel-Konfig; Eintrag in marketplace.json und README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 09:42:25 +02:00

281 lines
11 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
"""
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()