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>
This commit is contained in:
sko
2026-08-28 09:42:25 +02:00
co-authored by Claude Opus 4.8
parent 28ec699e51
commit dc3e6a0c68
6 changed files with 390 additions and 0 deletions
@@ -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=
+76
View File
@@ -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 <befehl> ...
```
`--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 <id>`, `--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/<id>/versions.json`) nutzen.
+280
View File
@@ -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()