Author SHA1 Message Date
skoandClaude Opus 5 72a1ca284a mediawiki: Suchfehler lesbar statt als Stacktrace (0.1.1)
Gleichstand mit inmedias.it/dokumentierer d1eeb94.

Eine von CirrusSearch abgelehnte Suchanfrage liess mwclient.errors.APIError
bis nach oben durchschlagen - der Benutzer sah einen Stacktrace statt einer
Meldung. cmd_search faengt den Fehler jetzt ab und beendet mit Code 1.

Fuer cirrussearch-parse-error gibt es einen eigenen Hinweis: "insource:wort"
funktioniert, die Regex-Form "insource:/muster/" nicht. Letztere braucht das
Elasticsearch-Plugin wikimedia-extra, das auf unserer Instanz fehlt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 11:51:07 +02:00
skoandClaude Opus 5 7efb302c10 mediawiki: purge-Subcommand und der Parser-Cache-Fallstrick
wiki.py mit dokumentierer/wiki.py synchronisiert (purge-Subcommand).

SKILL.md: purge und das bisher undokumentierte upload in die
Befehlsliste, dazu ein Abschnitt zu Seiten mit #get_web_data. Die
Vorlagen werden nicht bei jedem Aufruf ausgefuehrt; wer nach einer
Datenaenderung render aufruft und alte Werte sieht, hat meist den Cache
vor sich und nicht kaputte Daten.

description um den Fall erweitert, damit die Skill bei "Wiki zeigt
veraltete Daten" gefunden wird. version 0.1.0 -> 0.2.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 05:10:54 +02:00
sko ab56ba0c64 Merge pull request 'redmine: News und Versionen steuerbar (0.2.0)' (#9) from feat/redmine-news-versionen into main
Reviewed-on: #9
2026-09-17 08:50:44 +02:00
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
sko 21d709afbd Merge pull request 'humhub: Direktnachrichten senden (mail-send)' (#8) from feat/humhub-mail into main
Reviewed-on: #8
2026-09-16 06:32:09 +02:00
jhsandClaude Opus 5 2bfc62bcb8 humhub: in bestehenden Konversationen antworten (mail-reply)
mail-send legt immer eine neue Konversation an; fuer eine Antwort braucht es
POST /mail/<id>/entry. Die ID steht in der URL der Konversation.

Holt den Verlauf nicht ab, sondern prueft nur die Erreichbarkeit — fremde
Nachrichten gehoeren nicht ungefragt in einen Modellkontext.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 08:24:23 +02:00
jhsandClaude Opus 5 e00395fc64 humhub: Direktnachrichten senden (mail-send)
Die REST-API kann das Mail-Modul, das Skill bisher nicht — Direktnachrichten
mussten von Hand in der Weboberflaeche geschrieben werden.

Der Stolperstein ist die Empfaengeraufloesung: Profil-Slug und Benutzername
sind nicht dasselbe (zu /u/serge/ gehoert "Serge"), und get-by-username
unterscheidet Gross-/Kleinschreibung. resolve_user() nimmt deshalb ID,
Benutzername oder Profil-URL und faellt auf einen Durchlauf der Benutzerliste
zurueck, der beides case-insensitive vergleicht.

Kein Befehl zum Lesen von Konversationen: fremde Nachrichten gehoeren nicht
ungefragt in einen Modellkontext.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 08:56:09 +02:00
jhsandClaude Fable 5 4a20da2ed2 humhub: comment-list/comment-create (POST /comment via Content-ID/Perma-URL)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 07:28:01 +02:00
sko 2a02dac74e Merge pull request 'humhub: Umfragen anlegen und auflisten (poll-create / poll-list)' (#6) from feat/humhub-polls into main 2026-08-31 09:24:44 +02:00
jhsandClaude Opus 5 75e99dd0b8 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 09:24:31 +02:00
12 changed files with 534 additions and 36 deletions

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@
{ {
"name": "redmine", "name": "redmine",
"source": "./plugins/redmine", "source": "./plugins/redmine",
"description": "Redmine via REST-API: Projekte/Tickets auflisten, anzeigen, anlegen, aktualisieren und kommentieren via redmine.py (API-Key lokal)." "description": "Redmine via REST-API: Projekte, Tickets, Versionen und News auflisten, anzeigen, anlegen, aktualisieren und kommentieren via redmine.py (API-Key lokal)."
}, },
{ {
"name": "grill-me", "name": "grill-me",
+6
View File
@@ -0,0 +1,6 @@
# Python
__pycache__/
*.py[cod]
# lokale Umgebungs-/Secret-Dateien
.env
+8 -7
View File
@@ -79,9 +79,10 @@ natürlicher klingen. Basiert auf Wikipedias „Signs of AI writing".
### humhub ### humhub
HumHub (humhub.inmedias.it) über die REST-API: Wiki-Seiten und Posts auflisten, HumHub (humhub.inmedias.it) über die REST-API: Wiki-Seiten, Posts und Umfragen auflisten,
anzeigen, anlegen und aktualisieren — via mitgelieferter, self-contained anzeigen, anlegen und aktualisieren — via mitgelieferter, self-contained
`humhub.py` (`wiki-list/get/update/create`, `post-list/create/update`). `humhub.py` (`wiki-list/get/update/create`, `post-list/create/update`,
`poll-list/create`).
- Voraussetzung: `HUMHUB_API_TOKEN` als Umgebungsvariable bzw. dotenv-Datei - Voraussetzung: `HUMHUB_API_TOKEN` als Umgebungsvariable bzw. dotenv-Datei
(kein Token im Plugin). Token nur als berechtigter Benutzer unter (kein Token im Plugin). Token nur als berechtigter Benutzer unter
@@ -109,11 +110,11 @@ self-contained `wiki.py` (`fetch`/`render`/`search`/`update`). Instanzen `wiki`
### redmine ### redmine
Redmine (redmine.inmedias.it) über die REST-API: Projekte und Tickets auflisten, Redmine (redmine.inmedias.it) über die REST-API: Projekte, Tickets, Versionen
anzeigen, anlegen, aktualisieren und kommentieren — via mitgelieferter, und News auflisten, anzeigen, anlegen, aktualisieren und kommentieren — via
self-contained `redmine.py` (`me`/`projects`/`issues`/`show`/`create`/`update`/ mitgelieferter, self-contained `redmine.py` (`me`/`projects`/`issues`/`show`/
`comment`/`meta`). Namen (Projekt/Tracker/Status/Priorität) werden auf IDs `create`/`update`/`comment`/`news`/`versions`/`meta`). Namen (Projekt/Tracker/
aufgelöst. Status/Priorität/Version) werden auf IDs aufgelöst.
- Voraussetzung: `REDMINE_URL` und persönlicher `REDMINE_API_KEY` als - Voraussetzung: `REDMINE_URL` und persönlicher `REDMINE_API_KEY` als
Umgebungsvariable bzw. dotenv-Datei (kein Key im Plugin). API-Key unter Umgebungsvariable bzw. dotenv-Datei (kein Key im Plugin). API-Key unter
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "humhub", "name": "humhub",
"version": "0.1.0", "version": "0.3.0",
"description": "HumHub via REST-API: Wiki-Seiten und Posts auflisten, anzeigen, anlegen und aktualisieren (humhub.py).", "description": "HumHub via REST-API: Wiki-Seiten, Posts, Umfragen und Kommentare auflisten, anzeigen, anlegen und aktualisieren (humhub.py).",
"author": { "author": {
"name": "inmedias.it" "name": "inmedias.it"
} }
+61 -3
View File
@@ -1,13 +1,13 @@
--- ---
name: humhub name: humhub
description: HumHub Wiki-Seiten und Posts lesen/erstellen/aktualisieren via REST-API. Use when the user mentions "HumHub", "Space", "Wiki-Seite in HumHub" or "Post/Beitrag". description: HumHub Wiki-Seiten, Posts und Umfragen lesen/erstellen/aktualisieren via REST-API. Use when the user mentions "HumHub", "Space", "Wiki-Seite in HumHub", "Post/Beitrag" or "Umfrage/Poll".
version: 0.1.0 version: 0.2.0
--- ---
# HumHub (humhub.py) # HumHub (humhub.py)
Interagiert mit HumHub (https://humhub.inmedias.it) über die REST-API: Interagiert mit HumHub (https://humhub.inmedias.it) über die REST-API:
Wiki-Seiten und Posts auflisten, anzeigen, anlegen und aktualisieren — über die Wiki-Seiten, Posts und Umfragen auflisten, anzeigen, anlegen und aktualisieren — über die
mitgelieferte, self-contained `humhub.py`. mitgelieferte, self-contained `humhub.py`.
## Ausführen ## Ausführen
@@ -59,6 +59,64 @@ werden nicht committet).
./humhub.py post-update <id> [datei|-] ./humhub.py post-update <id> [datei|-]
``` ```
### Umfragen
```bash
./humhub.py poll-list <container_id> [--limit N]
./humhub.py poll-create <container_id> "<frage>" --answers "A|B|C" \
[--file beschreibung.md] [--anonymous] [--multiple] [--hide-results]
```
Antwortoptionen mit `|` trennen, mindestens zwei. Der Beschreibungstext kommt
wie überall aus einer Datei oder `-`; Markdown ist erlaubt. Ohne `--anonymous`
ist sichtbar, wer wie gestimmt hat — bei Anmeldungen mit begrenzten Plätzen ist
das der Sinn der Sache.
Zwei Eigenheiten der Polls-API, die zu `HTTP 500 "Internal error while save a
poll!"` führen, wenn man dem Muster der Post-API folgt: die Felder gehören
unter den Yii-Formnamen **`Poll`** (nicht `data`), und die Optionen heißen beim
Anlegen **`newAnswers`** (beim Lesen `answers`). Beim Lesen ist `limit` Pflicht
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|-]
./humhub.py mail-send https://humhub.inmedias.it/u/serge/ "Betreff" entwurf.md
./humhub.py mail-send jan.stobbe,26 "Betreff" -
./humhub.py mail-reply <konversations-id> [datei|-]
```
Empfänger sind Benutzername, Profil-URL oder numerische ID; mehrere mit Komma.
`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`.
**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 Inhalte (Markdown) aus Datei oder `-` (stdin) übergeben — nicht als langes
Shell-Argument (Sonderzeichen). `container_id` akzeptiert eine numerische Shell-Argument (Sonderzeichen). `container_id` akzeptiert eine numerische
Container-ID, einen **Space-Slug** oder eine **Space-URL** (`resolve_container` Container-ID, einen **Space-Slug** oder eine **Space-URL** (`resolve_container`
+212
View File
@@ -212,6 +212,61 @@ def cmd_post_create(args: argparse.Namespace) -> None:
print(f"Topics: {', '.join(saved_topics)}") print(f"Topics: {', '.join(saved_topics)}")
def cmd_poll_list(args: argparse.Namespace) -> None:
session, _ = get_session()
container_id = resolve_container(session, args.container_id)
# limit ist Pflicht, nicht Komfort: ohne Begrenzung antwortet die Instanz
# bei vielen Umfragen mit HTTP 500 statt mit einer Liste.
data = api_get(session, f"/polls/container/{container_id}?limit={args.limit}")
results = data.get("results", [])
if not results:
print("(keine Umfragen)")
return
for poll in results:
antworten = [a.get("answer", "") for a in poll.get("answers", [])]
zu = " [geschlossen]" if poll.get("closed") else ""
print(f"ID {poll.get('id')}{zu}: {poll.get('question', '')}")
print(f" {' | '.join(antworten)}")
def cmd_poll_create(args: argparse.Namespace) -> None:
"""Umfrage anlegen.
Zwei Abweichungen von der Post-API, die beide mit HTTP 500
("Internal error while save a poll!") enden, wenn man sie übersieht:
* Der Controller macht `$poll->load(Yii::$app->request->post())` — Yii
erwartet die Felder deshalb unter dem Formnamen **`Poll`**, nicht unter
`data` wie bei Posts und Wiki-Seiten.
* Die Optionen heißen beim Anlegen **`newAnswers`**; `answers` ist die
Leseform und wird beim POST ignoriert.
Quelle: humhub/polls, controllers/rest/PollsController.php + models/Poll.php.
Pflicht sind `question` und mindestens zwei Antworten.
"""
session, _ = get_session()
container_id = resolve_container(session, args.container_id)
antworten = [a.strip() for a in args.answers.split("|") if a.strip()]
if len(antworten) < 2:
sys.exit("Error: mindestens zwei Antwortoptionen nötig "
"(--answers 'Ja|Nein').")
beschreibung = read_file(args.file) if args.file else ""
payload = {"Poll": {
"question": args.question,
"description": beschreibung,
"newAnswers": antworten,
"anonymous": 1 if args.anonymous else 0,
"allow_multiple": 1 if args.multiple else 0,
"show_result_after_close": 1 if args.hide_results else 0,
"is_random": 0,
}}
data = api_post(session, f"/polls/container/{container_id}", payload)
print(f"Umfrage erstellt (ID {data.get('id')}): {data.get('question')}")
print("Optionen: " + " | ".join(a.get("answer", "")
for a in data.get("answers", [])))
def cmd_post_update(args: argparse.Namespace) -> None: def cmd_post_update(args: argparse.Namespace) -> None:
session, _ = get_session() session, _ = get_session()
message = read_file(args.file) message = read_file(args.file)
@@ -251,6 +306,118 @@ def cmd_space_list(args: argparse.Namespace) -> None:
print("(keine Spaces gefunden)") 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 resolve_user(session: requests.Session, value: str) -> int:
"""Resolve a user ID, username, or profile URL (…/u/<slug>/) to a user ID.
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 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 int(r.json()["id"])
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 int(u["id"])
if len(results) < 50:
break
page += 1
sys.exit(f"Error: Benutzer {value!r} nicht gefunden")
def cmd_mail_send(args: argparse.Namespace) -> None:
session, _ = get_session()
ids = [resolve_user(session, teil) for teil in args.to.split(",") if teil.strip()]
if not ids:
sys.exit("Error: kein Empfänger angegeben")
message = read_file(args.file)
data = api_post(session, "/mail", {
"title": args.title,
"message": message,
"recipient": ids,
})
empfaenger = ", ".join(str(i) for i in ids)
print(f"Nachricht gesendet (Konversation {data.get('id')}) an Benutzer {empfaenger}")
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) ───────────────────────────────────── # ── Fetch (perma-/content-id-agnostisch) ─────────────────────────────────────
def parse_content_ref(ref: str) -> int: def parse_content_ref(ref: str) -> int:
@@ -405,6 +572,30 @@ def main() -> None:
p.add_argument("--topics", default=None, help="Komma-getrennte Topic-Namen") p.add_argument("--topics", default=None, help="Komma-getrennte Topic-Namen")
p.set_defaults(func=cmd_post_create) 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.set_defaults(func=cmd_mail_send)
# 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 # post-update
p = sub.add_parser("post-update", help="Post aktualisieren") p = sub.add_parser("post-update", help="Post aktualisieren")
p.add_argument("id", type=int, help="Post-ID") p.add_argument("id", type=int, help="Post-ID")
@@ -412,6 +603,27 @@ def main() -> None:
p.add_argument("--topics", default=None, help="Komma-getrennte Topic-Namen (ersetzt bestehende)") p.add_argument("--topics", default=None, help="Komma-getrennte Topic-Namen (ersetzt bestehende)")
p.set_defaults(func=cmd_post_update) p.set_defaults(func=cmd_post_update)
# poll-list
p = sub.add_parser("poll-list", help="Umfragen eines Containers auflisten")
p.add_argument("container_id", help="Space-Slug, URL oder numerische Container-ID")
p.add_argument("--limit", type=int, default=10, help="Max. Anzahl (Standard: 10)")
p.set_defaults(func=cmd_poll_list)
# poll-create
p = sub.add_parser("poll-create", help="Neue Umfrage anlegen")
p.add_argument("container_id", help="Space-Slug, URL oder numerische Container-ID")
p.add_argument("question", help="Die Frage (Pflicht)")
p.add_argument("--answers", required=True,
help="Antwortoptionen, mit | getrennt: 'Ja|Nein|Vielleicht' "
"(mindestens zwei)")
p.add_argument("--file", default=None,
help="Markdown-Datei mit dem Beschreibungstext, '-' für stdin")
p.add_argument("--anonymous", action="store_true", help="anonyme Abstimmung")
p.add_argument("--multiple", action="store_true", help="Mehrfachauswahl erlauben")
p.add_argument("--hide-results", action="store_true",
help="Ergebnis erst nach Ende der Umfrage zeigen")
p.set_defaults(func=cmd_poll_create)
args = parser.parse_args() args = parser.parse_args()
args.func(args) args.func(args)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mediawiki", "name": "mediawiki",
"version": "0.1.0", "version": "0.1.1",
"description": "MediaWiki-Seiten/Templates lesen, rendern und schreiben via wiki.py (fetch/update/render/search, Instanzen wiki & devwiki).", "description": "MediaWiki-Seiten/Templates lesen, rendern und schreiben via wiki.py (fetch/update/render/search, Instanzen wiki & devwiki).",
"author": { "author": {
"name": "inmedias.it" "name": "inmedias.it"
+25 -2
View File
@@ -1,7 +1,7 @@
--- ---
name: mediawiki name: mediawiki
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". 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.1.0 version: 0.2.0
--- ---
# MediaWiki (wiki.py) # MediaWiki (wiki.py)
@@ -37,6 +37,8 @@ oder anderen direkten Server-Eingriffen. Reine Lese-/Render-Checks
./wiki.py -w devwiki search "Suchbegriff" [-n <namespace>] # Titelsuche ./wiki.py -w devwiki search "Suchbegriff" [-n <namespace>] # Titelsuche
./wiki.py -w devwiki update "Kunde:Beispiel GmbH" datei.wiki --summary "..." # speichern ./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 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) Tipp: Beim Schreiben den Inhalt aus einer Datei (`datei.wiki`) oder `-` (stdin)
@@ -57,6 +59,27 @@ dann `.env` im aktuellen Verzeichnis, dann bereits gesetzte Env-Variablen). Sieh
Es empfiehlt sich ein eigener Bot-/Service-Account mit den nötigen Schreibrechten. 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 ## Hinweise
- `search` liefert Titel; `render` ist nützlich, um vor/nach dem Schreiben das - `search` liefert Titel; `render` ist nützlich, um vor/nach dem Schreiben das
Ergebnis zu prüfen, ohne die Seite im Browser zu öffnen. Ergebnis zu prüfen, ohne die Seite im Browser zu öffnen.
+39 -7
View File
@@ -88,13 +88,20 @@ def cmd_search(args: argparse.Namespace) -> None:
sys.exit(f"Error: unknown namespace '{ns_key}'. " sys.exit(f"Error: unknown namespace '{ns_key}'. "
f"Known: all, {', '.join(NAMESPACES)}") f"Known: all, {', '.join(NAMESPACES)}")
result = site.api( try:
"query", list="search", result = site.api(
srsearch=args.query, "query", list="search",
srnamespace=ns, srsearch=args.query,
srlimit=50, srnamespace=ns,
srprop="title|snippet", 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}")
hits = result["query"]["search"] hits = result["query"]["search"]
if not hits: if not hits:
print("(keine Ergebnisse)", file=sys.stderr) print("(keine Ergebnisse)", file=sys.stderr)
@@ -110,6 +117,25 @@ def cmd_render(args: argparse.Namespace) -> None:
print(result["parse"]["text"]["*"], end="") 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: def cmd_upload(args: argparse.Namespace) -> None:
path = Path(args.file_path) path = Path(args.file_path)
if not path.exists(): if not path.exists():
@@ -161,6 +187,12 @@ def main() -> None:
render_p.add_argument("title", help="Page title") render_p.add_argument("title", help="Page title")
render_p.set_defaults(func=cmd_render) 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 = 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("file_path", help="Absolute path to the local file")
upload_p.add_argument("wiki_filename", help="Target filename on the wiki (without 'File:' prefix)") 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", "name": "redmine",
"version": "0.1.0", "version": "0.2.0",
"description": "Redmine via REST-API: Projekte/Tickets auflisten, anlegen, aktualisieren, kommentieren (redmine.py).", "description": "Redmine via REST-API: Projekte/Tickets/Versionen/News auflisten, anlegen, aktualisieren, kommentieren (redmine.py).",
"author": { "author": {
"name": "inmedias.it" "name": "inmedias.it"
} }
+53 -10
View File
@@ -1,15 +1,16 @@
--- ---
name: redmine 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". description: Redmine-Projekte, -Tickets, -Versionen und -News lesen/anlegen/aktualisieren via REST-API. Use when the user mentions "Redmine", "Ticket", "Issue in Redmine", "Meilenstein/Milestone/Version", "Projektstand/News" or die Projekte "pg-wissen"/"devops".
version: 0.1.0 version: 0.2.0
--- ---
# Redmine (redmine.py) # Redmine (redmine.py)
Interagiert mit Redmine (https://redmine.inmedias.it) über die REST-API: Interagiert mit Redmine (https://redmine.inmedias.it) über die REST-API:
Projekte und Tickets auflisten, anzeigen, anlegen, aktualisieren und Projekte, Tickets, Versionen und News auflisten, anzeigen, anlegen,
kommentieren — über die mitgelieferte, self-contained `redmine.py`. Namen aktualisieren und kommentieren - über die mitgelieferte, self-contained
werden auf IDs aufgelöst (Projekt, Tracker, Status, Priorität). `redmine.py`. Namen werden auf IDs aufgelöst (Projekt, Tracker, Status,
Priorität, Version).
## Ausführen ## Ausführen
@@ -43,9 +44,10 @@ werden auf IDs aufgelöst (Projekt, Tracker, Status, Priorität).
### Anlegen / Aktualisieren / Kommentieren ### Anlegen / Aktualisieren / Kommentieren
`--project` und `--subject` sind bei `create` Pflicht. Beschreibung inline `--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`, als langes Shell-Argument (Sonderzeichen). Weitere Felder: `--tracker`,
`--status`, `--priority`, `--assigned me`, `--parent <id>`, `--done <0-100>`. `--status`, `--priority`, `--assigned me`, `--parent <id>`, `--version <name|id>`,
`--done <0-100>`.
```bash ```bash
./redmine.py create --project pg-wissen --subject "Titel" \ ./redmine.py create --project pg-wissen --subject "Titel" \
@@ -54,6 +56,44 @@ als langes Shell-Argument (Sonderzeichen). Weitere Felder: `--tracker`,
./redmine.py comment 290 "kurzer Kommentar" ./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.
## Zugangsdaten ## Zugangsdaten
`redmine.py` bringt **keinen** Schlüssel mit. Jede/r hinterlegt einen eigenen `redmine.py` bringt **keinen** Schlüssel mit. Jede/r hinterlegt einen eigenen
@@ -71,6 +111,9 @@ gesetzte Env-Variablen). Siehe `.env.example`:
- Schreibzugriff nur in Projekten mit „Add/Edit issues"-Recht; ein Nicht-Admin - Schreibzugriff nur in Projekten mit „Add/Edit issues"-Recht; ein Nicht-Admin
kann keine Tickets in fremden Projekten ändern. kann keine Tickets in fremden Projekten ändern.
- Ein neues Milestone (Version) anzulegen erfordert das Recht *manage_versions* - **Ticket-Beziehungen** (`blocks`, `relates`, `precedes`) deckt `redmine.py`
und ist über `redmine.py` nicht abgedeckt — dafür die Weboberfläche oder die nicht ab - dafür `POST /issues/<id>/relations.json`. Ein Ticket kann nicht mit
REST-API (`POST /projects/<id>/versions.json`) nutzen. 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".
+124 -1
View File
@@ -19,6 +19,12 @@ Beispiele:
uv run redmine.py update 42 --status Erledigt --notes "gefixt" uv run redmine.py update 42 --status Erledigt --notes "gefixt"
uv run redmine.py comment 42 "kurzer Kommentar" uv run redmine.py comment 42 "kurzer Kommentar"
uv run redmine.py meta trackers 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 argparse
@@ -162,6 +168,45 @@ def cmd_show(s, a):
+ "\n".join(" " + ln for ln in j["notes"].splitlines())) + "\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: def build_issue_fields(s, a) -> dict:
f = {} f = {}
if getattr(a, "project", None): if getattr(a, "project", None):
@@ -182,6 +227,8 @@ def build_issue_fields(s, a) -> dict:
f["assigned_to_id"] = resolve_assigned(s, a.assigned) f["assigned_to_id"] = resolve_assigned(s, a.assigned)
if getattr(a, "parent", None): if getattr(a, "parent", None):
f["parent_issue_id"] = int(a.parent) 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: if getattr(a, "done", None) is not None:
f["done_ratio"] = int(a.done) f["done_ratio"] = int(a.done)
if getattr(a, "notes", None): if getattr(a, "notes", None):
@@ -211,6 +258,65 @@ def cmd_comment(s, a):
print(f"Kommentar an #{a.id} hinzugefügt.") 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): def cmd_meta(s, a):
spec = { spec = {
"trackers": ("/trackers.json", "trackers"), "trackers": ("/trackers.json", "trackers"),
@@ -258,6 +364,7 @@ def main():
c.add_argument("--priority", help="Priorität (Name oder ID)") c.add_argument("--priority", help="Priorität (Name oder ID)")
c.add_argument("--assigned", help="me | User-ID") c.add_argument("--assigned", help="me | User-ID")
c.add_argument("--parent", help="übergeordnetes Ticket (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)") c.add_argument("--done", type=int, help="Fortschritt in %% (0-100)")
if name == "update": if name == "update":
c.add_argument("--notes", help="Kommentar zur Änderung") c.add_argument("--notes", help="Kommentar zur Änderung")
@@ -265,6 +372,21 @@ def main():
pc = sub.add_parser("comment", help="Kommentar an ein Ticket hängen") pc = sub.add_parser("comment", help="Kommentar an ein Ticket hängen")
pc.add_argument("id"); pc.add_argument("text") 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 = sub.add_parser("meta", help="Stammdaten auflisten")
pm.add_argument("kind", choices=["trackers", "statuses", "priorities"]) pm.add_argument("kind", choices=["trackers", "statuses", "priorities"])
@@ -272,7 +394,8 @@ def main():
s, _ = session() s, _ = session()
{ {
"me": cmd_me, "projects": cmd_projects, "issues": cmd_issues, "show": cmd_show, "me": cmd_me, "projects": cmd_projects, "issues": cmd_issues, "show": cmd_show,
"create": cmd_create, "update": cmd_update, "comment": cmd_comment, "meta": cmd_meta, "create": cmd_create, "update": cmd_update, "comment": cmd_comment,
"news": cmd_news, "versions": cmd_versions, "meta": cmd_meta,
}[a.cmd](s, a) }[a.cmd](s, a)