feat(app,db): Repo-Bearbeitung aus Tickets heraus ueber Forge-Contents-API mit zwingender Worklog-Dokumentation (phase-008-itsm-repo-audit)
- forge_client.py: schlanker HTTP-Client fuer Forge-Contents-API (get/update), FORGE_BASE_URL + FORGE_SERVICE_TOKEN als Config - db.log_repo_edit: schreibt Worklog-Eintrag in ticket_timeline - app.py: /api/tickets/<id>/repo-file (laden), /tickets/<id>/repo-edit (speichern) -- schlaegt der Worklog-Eintrag fehl, wird trotz bereits erfolgtem Forge-Commit KEIN Erfolg gemeldet (siehe project.yaml phase-008), Commit-Sha wird dennoch fuer manuelle Nachbearbeitung geloggt - Ticket-Detail-Panel (JS): neue Sektion zum Laden/Bearbeiten/Committen einer Repo-Datei direkt aus dem Ticket
This commit is contained in:
parent
cc2232746a
commit
6d7c511ea7
161
app.py
161
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 '<button class="btn ghost" style="margin:2px" onclick="setStatus('+t.id+',\\''+s+'\\')">'+s+'</button>';
|
||||
}).join('');
|
||||
var repoEdit = ''
|
||||
+ '<div class="sz" style="margin-bottom:6px">Repo-Datei bearbeiten (Forge)</div>'
|
||||
+ '<div class="panel" style="margin-bottom:14px">'
|
||||
+ '<div class="grid-2"><input type="text" id="repo-name-'+t.id+'" placeholder="Repo (z.B. demo)">'
|
||||
+ '<input type="text" id="repo-branch-'+t.id+'" placeholder="Branch" value="main"></div>'
|
||||
+ '<div class="formrow"><input type="text" id="repo-path-'+t.id+'" placeholder="Dateipfad (z.B. README.md)" style="width:100%"></div>'
|
||||
+ '<button class="btn ghost" onclick="loadRepoFile('+t.id+')">Datei laden</button>'
|
||||
+ '<div id="repo-status-'+t.id+'" class="sz" style="margin:6px 0"></div>'
|
||||
+ '<textarea id="repo-content-'+t.id+'" rows="8" style="width:100%;display:none;font-family:monospace"></textarea>'
|
||||
+ '<input type="hidden" id="repo-sha-'+t.id+'">'
|
||||
+ '<div class="formrow" id="repo-msg-row-'+t.id+'" style="display:none">'
|
||||
+ '<input type="text" id="repo-msg-'+t.id+'" placeholder="Commit-Nachricht" style="width:100%"></div>'
|
||||
+ '<button class="btn" id="repo-save-'+t.id+'" style="display:none" onclick="saveRepoFile('+t.id+')">Speichern (im Repo committen)</button>'
|
||||
+ '</div>';
|
||||
return '<h2 style="margin-top:0">'+t.ticket_nr+'</h2>'
|
||||
+ '<div class="sz" style="margin-bottom:10px">'+t.titel+'</div>'
|
||||
+ '<div><span class="pill">'+t.status+'</span> '
|
||||
|
|
@ -567,9 +596,47 @@ function renderDetail(t){
|
|||
+ '<div class="sz" style="margin-bottom:6px">Beschreibung</div><div style="margin-bottom:14px">'+(t.beschreibung||'')+'</div>'
|
||||
+ '<div class="sz" style="margin-bottom:6px">Service</div><div style="margin-bottom:14px">'+(t.service_name||'(kein Service)')+'</div>'
|
||||
+ '<div class="sz" style="margin-bottom:6px">Status aendern</div><div style="margin-bottom:14px">'+statusBtns+'</div>'
|
||||
+ repoEdit
|
||||
+ '<div class="sz" style="margin-bottom:6px">Zeitleiste</div>'+tl
|
||||
+ '<div style="margin-top:16px"><button class="btn ghost" onclick="closeDetail()">Schliessen</button></div>';
|
||||
}
|
||||
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.'; });
|
||||
}
|
||||
</script>""" % (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/<int:ticket_id>/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/<int:ticket_id>/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).
|
||||
|
|
|
|||
20
db.py
20
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:
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
Loading…
Reference in New Issue