diff --git a/app.py b/app.py index 943ac80..e0cf92a 100644 --- a/app.py +++ b/app.py @@ -24,6 +24,20 @@ ENV: ITSM_SECRET_KEY Flask-Session-Secret AES_DASHBOARD_URL Basis-URL des AES-Dashboards fuer die Service-Integration RETENTION_INTERVAL_SECONDS Intervall fuer die Retention-Bereinigung (Default 24h) + FORGE_BASE_URL Basis-URL des Forge-Git-Servers (phase-008-itsm-repo-audit, + siehe forge_client.py) -- ohne diese Variable ist die + Repo-Bearbeitung aus dem Ticket heraus deaktiviert. + FORGE_SERVICE_TOKEN API-Token eines Forge-admin-Nutzers fuer den Contents- + Schreibzugriff (siehe forge_client.py) + +Repo-Bearbeitung aus Tickets heraus (phase-008-itsm-repo-audit, Nutzer-Vorgabe +2026-07-14): Repos auf Forge sind aus einem Ticket heraus editierbar, aber JEDE +Aenderung wird zwingend als Worklog-Eintrag in der ticket_timeline dieses +Tickets dokumentiert (siehe db.log_repo_edit) -- kein stiller Repo-Zugriff +ohne Ticket-Bezug. Schlaegt das Schreiben des Worklog-Eintrags fehl, wird dem +Nutzer ein Fehler gemeldet, selbst wenn der Forge-Commit bereits durch ist +(siehe repo_edit_route) -- Konsistenz zwischen Repo-Zustand und Audit-Trail +hat Vorrang vor einer optimistischen Erfolgsmeldung. """ import os import html @@ -38,6 +52,7 @@ from flask import Flask, request, redirect, url_for, session, jsonify, abort from werkzeug.security import generate_password_hash, check_password_hash import db +import forge_client AES_DASHBOARD_URL = os.getenv("AES_DASHBOARD_URL", "").rstrip("/") RETENTION_INTERVAL_SECONDS = int(os.getenv("RETENTION_INTERVAL_SECONDS", str(24 * 60 * 60))) @@ -559,6 +574,20 @@ function renderDetail(t){ var statusBtns = ['Offen','In Bearbeitung','Warten','Geloest','Geschlossen'].map(function(s){ return ''; }).join(''); + var repoEdit = '' + + '
Repo-Datei bearbeiten (Forge)
' + + '
' + + '
' + + '
' + + '
' + + '' + + '
' + + '' + + '' + + '' + + '' + + '
'; return '

'+t.ticket_nr+'

' + '
'+t.titel+'
' + '
'+t.status+' ' @@ -567,9 +596,47 @@ function renderDetail(t){ + '
Beschreibung
'+(t.beschreibung||'')+'
' + '
Service
'+(t.service_name||'(kein Service)')+'
' + '
Status aendern
'+statusBtns+'
' + + repoEdit + '
Zeitleiste
'+tl + '
'; } +function loadRepoFile(id){ + var repo = document.getElementById('repo-name-'+id).value.trim(); + var path = document.getElementById('repo-path-'+id).value.trim(); + var branch = document.getElementById('repo-branch-'+id).value.trim() || 'main'; + var status = document.getElementById('repo-status-'+id); + if(!repo || !path){ status.textContent = 'Repo und Dateipfad angeben.'; return; } + status.textContent = 'Laedt...'; + fetch('/api/tickets/'+id+'/repo-file?repo='+encodeURIComponent(repo)+'&path='+encodeURIComponent(path)+'&branch='+encodeURIComponent(branch)) + .then(r => r.json()).then(d => { + if(!d.ok){ status.textContent = 'Fehler: ' + d.error; return; } + status.textContent = 'Geladen (sha ' + d.sha.slice(0,10) + ').'; + var ta = document.getElementById('repo-content-'+id); + ta.value = d.content; ta.style.display = 'block'; + document.getElementById('repo-sha-'+id).value = d.sha; + document.getElementById('repo-msg-row-'+id).style.display = 'block'; + document.getElementById('repo-save-'+id).style.display = 'inline-block'; + }).catch(() => { status.textContent = 'Fehler beim Laden.'; }); +} +function saveRepoFile(id){ + var repo = document.getElementById('repo-name-'+id).value.trim(); + var path = document.getElementById('repo-path-'+id).value.trim(); + var branch = document.getElementById('repo-branch-'+id).value.trim() || 'main'; + var sha = document.getElementById('repo-sha-'+id).value; + var content = document.getElementById('repo-content-'+id).value; + var message = document.getElementById('repo-msg-'+id).value.trim(); + var status = document.getElementById('repo-status-'+id); + if(!message){ status.textContent = 'Bitte eine Commit-Nachricht angeben.'; return; } + status.textContent = 'Speichert...'; + var body = 'repo='+encodeURIComponent(repo)+'&path='+encodeURIComponent(path)+'&branch='+encodeURIComponent(branch) + +'&sha='+encodeURIComponent(sha)+'&content='+encodeURIComponent(content)+'&message='+encodeURIComponent(message); + fetch('/tickets/'+id+'/repo-edit', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body: body}) + .then(r => r.json()).then(d => { + if(!d.ok){ status.textContent = 'Fehler: ' + d.error; return; } + status.textContent = 'Gespeichert -- Commit ' + d.commit.slice(0,10) + ', im Worklog dokumentiert.'; + openDetail(id); + }).catch(() => { status.textContent = 'Fehler beim Speichern.'; }); +} """ % (kpi_html, new_form, tabs_html, rows) @@ -652,6 +719,100 @@ def api_ticket(ticket_id): return jsonify(_jsonable(ticket)) +# ── Repo-Bearbeitung aus Tickets heraus (phase-008-itsm-repo-audit) ───────────── +def _repo_edit_actor_label(): + """Einheitliches akteur-Label fuer ticket_timeline-Eintraege dieser + Funktion -- gleiche Konvention wie an anderen Stellen (session.get("email")).""" + return session.get("email") or "unbekannt" + + +@app.route("/api/tickets//repo-file") +def api_ticket_repo_file(ticket_id): + """Laedt eine Datei aus einem Forge-Repo, um sie im Ticket editierbar + anzuzeigen. Verlangt ein zum Ticket gehoeriges (also existierendes, + mandanten-eigenes) Ticket, damit die Aktion ueberhaupt einen Ticket-Bezug + hat -- das ist Voraussetzung fuer den Worklog-Eintrag beim Speichern, + nicht nur beim Lesen.""" + r = _require_login() + if r: + return r + ticket = db.get_ticket(session["tenant_id"], ticket_id) + if not ticket: + abort(404) + repo = request.args.get("repo", "").strip() + path = request.args.get("path", "").strip() + branch = request.args.get("branch", "").strip() or "main" + if not repo or not path: + return jsonify({"ok": False, "error": "repo und path sind erforderlich"}), 400 + try: + text, sha = forge_client.get_contents(repo, path, ref=branch) + except forge_client.ForgeClientError as e: + return jsonify({"ok": False, "error": str(e)}), 502 + return jsonify({"ok": True, "content": text, "sha": sha, "repo": repo, "path": path, "branch": branch}) + + +@app.route("/tickets//repo-edit", methods=["POST"]) +def ticket_repo_edit_route(ticket_id): + """Schreibt eine Datei-Aenderung ueber die Forge-Contents-API und + dokumentiert sie zwingend im Worklog (ticket_timeline) dieses Tickets. + Harte Anforderung (siehe Modul-Docstring/project.yaml phase-008): + schlaegt der Worklog-Schreibvorgang fehl, wird KEIN Erfolg gemeldet, auch + wenn der Forge-Commit zu diesem Zeitpunkt bereits geschrieben wurde -- + ein bereits erfolgter Git-Commit kann nicht atomar mit dem DB-Insert + zusammengefasst werden (zwei verschiedene Systeme), daher hier stattdessen + die zweitbeste Garantie: der Nutzer wird nie faelschlich im Glauben + gelassen, dass alles dokumentiert wurde. Der (dann undokumentierte) + Forge-Commit bleibt fuer eine manuelle Nachbearbeitung im Server-Log + sichtbar (siehe except-Zweig unten).""" + r = _require_login() + if r: + return r + tenant_id = session["tenant_id"] + ticket = db.get_ticket(tenant_id, ticket_id) + if not ticket: + abort(404) + repo = request.form.get("repo", "").strip() + path = request.form.get("path", "").strip() + branch = request.form.get("branch", "").strip() or "main" + sha = request.form.get("sha", "").strip() + content = request.form.get("content", "") + message = request.form.get("message", "").strip() + if not repo or not path or not sha or not message: + return jsonify({"ok": False, "error": "repo, path, sha und message sind erforderlich"}), 400 + + message_with_ticket = "%s (ITSM-Ticket %s)" % (message, ticket["ticket_nr"]) + actor = _repo_edit_actor_label() + try: + commit_sha = forge_client.update_contents( + repo, path, content, sha, branch, message_with_ticket, + author_name=actor, author_email=(session.get("email") or "itsm@forge.local"), + ) + except forge_client.ForgeClientError as e: + return jsonify({"ok": False, "error": str(e)}), 502 + + try: + db.log_repo_edit(tenant_id, ticket_id, actor, repo, path, branch, commit_sha) + except Exception as e: + # Forge-Commit ist bereits geschrieben (commit_sha oben), aber der + # Worklog-Eintrag ist NICHT geglueckt -- gemaess phase-008-Vorgabe + # darf dies nicht als Erfolg gemeldet werden. Der Commit-Sha wird + # dennoch geloggt, damit eine manuelle Nachdokumentation moeglich + # bleibt (Konsistenz-Luecke ist sichtbar, nicht verdeckt). + app.logger.error( + "phase-008: Forge-Commit %s (Repo %s, Ticket %s) erfolgreich, " + "aber Worklog-Eintrag fehlgeschlagen: %s", commit_sha, repo, ticket_id, e, + ) + return jsonify({ + "ok": False, + "error": "Datei wurde in Forge committet (%s...), aber der Worklog-Eintrag " + "konnte nicht gespeichert werden. Bitte Admin informieren." % commit_sha[:10], + }), 500 + + db.log_audit(tenant_id, session["user_id"], "repo_file_edited", "ticket", str(ticket_id), + {"repo": repo, "path": path, "branch": branch, "commit": commit_sha}, _client_ip()) + return jsonify({"ok": True, "commit": commit_sha}) + + # ── Service-Katalog ───────────────────────────────────────────────────────────── def _service_live_status(endpoint): """Prueft die Live-Erreichbarkeit eines gebuchten Service-Endpunkts (z.B. AES-Dashboard). diff --git a/db.py b/db.py index 52fc5f1..857bbf8 100644 --- a/db.py +++ b/db.py @@ -315,6 +315,26 @@ def get_ticket(tenant_id, ticket_id): return ticket +def log_repo_edit(tenant_id, ticket_id, actor_label, repo, path, branch, commit_sha): + """Schreibt einen Worklog-Eintrag fuer eine ueber ITSM vorgenommene + Forge-Repo-Aenderung (phase-008-itsm-repo-audit, Nutzer-Vorgabe + 2026-07-14: JEDE Repo-Aenderung ueber ITSM muss im Worklog des + zugehoerigen Projekttickets dokumentiert werden). Nutzt die bestehende + ticket_timeline-Tabelle -- kein neues Datenmodell noetig, gleiches + Insert-Muster wie update_ticket_status/create_ticket. Wirft bei einem + DB-Fehler regulaer weiter (Aufrufer in app.py MUSS das als Fehlschlag + behandeln und darf den Repo-Commit dann nicht als erfolgreich melden -- + siehe project.yaml phase-008).""" + text = "Repo-Datei bearbeitet: %s@%s (%s) -- Commit %s" % (repo, branch, path, commit_sha[:10]) + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """INSERT INTO ticket_timeline (ticket_id, tenant_id, akteur, text) + VALUES (%s,%s,%s,%s)""", + (ticket_id, tenant_id, actor_label, text), + ) + + def update_ticket_status(tenant_id, ticket_id, status, actor_label): with get_conn() as conn: with conn.cursor() as cur: diff --git a/forge_client.py b/forge_client.py new file mode 100644 index 0000000..53a261a --- /dev/null +++ b/forge_client.py @@ -0,0 +1,91 @@ +""" +Kleiner HTTP-Client fuer die Forge-Contents-API (phase-008-itsm-repo-audit). + +Analog zum bestehenden Muster in app.py (urllib.request statt einer externen +HTTP-Bibliothek -- keine zusaetzliche Abhaengigkeit fuer nur zwei Aufrufe). +Spricht ausschliesslich die Forge-REST-API (siehe mscadm/forge +forge-web/src/api.rs), die bewusst Gitea-API-kompatibel gehalten ist. + +ENV (siehe auch app.py-Docstring): + FORGE_BASE_URL Basis-URL des Forge-Servers, z.B. http://127.0.0.1:8095 + FORGE_SERVICE_TOKEN API-Token eines Forge-Nutzers mit role=admin (siehe + Forge /api/v1/admin/users) -- dient hier NICHT der + Admin-API, sondern normalen Contents-Schreibzugriffen; + admin ist ausreichend, da Forge (noch) kein feineres + Repo-Schreibrecht kennt. + +Bewusster Scope-Schnitt: nur get_contents/update_contents (was diese Phase +tatsaechlich braucht), keine generische Forge-API-Client-Klasse mit allen +Endpunkten -- analog zur Begruendung in forge-web/src/api.rs ("nicht 100% +Paritaet ab Tag 1"). +""" +import base64 +import json +import os +import urllib.error +import urllib.parse +import urllib.request + +FORGE_BASE_URL = os.getenv("FORGE_BASE_URL", "").rstrip("/") +FORGE_SERVICE_TOKEN = os.getenv("FORGE_SERVICE_TOKEN", "") + + +class ForgeClientError(Exception): + """Fehler beim Sprechen mit der Forge-API -- Aufrufer soll dies dem + Nutzer als Fehlermeldung zeigen, nicht stillschweigend schlucken.""" + + +def _request(method, path, body=None): + if not FORGE_BASE_URL: + raise ForgeClientError("FORGE_BASE_URL ist nicht konfiguriert") + url = FORGE_BASE_URL + path + headers = {"Authorization": "token " + FORGE_SERVICE_TOKEN} + data = None + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + detail = "" + try: + detail = e.read().decode("utf-8") + except Exception: + pass + raise ForgeClientError("Forge-API-Fehler (%s): %s" % (e.code, detail or e.reason)) + except urllib.error.URLError as e: + raise ForgeClientError("Forge nicht erreichbar: %s" % e.reason) + + +def get_contents(repo, path, ref=None): + """Liest eine Datei aus einem Forge-Repo. Rueckgabe: (text, sha). + `text` ist bereits UTF-8-dekodiert (Repo-Dateien fuer diese Funktion sind + Text-Dateien wie Doku/Config -- Binaerdateien werden bewusst nicht + unterstuetzt, siehe project.yaml phase-008 Scope).""" + q = "?ref=" + urllib.parse.quote(ref, safe="") if ref else "" + result = _request("GET", "/api/v1/repos/x/%s/contents/%s%s" % (repo, path, q)) + if result.get("type") != "file": + raise ForgeClientError("Pfad ist keine Datei: %s" % path) + raw = base64.b64decode(result["content"]) + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + raise ForgeClientError("Datei ist keine UTF-8-Textdatei -- ueber ITSM nicht editierbar") + return text, result["sha"] + + +def update_contents(repo, path, content_text, sha, branch, message, author_name, author_email): + """Schreibt eine Datei in ein Forge-Repo (optimistisches Sha-Locking wie + von Forges Contents-API verlangt). Rueckgabe: commit_sha (str).""" + encoded = base64.b64encode(content_text.encode("utf-8")).decode("ascii") + body = { + "content": encoded, + "sha": sha, + "branch": branch, + "message": message, + "author": {"name": author_name, "email": author_email}, + } + result = _request("PUT", "/api/v1/repos/x/%s/contents/%s" % (repo, path), body) + return result["commit"]["sha"]