Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72a1ca284a | ||
|
|
7efb302c10 | ||
|
|
ab56ba0c64 | ||
|
|
6613c9ef29 | ||
|
|
21d709afbd | ||
|
|
2bfc62bcb8 | ||
|
|
e00395fc64 | ||
|
|
4a20da2ed2 | ||
|
|
2a02dac74e | ||
|
|
75e99dd0b8 |
No files matched your search
@@ -36,7 +36,7 @@
|
||||
{
|
||||
"name": "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",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# lokale Umgebungs-/Secret-Dateien
|
||||
.env
|
||||
@@ -79,9 +79,10 @@ natürlicher klingen. Basiert auf Wikipedias „Signs of AI writing".
|
||||
|
||||
### 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
|
||||
`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
|
||||
(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.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.
|
||||
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.
|
||||
|
||||
- Voraussetzung: `REDMINE_URL` und persönlicher `REDMINE_API_KEY` als
|
||||
Umgebungsvariable bzw. dotenv-Datei (kein Key im Plugin). API-Key unter
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "humhub",
|
||||
"version": "0.1.0",
|
||||
"description": "HumHub via REST-API: Wiki-Seiten und Posts auflisten, anzeigen, anlegen und aktualisieren (humhub.py).",
|
||||
"version": "0.3.0",
|
||||
"description": "HumHub via REST-API: Wiki-Seiten, Posts, Umfragen und Kommentare auflisten, anzeigen, anlegen und aktualisieren (humhub.py).",
|
||||
"author": {
|
||||
"name": "inmedias.it"
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
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".
|
||||
version: 0.1.0
|
||||
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.2.0
|
||||
---
|
||||
|
||||
# HumHub (humhub.py)
|
||||
|
||||
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`.
|
||||
|
||||
## Ausführen
|
||||
@@ -59,6 +59,64 @@ werden nicht committet).
|
||||
./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
|
||||
Shell-Argument (Sonderzeichen). `container_id` akzeptiert eine numerische
|
||||
Container-ID, einen **Space-Slug** oder eine **Space-URL** (`resolve_container`
|
||||
|
||||
@@ -212,6 +212,61 @@ def cmd_post_create(args: argparse.Namespace) -> None:
|
||||
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:
|
||||
session, _ = get_session()
|
||||
message = read_file(args.file)
|
||||
@@ -251,6 +306,118 @@ 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 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) ─────────────────────────────────────
|
||||
|
||||
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.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
|
||||
p = sub.add_parser("post-update", help="Post aktualisieren")
|
||||
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.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.func(args)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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).",
|
||||
"author": {
|
||||
"name": "inmedias.it"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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".
|
||||
version: 0.1.0
|
||||
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
|
||||
---
|
||||
|
||||
# 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 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)
|
||||
@@ -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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -88,6 +88,7 @@ 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,
|
||||
@@ -95,6 +96,12 @@ def cmd_search(args: argparse.Namespace) -> None:
|
||||
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"]
|
||||
if not hits:
|
||||
print("(keine Ergebnisse)", file=sys.stderr)
|
||||
@@ -110,6 +117,25 @@ 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():
|
||||
@@ -161,6 +187,12 @@ 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)")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "redmine",
|
||||
"version": "0.1.0",
|
||||
"description": "Redmine via REST-API: Projekte/Tickets auflisten, anlegen, aktualisieren, kommentieren (redmine.py).",
|
||||
"version": "0.2.0",
|
||||
"description": "Redmine via REST-API: Projekte/Tickets/Versionen/News auflisten, anlegen, aktualisieren, kommentieren (redmine.py).",
|
||||
"author": {
|
||||
"name": "inmedias.it"
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
---
|
||||
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
|
||||
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.2.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).
|
||||
Projekte, Tickets, Versionen und News 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).
|
||||
|
||||
## Ausführen
|
||||
|
||||
@@ -43,9 +44,10 @@ werden auf IDs aufgelöst (Projekt, Tracker, Status, Priorität).
|
||||
### 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>`, `--done <0-100>`.
|
||||
`--status`, `--priority`, `--assigned me`, `--parent <id>`, `--version <name|id>`,
|
||||
`--done <0-100>`.
|
||||
|
||||
```bash
|
||||
./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"
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
`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
|
||||
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.
|
||||
- **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".
|
||||
@@ -19,6 +19,12 @@ 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
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -162,6 +168,45 @@ 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):
|
||||
@@ -182,6 +227,8 @@ 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):
|
||||
@@ -211,6 +258,65 @@ 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_meta(s, a):
|
||||
spec = {
|
||||
"trackers": ("/trackers.json", "trackers"),
|
||||
@@ -258,6 +364,7 @@ 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")
|
||||
@@ -265,6 +372,21 @@ 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")
|
||||
|
||||
pm = sub.add_parser("meta", help="Stammdaten auflisten")
|
||||
pm.add_argument("kind", choices=["trackers", "statuses", "priorities"])
|
||||
|
||||
@@ -272,7 +394,8 @@ 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, "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)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user