1 Commits
Author SHA1 Message Date
jhsandClaude Opus 5 15dedb3d3f humhub: Umfragen anlegen und auflisten (poll-create / poll-list)
Die Polls-REST-API ist auf der Instanz aktiv, der Skill kannte sie nicht.
Zwei Abweichungen vom Muster der Post-API, die beide in
HTTP 500 'Internal error while save a poll!' enden:

* Der Controller macht $poll->load(Yii::$app->request->post()) — die Felder
  gehoeren unter den Yii-Formnamen 'Poll', nicht unter 'data'.
* Die Optionen heissen beim Anlegen 'newAnswers'; 'answers' ist die Leseform
  und wird beim POST ignoriert.

Beim Lesen ist limit Pflicht: ohne Begrenzung antwortet die Instanz ab etwa
25 Umfragen ebenfalls mit 500.

Quelle: humhub/polls, controllers/rest/PollsController.php + models/Poll.php.
Verifiziert an Space inmedias (Umfrage 36).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 07:58:20 +02:00
13 changed files with 31 additions and 672 deletions

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@
{
"name": "redmine",
"source": "./plugins/redmine",
"description": "Redmine via REST-API: Projekte, Tickets, Versionen, News und Projekt-Wikis auflisten, anzeigen, anlegen, aktualisieren und kommentieren via redmine.py (API-Key lokal)."
"description": "Redmine via REST-API: Projekte/Tickets auflisten, anzeigen, anlegen, aktualisieren und kommentieren via redmine.py (API-Key lokal)."
},
{
"name": "grill-me",
-6
View File
@@ -1,6 +0,0 @@
# Python
__pycache__/
*.py[cod]
# lokale Umgebungs-/Secret-Dateien
.env
+5 -5
View File
@@ -110,11 +110,11 @@ self-contained `wiki.py` (`fetch`/`render`/`search`/`update`). Instanzen `wiki`
### redmine
Redmine (redmine.inmedias.it) über die REST-API: Projekte, Tickets, Versionen
und News auflisten, anzeigen, anlegen, aktualisieren und kommentieren — via
mitgelieferter, self-contained `redmine.py` (`me`/`projects`/`issues`/`show`/
`create`/`update`/`comment`/`news`/`versions`/`meta`). Namen (Projekt/Tracker/
Status/Priorität/Version) werden auf IDs aufgelöst.
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
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "humhub",
"version": "0.4.1",
"description": "HumHub via REST-API: Wiki-Seiten, Posts, Umfragen und Kommentare auflisten, anzeigen, anlegen und aktualisieren (humhub.py).",
"version": "0.2.0",
"description": "HumHub via REST-API: Wiki-Seiten, Posts und Umfragen auflisten, anzeigen, anlegen und aktualisieren (humhub.py).",
"author": {
"name": "inmedias.it"
}
-77
View File
@@ -80,89 +80,12 @@ und nicht Komfort — ohne Begrenzung antwortet die Instanz ab ~25 Umfragen
ebenfalls mit 500. Ein fehlgeschlagener POST legt nichts an; vor einem zweiten
Versuch trotzdem mit `poll-list` prüfen.
### Kommentare
```bash
./humhub.py comment-list <content-id|perma-url> # Kommentare eines Inhalts
./humhub.py comment-create <content-id|perma-url> [datei|-] # Kommentar schreiben
```
Beide nehmen wie `fetch` eine **Content-ID** oder Perma-URL (nicht die Post-/
Wiki-Seiten-ID) und lösen das Zielobjekt selbst auf — funktioniert für Posts
und Wiki-Seiten.
### Direktnachrichten
```bash
./humhub.py mail-send <empfaenger> "<betreff>" [datei|-] # Probelauf
./humhub.py mail-send <empfaenger> "<betreff>" [datei|-] --send # senden
./humhub.py mail-send https://humhub.inmedias.it/u/serge/ "Betreff" entwurf.md
./humhub.py mail-send jan.stobbe,26 "Betreff" - --send
./humhub.py mail-reply <konversations-id> [datei|-]
./humhub.py mail-user-remove <konversations-id> <empfaenger>
```
Empfänger sind Benutzername, Profil-URL oder numerische ID; mehrere mit Komma.
**Ohne `--send` wird nichts gesendet**, sondern nur aufgelöst und angezeigt,
wer die Nachricht bekäme — der Probelauf ist der Default.
**`recipient` will GUIDs, keine User-IDs.** `mail-send` löst jede Angabe
deshalb bis zur GUID auf (`/user/<id>` liefert sie, die Benutzerliste je nach
Fassung nicht). Mit numerischen IDs antwortet die API `400 Validation failed`
und „Du kannst keine Nachricht ohne Empfänger senden!" — die Meldung klingt
nach einem leeren Feld, obwohl das Feld gefüllt ist, und weist damit in die
falsche Richtung.
`mail-reply` hängt einen Beitrag an eine bestehende Konversation
(`POST /mail/<id>/entry`). Die ID steht in der URL der Konversation:
`…/mail/mail/index?id=35`.
**Eine Fehlzustellung lässt sich nicht löschen.** `DELETE /mail/<id>` gibt es
in dieser API-Version nicht (404, Route unbekannt), und über die Oberfläche
kommt man auch nicht dran. Das einzige Gegenmittel ist
`mail-user-remove`, das einen Teilnehmer wieder aus der Konversation nimmt
(`DELETE /mail/<id>/user/<userid>`). Ob der Empfänger vorher schon eine
Benachrichtigung bekommen hat, bleibt dabei offen.
**Deshalb prüft `mail-send` nach dem Versand die Teilnehmerliste.** Eine
falsche Kennung in `recipient` führt nicht verlässlich zu einer
Fehlermeldung: am 25.09.2026 hat die API einen Versand mit numerischen IDs
mit `HTTP 200` quittiert und die Nachricht an einen **anderen** Benutzer
zugestellt. Der Befehl gibt deshalb aus, wer die Konversation tatsächlich
bekommen hat, und nennt bei einer Abweichung das `mail-user-remove`-Kommando.
Die Teilnehmerliste kann direkt nach dem Anlegen unvollständig sein — im
Zweifel noch einmal nachsehen, bevor man daraus einen Schluss zieht.
Zwei Routen, zwei Kennungen: `POST /mail` will die **GUID**,
`DELETE /mail/<id>/user/<userid>` die **numerische ID**. Beides ist im
Skript gekapselt, `mail-user-remove` nimmt wie alle Befehle Benutzername,
Profil-URL oder ID.
**Slug ≠ Benutzername.** Zur Profil-URL `/u/serge/` gehört der Benutzername
`Serge`, und `get-by-username` unterscheidet Groß-/Kleinschreibung. Wer den
Slug einsetzt, bekommt sonst `User not found!`. `mail-send` fängt das ab: erst
`get-by-username`, dann ein Durchlauf der Benutzerliste, der Benutzername und
Slug ohne Rücksicht auf Groß-/Kleinschreibung vergleicht.
Zum Lesen von Konversationen gibt es bewusst keinen Befehl — Nachrichten
anderer gehören nicht ungefragt in einen Modellkontext. Auch `mail-reply`
holt den Verlauf nicht ab, sondern prüft nur, ob die Konversation erreichbar
ist.
Inhalte (Markdown) aus Datei oder `-` (stdin) übergeben — nicht als langes
Shell-Argument (Sonderzeichen). `container_id` akzeptiert eine numerische
Container-ID, einen **Space-Slug** oder eine **Space-URL** (`resolve_container`
löst Slug/URL selbst auf). Passende Container per `space-list` ermitteln, Seiten
per `wiki-list` / `post-list`.
**HumHub macht aus Zeichenfolgen wie `:)` Emojis — auch mitten im Fachtext.**
Am 08.10.2026 wurde aus „(0.0.0.0 und ::)“ ein „(0.0.0.0 und :😃“: der zweite
Doppelpunkt der IPv6-Adresse `::` bildete mit der schließenden Klammer ein
Smiley. Betroffen sind typisch IPv6-Adressen (`::`, `::1`, `[::]:3306`),
Portangaben und Pfade direkt vor `)` oder `(`. **Solche Werte immer in
Backticks setzen** — in Inline-Code ersetzt HumHub nichts. Vor dem Absenden
den Text außerhalb von Backticks auf `:)`, `:(`, `;)`, `:D`, `:P` prüfen.
## Zugangsdaten
`humhub.py` bringt **keinen** Token mit. Jede/r hinterlegt ein eigenes
-238
View File
@@ -306,211 +306,6 @@ def cmd_space_list(args: argparse.Namespace) -> None:
print("(keine Spaces gefunden)")
# ── Comment commands ─────────────────────────────────────────────────────────
def cmd_comment_list(args: argparse.Namespace) -> None:
session, _ = get_session()
content_id = parse_content_ref(args.ref)
data = api_get(session, f"/comment/content/{content_id}")
results = data.get("results", data if isinstance(data, list) else [])
if not results:
print("(keine Kommentare)")
return
for c in results:
cid = c.get("id")
author = (c.get("createdBy") or {}).get("display_name", "?")
created = c.get("createdAt", "")
msg = (c.get("message") or "").replace("\n", " ")[:100]
print(f"ID {cid:>5} [{created}] {author}: {msg}")
def cmd_comment_create(args: argparse.Namespace) -> None:
session, _ = get_session()
content_id = parse_content_ref(args.ref)
message = read_file(args.file)
envelope = api_get(session, f"/content/{content_id}")
meta = envelope.get("metadata", {})
model = meta.get("object_model", "")
object_id = meta.get("object_id")
if not model or not object_id:
sys.exit(f"Error: Content {content_id} nicht auflösbar (object_model/object_id fehlen)")
payload = {
"objectModel": model,
"objectId": object_id,
"Comment": {"message": message},
}
data = api_post(session, "/comment", payload)
cid = data.get("id")
short_model = model.split("\\")[-1]
print(f"Kommentar erstellt (ID {cid}) auf {short_model} {object_id}")
print(f"Perma: https://humhub.inmedias.it/content/perma?id={content_id}")
# ── Mail (Direktnachrichten) ─────────────────────────────────────────────────
def user_guid(session: requests.Session, user: dict) -> str:
"""GUID eines Benutzers, notfalls über /user/<id> nachgeladen.
Die Benutzerliste liefert die GUID je nach Fassung nicht mit.
"""
guid = user.get("guid") or (user.get("account") or {}).get("guid")
if guid:
return str(guid)
uid = user.get("id") or (user.get("account") or {}).get("id")
return str(api_get(session, f"/user/{uid}")["guid"])
def resolve_user_info(session: requests.Session, value: str) -> dict:
"""Benutzerdatensatz zu numerischer ID, Benutzername oder Profil-URL.
Slug und Benutzername sind nicht dasselbe: zu https://…/u/serge/ gehört der
Benutzername "Serge". `get-by-username` kennt nur den Benutzernamen und
unterscheidet Groß-/Kleinschreibung — deshalb der Fallback über die
Benutzerliste, der auch den Slug vergleicht.
"""
value = value.strip()
if value.isdigit():
return api_get(session, f"/user/{int(value)}")
name = value
if "/" in value:
teile = [t for t in urlparse(value).path.split("/") if t]
if teile:
name = teile[-1]
r = session.get(f"{BASE_URL}/user/get-by-username",
params={"username": name})
if r.ok:
return r.json()
page = 1
while True:
data = api_get(session, f"/user?limit=50&page={page}")
results = data.get("results", [])
for u in results:
username = (u.get("account") or {}).get("username", "")
slug_teile = [t for t in urlparse(u.get("url", "")).path.split("/") if t]
slug = slug_teile[-1] if slug_teile else ""
if name.lower() in (username.lower(), slug.lower()):
return u
if len(results) < 50:
break
page += 1
sys.exit(f"Error: Benutzer {value!r} nicht gefunden")
def resolve_user(session: requests.Session, value: str) -> str:
"""Resolve a user ID, username, or profile URL (…/u/<slug>/) to a user GUID.
**GUID, nicht ID.** `POST /mail` nimmt als `recipient` ausschließlich
GUIDs an; mit numerischen IDs antwortet die API `400 Validation failed`
und „Du kannst keine Nachricht ohne Empfänger senden!" — eine Meldung,
die nach einem leeren Feld klingt, obwohl das Feld gefüllt ist.
"""
return user_guid(session, resolve_user_info(session, value))
def mail_teilnehmer(session: requests.Session, conv_id: int) -> list[dict]:
"""Teilnehmer einer Konversation. Leere Liste, wenn nicht lesbar."""
r = session.get(f"{BASE_URL}/mail/{conv_id}/users")
if not r.ok:
return []
daten = r.json()
return daten if isinstance(daten, list) else daten.get("results", [])
def eigene_guid(session: requests.Session) -> str:
"""GUID des eigenen Kontos, leer wenn nicht ermittelbar."""
r = session.get(f"{BASE_URL}/user/me")
return str(r.json().get("guid", "")) if r.ok else ""
def cmd_mail_send(args: argparse.Namespace) -> None:
session, _ = get_session()
leute = [resolve_user_info(session, teil)
for teil in args.to.split(",") if teil.strip()]
if not leute:
sys.exit("Error: kein Empfänger angegeben")
guids = [user_guid(session, u) for u in leute]
namen = {g: u.get("display_name", "?") for g, u in zip(guids, leute)}
for g in guids:
print(f"Empfänger: {namen[g]} ({g})")
if not args.send:
print("PROBELAUF — nichts gesendet. Mit --send abschicken.")
return
message = read_file(args.file)
data = api_post(session, "/mail", {
"title": args.title,
"message": message,
"recipient": guids,
})
conv_id = data.get("id")
print(f"Nachricht gesendet (Konversation {conv_id})")
# Gegenprobe. Am 25.09.2026 hat die API eine Nachricht mit numerischen IDs
# in `recipient` mit HTTP 200 quittiert und sie an einen *anderen*
# Benutzer zugestellt — sie meldet einen falschen Empfaenger also nicht
# zwingend als Fehler. Deshalb nach jedem Versand nachsehen, wer die
# Konversation tatsaechlich bekommen hat. Loeschen laesst sie sich nicht
# (`DELETE /mail/<id>` gibt es nicht), nur Teilnehmer entfernen.
teilnehmer = mail_teilnehmer(session, conv_id)
if not teilnehmer:
print("ACHTUNG: Teilnehmerliste nicht lesbar — Zustellung nicht geprüft.")
return
print("Teilnehmer laut Server:")
for u in teilnehmer:
print(f" {u.get('display_name')} ({u.get('guid')})")
drin = {str(u.get("guid")) for u in teilnehmer}
ich = eigene_guid(session)
fehlend = [g for g in guids if g not in drin]
fremd = [u for u in teilnehmer
if str(u.get("guid")) not in guids and str(u.get("guid")) != ich]
if fehlend:
print("ACHTUNG: nicht zugestellt an " +
", ".join(f"{namen[g]} ({g})" for g in fehlend))
if fremd:
print("ACHTUNG: unerwartete Teilnehmer — entfernen mit:")
for u in fremd:
print(f" ./humhub.py mail-user-remove {conv_id} {u.get('id')}")
def cmd_mail_user_remove(args: argparse.Namespace) -> None:
"""Nimmt einen Teilnehmer aus einer Konversation.
Der einzige Weg, eine Fehlzustellung zurueckzunehmen: die Konversation
selbst laesst sich weder ueber die API noch ueber die Oberflaeche
loeschen. Anders als beim Versand will diese Route die **numerische ID**,
nicht die GUID.
"""
session, _ = get_session()
user = resolve_user_info(session, args.user)
uid = user.get("id")
r = session.delete(f"{BASE_URL}/mail/{args.id}/user/{uid}")
if not r.ok:
sys.exit(f"Error {r.status_code}: {r.text[:200]}")
print(f"{user.get('display_name')} (ID {uid}) aus Konversation "
f"{args.id} entfernt. Verbleibend:")
for u in mail_teilnehmer(session, args.id):
print(f" {u.get('display_name')} ({u.get('guid')})")
def cmd_mail_reply(args: argparse.Namespace) -> None:
"""Antwortet in einer bestehenden Konversation.
Bewusst ohne Lesebefehl: der Text der Konversation wird nicht abgerufen,
nur ihre Erreichbarkeit geprueft.
"""
session, _ = get_session()
message = read_file(args.file)
r = session.get(f"{BASE_URL}/mail/{args.id}")
if not r.ok:
sys.exit(f"Error {r.status_code}: Konversation {args.id} nicht erreichbar")
data = api_post(session, f"/mail/{args.id}/entry", {"message": message})
print(f"Antwort geschrieben (Eintrag {data.get('id')}) in Konversation {args.id}")
# ── Fetch (perma-/content-id-agnostisch) ─────────────────────────────────────
def parse_content_ref(ref: str) -> int:
@@ -665,39 +460,6 @@ def main() -> None:
p.add_argument("--topics", default=None, help="Komma-getrennte Topic-Namen")
p.set_defaults(func=cmd_post_create)
# comment-list
p = sub.add_parser("comment-list", help="Kommentare eines Inhalts auflisten")
p.add_argument("ref", help="Content-ID oder Perma-Link (…/content/perma?id=NNNN)")
p.set_defaults(func=cmd_comment_list)
# comment-create
p = sub.add_parser("comment-create", help="Kommentar auf einen Inhalt (Post/Wiki-Seite) schreiben")
p.add_argument("ref", help="Content-ID oder Perma-Link (…/content/perma?id=NNNN)")
p.add_argument("file", help="Markdown-Datei oder '-' für stdin")
p.set_defaults(func=cmd_comment_create)
# mail-send
p = sub.add_parser("mail-send", help="Direktnachricht an Benutzer senden")
p.add_argument("to", help="Benutzername, Profil-URL oder ID; mehrere mit Komma")
p.add_argument("title", help="Betreff")
p.add_argument("file", help="Markdown-Datei oder '-' für stdin")
p.add_argument("--send", action="store_true",
help="wirklich senden (ohne: Probelauf mit Empfängernamen)")
p.set_defaults(func=cmd_mail_send)
# mail-user-remove
p = sub.add_parser("mail-user-remove",
help="Teilnehmer aus einer Konversation entfernen (Fehlzustellung)")
p.add_argument("id", type=int, help="Konversations-ID")
p.add_argument("user", help="Benutzername, Profil-URL oder ID")
p.set_defaults(func=cmd_mail_user_remove)
# mail-reply
p = sub.add_parser("mail-reply", help="In einer bestehenden Konversation antworten")
p.add_argument("id", type=int, help="Konversations-ID (aus /mail/mail/index?id=NN)")
p.add_argument("file", help="Markdown-Datei oder '-' für stdin")
p.set_defaults(func=cmd_mail_reply)
# post-update
p = sub.add_parser("post-update", help="Post aktualisieren")
p.add_argument("id", type=int, help="Post-ID")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mediawiki",
"version": "0.1.1",
"version": "0.1.0",
"description": "MediaWiki-Seiten/Templates lesen, rendern und schreiben via wiki.py (fetch/update/render/search, Instanzen wiki & devwiki).",
"author": {
"name": "inmedias.it"
+2 -25
View File
@@ -1,7 +1,7 @@
---
name: mediawiki
description: MediaWiki-Seiten und -Templates lesen, rendern, schreiben und deren Parser-Cache leeren. Use when editing/fetching wiki pages (wiki.inmedias.it, devwiki.inmedias.it) or when the user mentions "wiki", "Vorlage/Template", "Kundenseite", or when a page shows stale API data.
version: 0.2.0
description: MediaWiki-Seiten und -Templates lesen, rendern und schreiben. Use when editing/fetching wiki pages (wiki.inmedias.it, devwiki.inmedias.it) or when the user mentions "wiki", "Vorlage/Template", "Kundenseite".
version: 0.1.0
---
# MediaWiki (wiki.py)
@@ -37,8 +37,6 @@ oder anderen direkten Server-Eingriffen. Reine Lese-/Render-Checks
./wiki.py -w devwiki search "Suchbegriff" [-n <namespace>] # Titelsuche
./wiki.py -w devwiki update "Kunde:Beispiel GmbH" datei.wiki --summary "..." # speichern
./wiki.py -w wiki update "Titel" - --summary "..." # Inhalt von stdin
./wiki.py -w wiki purge "Titel" ["Titel 2" ...] [--links] # Parser-Cache leeren
./wiki.py -w wiki upload /pfad/bild.png bild.png --description "..." # Datei hochladen
```
Tipp: Beim Schreiben den Inhalt aus einer Datei (`datei.wiki`) oder `-` (stdin)
@@ -59,27 +57,6 @@ dann `.env` im aktuellen Verzeichnis, dann bereits gesetzte Env-Variablen). Sieh
Es empfiehlt sich ein eigener Bot-/Service-Account mit den nötigen Schreibrechten.
## Seiten, die ihre Daten aus einer API holen
Vorlagen mit `#get_web_data` (External Data) werden **nicht** bei jedem
Seitenaufruf ausgeführt. MediaWiki liefert die Seite aus dem Parser-Cache und
fragt die API gar nicht erst an. Ändern sich die Daten hinter der API, zeigt
die Seite weiter beliebig alte Werte — ohne Fehlermeldung, ohne dass es
jemandem auffällt.
Deshalb gehört nach jedem Datenupload ein Purge:
```bash
./wiki.py -w wiki purge "Nextcloud (Hosting)"
```
`--links` schaltet zusätzlich `forcelinkupdate` ein (langsamer, aktualisiert
die Linktabellen) — für reine Datenaktualisierungen nicht nötig.
Das betrifft auch die Fehlersuche: Wer nach einer Änderung `render` aufruft und
die alten Werte sieht, hat meist nicht die Daten kaputt, sondern nur den Cache
vor sich. Erst purgen, dann urteilen.
## Hinweise
- `search` liefert Titel; `render` ist nützlich, um vor/nach dem Schreiben das
Ergebnis zu prüfen, ohne die Seite im Browser zu öffnen.
+7 -39
View File
@@ -88,20 +88,13 @@ def cmd_search(args: argparse.Namespace) -> None:
sys.exit(f"Error: unknown namespace '{ns_key}'. "
f"Known: all, {', '.join(NAMESPACES)}")
try:
result = site.api(
"query", list="search",
srsearch=args.query,
srnamespace=ns,
srlimit=50,
srprop="title|snippet",
)
except mwclient.errors.APIError as exc:
if exc.code == "cirrussearch-parse-error":
sys.exit(f"Error: Suchanfrage nicht verstanden: {args.query!r}\n"
f"Hinweis: 'insource:wort' funktioniert, die Regex-Form "
f"'insource:/muster/' ist auf dieser Instanz nicht freigeschaltet.")
sys.exit(f"Error: Suche fehlgeschlagen ({exc.code}): {exc.info}")
result = site.api(
"query", list="search",
srsearch=args.query,
srnamespace=ns,
srlimit=50,
srprop="title|snippet",
)
hits = result["query"]["search"]
if not hits:
print("(keine Ergebnisse)", file=sys.stderr)
@@ -117,25 +110,6 @@ def cmd_render(args: argparse.Namespace) -> None:
print(result["parse"]["text"]["*"], end="")
def cmd_purge(args: argparse.Namespace) -> None:
"""Parser-Cache einer oder mehrerer Seiten leeren.
Noetig fuer Seiten, die ihre Daten per #get_web_data aus einer API holen:
MediaWiki rendert sonst aus dem Parser-Cache weiter und fragt die API gar
nicht erst an - die Seite zeigt dann beliebig alte Werte, ohne dass das
irgendwo auffaellt.
"""
site = get_site(args.wiki)
result = site.api("purge", titles="|".join(args.titles),
forcelinkupdate=1 if args.links else None)
for entry in result.get("purge", []):
title = entry.get("title", "?")
if "missing" in entry:
print(f"Error: page '{title}' does not exist", file=sys.stderr)
else:
print(f"Purged '{title}'")
def cmd_upload(args: argparse.Namespace) -> None:
path = Path(args.file_path)
if not path.exists():
@@ -187,12 +161,6 @@ def main() -> None:
render_p.add_argument("title", help="Page title")
render_p.set_defaults(func=cmd_render)
purge_p = sub.add_parser("purge", help="Clear the parser cache of one or more pages")
purge_p.add_argument("titles", nargs="+", help="Page title(s)")
purge_p.add_argument("--links", action="store_true",
help="Also run forcelinkupdate (slower, updates link tables)")
purge_p.set_defaults(func=cmd_purge)
upload_p = sub.add_parser("upload", help="Upload a local file to the wiki file store")
upload_p.add_argument("file_path", help="Absolute path to the local file")
upload_p.add_argument("wiki_filename", help="Target filename on the wiki (without 'File:' prefix)")
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "redmine",
"version": "0.3.0",
"description": "Redmine via REST-API: Projekte/Tickets/Versionen/News/Wiki-Seiten auflisten, anlegen, aktualisieren, kommentieren (redmine.py).",
"version": "0.1.0",
"description": "Redmine via REST-API: Projekte/Tickets auflisten, anlegen, aktualisieren, kommentieren (redmine.py).",
"author": {
"name": "inmedias.it"
}
+10 -81
View File
@@ -1,16 +1,15 @@
---
name: redmine
description: Redmine-Projekte, -Tickets, -Versionen, -News und Projekt-Wikis lesen/anlegen/aktualisieren via REST-API. Use when the user mentions "Redmine", "Ticket", "Issue in Redmine", "Meilenstein/Milestone/Version", "Projektstand/News", "Redmine-Wiki" or die Projekte "pg-wissen"/"devops".
version: 0.3.0
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, Tickets, Versionen, News und Projekt-Wikis auflisten, anzeigen,
anlegen, aktualisieren und kommentieren - über die mitgelieferte, self-contained
`redmine.py`. Namen werden auf IDs aufgelöst (Projekt, Tracker, Status,
Priorität, Version).
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
@@ -44,10 +43,9 @@ Priorität, Version).
### Anlegen / Aktualisieren / Kommentieren
`--project` und `--subject` sind bei `create` Pflicht. Beschreibung inline
(`--description`) oder aus Datei/stdin (`--description-file -`) übergeben - nicht
(`--description`) oder aus Datei/stdin (`--description-file -`) übergeben — nicht
als langes Shell-Argument (Sonderzeichen). Weitere Felder: `--tracker`,
`--status`, `--priority`, `--assigned me`, `--parent <id>`, `--version <name|id>`,
`--done <0-100>`.
`--status`, `--priority`, `--assigned me`, `--parent <id>`, `--done <0-100>`.
```bash
./redmine.py create --project pg-wissen --subject "Titel" \
@@ -56,72 +54,6 @@ als langes Shell-Argument (Sonderzeichen). Weitere Felder: `--tracker`,
./redmine.py comment 290 "kurzer Kommentar"
```
### Versionen (Meilensteine)
Versionen tragen die grobe Reihenfolge und speisen die Roadmap-Ansicht.
```bash
./redmine.py versions --project devops # auflisten
./redmine.py versions --project devops --name "Phase 1" \
--due 2026-10-15 --description "Was in dieser Phase zu tun ist"
./redmine.py create --project devops --subject "Schritt" --version "Phase 1"
./redmine.py update 350 --version 23 # ohne --project: ID
```
Ein Versionsname lässt sich nur mit `--project` auflösen, weil Versionen zu
einem Projekt gehören. Bei `update` ist `--project` meist nicht angegeben -
dort die Versions-ID verwenden.
Anlegen erfordert das Recht *manage_versions*.
### News (Projektstand, Ankündigungen)
```bash
./redmine.py news --project devops # auflisten
./redmine.py news --project devops --title "Kurzer Titel" \
--summary "Eine Zeile für die Übersicht" \
--description-file text.md
./redmine.py news --id 1 --description-file korrigiert.md # ändern
```
Zwei Eigenheiten der Redmine-API:
- **Der Titel darf höchstens 60 Zeichen haben**, sonst HTTP 422. `redmine.py`
prüft das vorab.
- Die Antwort beim Anlegen enthält **keine News-ID**; der Befehl gibt deshalb
die Projekt-URL aus statt eines toten Direktlinks. Ändern per `--id` geht
trotzdem (`PUT /news/{id}.json`).
Format ist Markdown, wie bei Tickets.
### Projekt-Wiki
```bash
./redmine.py wiki --project devops # Seiten auflisten
./redmine.py wiki --project devops --title Wiki # Seite anzeigen
./redmine.py wiki --project devops --title Wiki --version 2
./redmine.py wiki --project devops --title "Bericht" \
--text-file bericht.md --comment "Erhebung vom 13.10."
./redmine.py wiki --project devops --title "Unterseite" \
--text-file u.md --parent "Bericht" # Seite einhängen
```
Drei Eigenheiten:
- **Anlegen und Ändern sind derselbe `PUT`.** Ein Tippfehler im Titel erzeugt
sonst stillschweigend eine neue Seite. `redmine.py` sieht deshalb vorher nach
und meldet „Angelegt" bzw. „Geändert".
- **Leerzeichen werden zu Unterstriche.** Redmine normalisiert den Titel; beide
Schreibweisen treffen dieselbe Seite.
- **Die Startseite heißt `Wiki`.** Existiert sie nicht, ist das Projekt-Wiki
leer und `/projects/<kennung>/wiki` läuft ins Leere. Eine Seite mit genau
diesem Titel legt die Startseite an.
Löschen deckt `redmine.py` bewusst nicht ab - dafür
`DELETE /projects/<kennung>/wiki/<Titel>.json`.
## Zugangsdaten
`redmine.py` bringt **keinen** Schlüssel mit. Jede/r hinterlegt einen eigenen
@@ -139,9 +71,6 @@ gesetzte Env-Variablen). Siehe `.env.example`:
- Schreibzugriff nur in Projekten mit „Add/Edit issues"-Recht; ein Nicht-Admin
kann keine Tickets in fremden Projekten ändern.
- **Ticket-Beziehungen** (`blocks`, `relates`, `precedes`) deckt `redmine.py`
nicht ab - dafür `POST /issues/<id>/relations.json`. Ein Ticket kann nicht mit
einem eigenen Unterticket verknüpft werden (HTTP 422).
- Die Priorität eines Elterntickets leitet Redmine aus den Unteraufgaben ab;
ein von Hand gesetzter Wert wird überschrieben. Abschaltbar unter
Administration → Konfiguration → „Attribute übergeordneter Tickets".
- 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.
+1 -195
View File
@@ -19,15 +19,6 @@ Beispiele:
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
uv run redmine.py wiki --project devops
uv run redmine.py wiki --project devops --title Wiki
uv run redmine.py wiki --project devops --title Bericht --text-file bericht.md
"""
import argparse
@@ -35,7 +26,6 @@ import json
import os
import sys
from pathlib import Path
from urllib.parse import quote
import requests
from dotenv import load_dotenv
@@ -172,45 +162,6 @@ def cmd_show(s, a):
+ "\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):
@@ -231,8 +182,6 @@ def build_issue_fields(s, a) -> dict:
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):
@@ -262,123 +211,6 @@ def cmd_comment(s, a):
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_wiki(s, a):
"""Wiki-Seiten eines Projekts auflisten, lesen oder schreiben.
Drei Betriebsarten, unterschieden an den Angaben:
ohne --title Seiten des Projekts auflisten
--title Seite anzeigen
--title mit --text/--text-file Seite schreiben (PUT legt an oder ändert)
Redmine nimmt zum Schreiben denselben PUT für Anlegen und Ändern. Damit ein
Tippfehler im Titel nicht unbemerkt eine neue Seite erzeugt, wird vorher
nachgesehen, ob die Seite schon existiert, und das Ergebnis entsprechend
gemeldet.
Die Startseite eines Projekt-Wikis heißt bei Redmine standardmäßig `Wiki`.
"""
pid = resolve_project(s, a.project)
base = f"/projects/{pid}/wiki"
if not a.title:
pages = api(s, "GET", f"{base}/index.json").get("wiki_pages", [])
if a.json:
return print(json.dumps(pages, ensure_ascii=False, indent=2))
if not pages:
return print("— keine Wiki-Seiten")
for w in sorted(pages, key=lambda x: x["title"]):
eltern = w.get("parent", {}).get("title")
print(f" v{w.get('version', '?'):<3} {str(w.get('updated_on', ''))[:10]} "
f"{w['title']}" + (f" (unter {eltern})" if eltern else ""))
return
titel = quote(a.title, safe="")
text = read_text(a.text, a.text_file)
if text is None:
pfad = f"{base}/{titel}" + (f"/{a.version}" if a.version else "") + ".json"
w = api(s, "GET", pfad).get("wiki_page", {})
if a.json:
return print(json.dumps(w, ensure_ascii=False, indent=2))
autor = w.get("author", {}).get("name", "?")
print(f"# {w.get('title')} (v{w.get('version')}, {autor}, "
f"{str(w.get('updated_on', ''))[:16]})")
print(f"# {s.base}/projects/{pid}/wiki/{titel}")
print("---")
return print(w.get("text", ""))
seite = {"text": text}
if a.comment:
seite["comments"] = a.comment
if a.parent:
seite["parent_title"] = a.parent
# Vorher nachsehen: 404 heißt anlegen, alles andere ändern.
r = s.request("GET", f"{s.base}{base}/{titel}.json")
neu = r.status_code == 404
api(s, "PUT", f"{base}/{titel}.json", data=json.dumps({"wiki_page": seite}))
print(f"{'Angelegt' if neu else 'Geändert'}: {a.title} "
f"-> {s.base}/projects/{pid}/wiki/{titel}")
def cmd_meta(s, a):
spec = {
"trackers": ("/trackers.json", "trackers"),
@@ -426,7 +258,6 @@ def main():
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")
@@ -434,30 +265,6 @@ def main():
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")
pw = sub.add_parser("wiki", help="Wiki-Seiten auflisten, lesen oder schreiben")
pw.add_argument("--project", required=True, help="Projekt (ID/Identifier/Name)")
pw.add_argument("--title", help="Seitentitel - ohne Angabe werden die Seiten aufgelistet")
pw.add_argument("--text", help="Seiteninhalt; gesetzt heißt: schreiben statt anzeigen")
pw.add_argument("--text-file", help="Datei oder '-' für stdin")
pw.add_argument("--comment", help="Änderungskommentar (erscheint in der Historie)")
pw.add_argument("--parent", help="Titel der übergeordneten Seite")
pw.add_argument("--version", help="alte Fassung anzeigen (Versionsnummer)")
pm = sub.add_parser("meta", help="Stammdaten auflisten")
pm.add_argument("kind", choices=["trackers", "statuses", "priorities"])
@@ -465,8 +272,7 @@ def main():
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, "wiki": cmd_wiki, "meta": cmd_meta,
"create": cmd_create, "update": cmd_update, "comment": cmd_comment, "meta": cmd_meta,
}[a.cmd](s, a)