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"]