Bis hierher galt eine Zweiteilung: Zustand im Deploymentcenter, Begruendung in docs/ROADMAP.md, verbunden ueber doc_anchor. Die Beschreibung der uebernommenen Eintraege sagte es woertlich - "Die Begruendung steht weiterhin in der Datei - hier steht nur der Zustand." Zwei Folgen, die erst im Betrieb sichtbar wurden: 1. Die Roadmap-Tafel kann die Begruendung nicht zeigen. Sie sieht die Datenbank; die Datei liegt auf einem Entwicklungsrechner. Im Seitenpanel stand deshalb nur doc_anchor als nicht anklickbarer Text mit dem Hinweis, er zeige auf etwas, das das WebUI nie zu sehen bekommt. 2. Schwerer: Die Beschreibung von dc_roadmap wies Agenten an, vor dem erneuten Vorschlagen eines zurueckgestellten Vorhabens "diesen Absatz" zu lesen. Ein Agent in polytrader oder clawddotnet kann ihn nicht lesen - er liegt in einem fremden Repository. Die Anweisung war nicht befolgbar. Dazu kam, dass cli/roadmap.php --check PHP braucht und auf dem Windows-Arbeitsplatz keines liegt: Die Haelfte der Pflegemaschinerie, die die Zweiteilung zusammenhalten sollte, lief dort nie. == Was sich aendert == bugtracker_items und roadmap_epics tragen rationale (Migration 020). Ein EIGENES Feld neben description, nicht statt dessen: description sagt, was gemeldet wurde, und kommt oft von einer fremden Maschine; rationale sagt, warum wir uns so entschieden haben. Zusammengelegt haette jede redaktionelle Ueberarbeitung den urspruenglichen Wortlaut der Meldung ueberschrieben, also die Grundlage, auf der entschieden wurde. Der Verlauf vermerkt nur, DASS die Begruendung sich geaendert hat und um wie viele Zeichen. Der ganze Absatz ein zweites Mal im Verlauf haette ihn nach der fuenften Ueberarbeitung unlesbar gemacht. == Der Weg herauf, einmalig == ?action=import_rationale liest eine mitgeschickte Roadmap-Datei. Drei Formen werden erkannt: Bereichsabschnitte, Vorhabensabschnitte und Tabellenzeilen. Die dritte ist kein Beiwerk - der Abschnitt "Verworfen" fuehrt seine zwoelf Ablehnungen als Tabelle, und ohne sie bliebe ausgerechnet das nicht am Item auffindbar, was am haeufigsten gebraucht wird: warum etwas schon einmal abgelehnt wurde. Ohne overwrite=true bleibt stehen, was im DC schon steht. == Der Weg herunter, dauerhaft == Ein zweites Markerpaar dc:rationale neben dc:roadmap. Bewusst ein zweites und keine Erweiterung des ersten: Zwischen den beiden steht in jeder gewachsenen Roadmap-Datei Handgeschriebenes - Einleitung, Leitlinie, Statuszeichen -, und ein einziger Block haette das beim naechsten Erzeugen verschluckt. ?action=render_file setzt beide Bloecke serverseitig ein, damit die Markersuche nur einmal umgesetzt ist. Sie hat eine Feinheit, die zweimal umgesetzt zweimal verschieden waere: Ein im Fliesstext ERWAEHNTER Marker darf nicht gefunden werden - die Einleitung dieser Datei erwaehnt jetzt beide. == Werkzeuge == scripts/dc.py roadmap-import und roadmap-write, in Python und nicht in cli/roadmap.php - sonst waeren sie auf dem Arbeitsplatz wieder nicht aufrufbar. roadmap-write behaelt die Zeilenenden der Datei bei. == Bestand == 35 Vorhaben und 6 Bereiche aus docs/ROADMAP.md uebernommen, 476 Zeilen handgepflegte Prosa durch den erzeugten Block ersetzt. Gegengeprueft auf Wortebene: Aus dem Fliesstext fehlt nichts, die einzigen Abweichungen sind die ersetzten Ueberschriften und die alte Einleitung. Kaertchen ohne hinterlegte Begruendung tragen auf der Tafel eine dezente Marke - erledigte nicht, dort waere sie ein Vorwurf ohne Adressaten. MCP-Adapter auf 1.3.0: dc_roadmap_set nimmt rationale, und die Beschreibung von dc_roadmap verweist nicht mehr auf eine Datei, die der Agent nicht erreichen kann. Geprueft: 7 neue Testfaelle, Suite gesamt 179 von 179. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
621 lines
25 KiB
Python
621 lines
25 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Deploymentcenter CLI Tool (dc.py)
|
|
Umfassendes Befehlszeilenwerkzeug zur Interaktion mit dem Deploymentcenter
|
|
(Bugtracker, Roadmap, Watchdog, Supervisor und Projects).
|
|
Funktioniert out-of-the-box ohne externe Abhängigkeiten (nur Python Standardbibliothek).
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import urllib.request
|
|
import urllib.error
|
|
import urllib.parse
|
|
import argparse
|
|
from pathlib import Path
|
|
from typing import Optional, Dict, Any, List
|
|
|
|
DEFAULT_BASE_URL = "https://dc.mhdf.de"
|
|
DEFAULT_PROJECT = "deploymentcenter"
|
|
FALLBACK_TOKEN = ""
|
|
|
|
# ANSI Farbcodes (unterstützt auf modernen Windows- und POSIX-Terminals)
|
|
class Colors:
|
|
HEADER = '\033[95m'
|
|
BLUE = '\033[94m'
|
|
CYAN = '\033[96m'
|
|
GREEN = '\033[92m'
|
|
YELLOW = '\033[93m'
|
|
RED = '\033[91m'
|
|
BOLD = '\033[1m'
|
|
DIM = '\033[2m'
|
|
RESET = '\033[0m'
|
|
|
|
@classmethod
|
|
def disable(cls):
|
|
cls.HEADER = ''
|
|
cls.BLUE = ''
|
|
cls.CYAN = ''
|
|
cls.GREEN = ''
|
|
cls.YELLOW = ''
|
|
cls.RED = ''
|
|
cls.BOLD = ''
|
|
cls.DIM = ''
|
|
cls.RESET = ''
|
|
|
|
if not sys.stdout.isatty() or os.name == 'nt' and 'WT_SESSION' not in os.environ and 'TERM' not in os.environ:
|
|
# Unter Windows CMD ohne ANSI-Support Farben beibehalten wenn möglich
|
|
try:
|
|
import ctypes
|
|
kernel32 = ctypes.windll.kernel32
|
|
kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def find_token() -> str:
|
|
"""Ermittelt das Deploymentcenter-Token nach Priorität."""
|
|
# 1. Umgebungsvariable
|
|
env_token = os.environ.get("DC_TOKEN")
|
|
if env_token and env_token.strip():
|
|
return env_token.strip()
|
|
|
|
# 2. .mcp.json im aktuellen Verzeichnis oder Repo-Root
|
|
candidate_paths = [
|
|
os.path.join(os.getcwd(), ".mcp.json"),
|
|
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".mcp.json"),
|
|
os.path.expanduser("~/.mcp.json")
|
|
]
|
|
for cp in candidate_paths:
|
|
if os.path.isfile(cp):
|
|
try:
|
|
with open(cp, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
servers = data.get("mcpServers", {})
|
|
dc_server = servers.get("deploymentcenter", {})
|
|
token = dc_server.get("env", {}).get("DC_TOKEN")
|
|
if token and token.strip():
|
|
return token.strip()
|
|
except Exception:
|
|
pass
|
|
|
|
# 3. Fallback
|
|
return FALLBACK_TOKEN
|
|
|
|
|
|
class DcClient:
|
|
def __init__(self, base_url: str, token: str):
|
|
self.base_url = base_url.rstrip("/")
|
|
self.token = token
|
|
|
|
def request(self, endpoint: str, method: str = "GET", data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
url = f"{self.base_url}{endpoint}"
|
|
headers = {
|
|
"Authorization": f"Bearer {self.token}",
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "dc-cli/1.0"
|
|
}
|
|
body = json.dumps(data).encode("utf-8") if data is not None else None
|
|
|
|
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
raw = resp.read().decode("utf-8")
|
|
return json.loads(raw) if raw.strip() else {}
|
|
except urllib.error.HTTPError as e:
|
|
raw_err = e.read().decode("utf-8")
|
|
try:
|
|
err_json = json.loads(raw_err)
|
|
return {"_error": True, "status_code": e.code, "error": err_json}
|
|
except Exception:
|
|
return {"_error": True, "status_code": e.code, "message": raw_err or str(e)}
|
|
except Exception as e:
|
|
return {"_error": True, "status_code": 0, "message": str(e)}
|
|
|
|
|
|
def format_status(status: str) -> str:
|
|
s = (status or "").lower()
|
|
if s in ["resolved", "closed", "verified"]:
|
|
return f"{Colors.GREEN}{status}{Colors.RESET}"
|
|
if s in ["in_progress", "in_verification", "awaiting_verification"]:
|
|
return f"{Colors.YELLOW}{status}{Colors.RESET}"
|
|
if s in ["triage_external", "triage"]:
|
|
return f"{Colors.CYAN}{status}{Colors.RESET}"
|
|
if s in ["open", "fix_insufficient"]:
|
|
return f"{Colors.RED}{status}{Colors.RESET}"
|
|
return status
|
|
|
|
|
|
def format_severity(sev: str) -> str:
|
|
s = (sev or "").lower()
|
|
if s in ["critical", "high"]:
|
|
return f"{Colors.RED}{Colors.BOLD}{sev}{Colors.RESET}"
|
|
if s == "medium":
|
|
return f"{Colors.YELLOW}{sev}{Colors.RESET}"
|
|
if s == "low":
|
|
return f"{Colors.BLUE}{sev}{Colors.RESET}"
|
|
return f"{Colors.DIM}{sev}{Colors.RESET}"
|
|
|
|
|
|
def cmd_list(client: DcClient, args):
|
|
query_params = {
|
|
"action": "list",
|
|
"limit": args.limit
|
|
}
|
|
if args.project:
|
|
query_params["project_slug"] = args.project
|
|
if args.status and args.status != "all":
|
|
query_params["status"] = args.status
|
|
if args.type and args.type != "all":
|
|
query_params["type"] = args.type
|
|
if args.search:
|
|
query_params["q"] = args.search
|
|
|
|
url = "/api/bugtracker/v1/manage?" + urllib.parse.urlencode(query_params)
|
|
res = client.request(url)
|
|
|
|
if res.get("_error"):
|
|
print(f"{Colors.RED}Fehler beim Abrufen:{Colors.RESET} {res}")
|
|
return 1
|
|
|
|
items = res.get("items", [])
|
|
total = res.get("total", len(items))
|
|
|
|
# Optionaler client-seitiger Sort
|
|
if args.sort == "id_desc":
|
|
items.sort(key=lambda x: x.get("id", 0), reverse=True)
|
|
elif args.sort == "id_asc":
|
|
items.sort(key=lambda x: x.get("id", 0))
|
|
elif args.sort == "severity":
|
|
order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "wishlist": 4, "idea": 5}
|
|
items.sort(key=lambda x: order.get(x.get("severity", "").lower(), 99))
|
|
|
|
print(f"{Colors.BOLD}Bugtracker Items ({len(items)} von {total} angezeigt):{Colors.RESET}\n")
|
|
for it in items:
|
|
iid = it.get("id")
|
|
status = format_status(it.get("status", ""))
|
|
itype = it.get("type", "")
|
|
sev = format_severity(it.get("severity", ""))
|
|
proj = it.get("project_slug", "")
|
|
title = it.get("title", "")
|
|
epic = it.get("epic")
|
|
epic_str = f" [{Colors.CYAN}{epic}{Colors.RESET}]" if epic else ""
|
|
|
|
print(f"#{Colors.BOLD}{iid:<4}{Colors.RESET} [{proj}] [{status}] [{itype}] ({sev}){epic_str} {title}")
|
|
|
|
return 0
|
|
|
|
|
|
def cmd_get(client: DcClient, args):
|
|
res = client.request(f"/api/bugtracker/v1/manage?action=get&id={args.id}")
|
|
if res.get("_error"):
|
|
print(f"{Colors.RED}Fehler beim Abrufen von #{args.id}:{Colors.RESET} {res}")
|
|
return 1
|
|
|
|
item = res.get("item")
|
|
if not item:
|
|
print(f"{Colors.RED}Item #{args.id} nicht gefunden.{Colors.RESET}")
|
|
return 1
|
|
|
|
print(f"{Colors.BOLD}{'='*80}{Colors.RESET}")
|
|
print(f"#{Colors.BOLD}{item.get('id')} {item.get('title')}{Colors.RESET}")
|
|
print(f"Projekt: {item.get('project_slug')} (gemeldet von: {item.get('reported_from', 'intern')})")
|
|
print(f"Status: {format_status(item.get('status'))} | Typ: {item.get('type')} | Schweregrad: {format_severity(item.get('severity'))}")
|
|
if item.get('epic'):
|
|
print(f"Roadmap: Bereich {Colors.CYAN}{item.get('epic')}{Colors.RESET} | Rang: {item.get('roadmap_rank')}")
|
|
if item.get('file_path'):
|
|
print(f"Fundort: {item.get('file_path')}:{item.get('line_no')}")
|
|
if item.get('build_version'):
|
|
print(f"Version: {item.get('build_version')} (Commit: {item.get('commit_sha')})")
|
|
if item.get('target_build'):
|
|
print(f"Zielversion:{item.get('target_build')}")
|
|
print(f"Erstellt: {item.get('created_at')} von {item.get('created_by')}")
|
|
print(f"{Colors.BOLD}{'='*80}{Colors.RESET}\n")
|
|
|
|
desc = item.get("description")
|
|
if desc:
|
|
print(f"{Colors.BOLD}Beschreibung:{Colors.RESET}\n{desc.strip()}\n")
|
|
|
|
err_msg = item.get("error_message")
|
|
if err_msg:
|
|
print(f"{Colors.BOLD}Fehlermeldung:{Colors.RESET}\n{Colors.RED}{err_msg.strip()}{Colors.RESET}\n")
|
|
|
|
trace = item.get("stack_trace")
|
|
if trace:
|
|
print(f"{Colors.BOLD}Stack Trace:{Colors.RESET}\n{Colors.DIM}{trace.strip()}{Colors.RESET}\n")
|
|
|
|
comments = item.get("comments", [])
|
|
if comments:
|
|
print(f"{Colors.BOLD}Kommentare & Verlauf ({len(comments)}):{Colors.RESET}")
|
|
for c in comments:
|
|
action = f" [{c.get('action_taken')}]" if c.get('action_taken') else ""
|
|
print(f" - {Colors.DIM}{c.get('created_at')}{Colors.RESET} {Colors.BOLD}{c.get('author')}{Colors.RESET}{action}:")
|
|
comment_text = c.get('comment', '').strip()
|
|
for line in comment_text.splitlines():
|
|
print(f" {line}")
|
|
print()
|
|
|
|
return 0
|
|
|
|
|
|
def cmd_comments(client: DcClient, args):
|
|
res = client.request(f"/api/bugtracker/v1/manage?action=get&id={args.id}")
|
|
if res.get("_error"):
|
|
print(f"{Colors.RED}Fehler beim Abrufen von #{args.id}:{Colors.RESET} {res}")
|
|
return 1
|
|
|
|
item = res.get("item")
|
|
if not item:
|
|
print(f"{Colors.RED}Item #{args.id} nicht gefunden.{Colors.RESET}")
|
|
return 1
|
|
|
|
comments = item.get("comments", [])
|
|
if not comments:
|
|
print(f"{Colors.YELLOW}Keine Kommentare zu Item #{args.id} vorhanden.{Colors.RESET}")
|
|
return 0
|
|
|
|
print(f"{Colors.BOLD}Kommentare & Diskussionsverlauf zu #{item.get('id')} ({item.get('title')}):{Colors.RESET}\n")
|
|
for c in comments:
|
|
action = f" [{Colors.CYAN}{c.get('action_taken')}{Colors.RESET}]" if c.get('action_taken') else ""
|
|
print(f"- {Colors.DIM}{c.get('created_at')}{Colors.RESET} {Colors.BOLD}{c.get('author')}{Colors.RESET}{action}:")
|
|
for line in (c.get('comment') or '').strip().splitlines():
|
|
print(f" {line}")
|
|
print()
|
|
return 0
|
|
|
|
|
|
def cmd_comment(client: DcClient, args):
|
|
payload = {
|
|
"comment": args.text,
|
|
"action_taken": args.action or "commented"
|
|
}
|
|
res = client.request(f"/api/bugtracker/v1/manage?action=comment&id={args.id}", method="POST", data=payload)
|
|
if res.get("_error"):
|
|
print(f"{Colors.RED}Fehler beim Kommentieren:{Colors.RESET} {res}")
|
|
return 1
|
|
print(f"{Colors.GREEN}Kommentar zu #{args.id} erfolgreich hinzugefügt.{Colors.RESET}")
|
|
return 0
|
|
|
|
|
|
def cmd_resolve(client: DcClient, args):
|
|
payload = {
|
|
"resolved_in_build": args.build,
|
|
"resolution_notes": args.notes or ""
|
|
}
|
|
res = client.request(f"/api/bugtracker/v1/manage?action=resolve&id={args.id}", method="POST", data=payload)
|
|
if res.get("_error"):
|
|
print(f"{Colors.RED}Fehler beim Abschließen:{Colors.RESET} {res}")
|
|
return 1
|
|
print(f"{Colors.GREEN}Item #{args.id} erfolgreich auf 'resolved' gesetzt (Build: {args.build}).{Colors.RESET}")
|
|
return 0
|
|
|
|
|
|
def cmd_release(client: DcClient, args):
|
|
payload = {"note": args.note or ""}
|
|
res = client.request(f"/api/bugtracker/v1/manage?action=release&id={args.id}", method="POST", data=payload)
|
|
if res.get("_error"):
|
|
print(f"{Colors.RED}Fehler beim Freigeben:{Colors.RESET} {res}")
|
|
return 1
|
|
print(f"{Colors.GREEN}Item #{args.id} erfolgreich freigegeben.{Colors.RESET}")
|
|
return 0
|
|
|
|
|
|
def cmd_next(client: DcClient, args):
|
|
payload = {
|
|
"project_slug": args.project,
|
|
"limit": args.limit
|
|
}
|
|
res = client.request("/api/bugtracker/v1/manage?action=next", method="POST", data=payload)
|
|
if res.get("_error"):
|
|
print(f"{Colors.RED}Fehler beim Abrufen der nächsten Aufgabe:{Colors.RESET} {res}")
|
|
return 1
|
|
items = res.get("items", [])
|
|
if not items:
|
|
print(f"{Colors.YELLOW}Keine anstehenden Aufgaben für Projekt '{args.project}' in der Warteschlange.{Colors.RESET}")
|
|
return 0
|
|
print(f"{Colors.GREEN}Nächste Aufgabe(n) übernommen:{Colors.RESET}\n")
|
|
for it in items:
|
|
print(f"#{Colors.BOLD}{it.get('id')}{Colors.RESET} [{it.get('status')}] ({it.get('severity')}): {it.get('title')}")
|
|
if it.get('description'):
|
|
print(f" {it.get('description')[:150]}...")
|
|
return 0
|
|
|
|
|
|
def cmd_report(client: DcClient, args):
|
|
payload = {
|
|
"project_slug": args.project,
|
|
"type": args.type,
|
|
"title": args.title,
|
|
"description": args.desc,
|
|
"severity": args.severity
|
|
}
|
|
if args.file: payload["file_path"] = args.file
|
|
if args.line: payload["line_no"] = args.line
|
|
if args.ref: payload["client_ref"] = args.ref
|
|
if args.version: payload["build_version"] = args.version
|
|
if args.env: payload["environment"] = args.env
|
|
|
|
res = client.request("/api/bugtracker/v1/report", method="POST", data=payload)
|
|
if res.get("_error"):
|
|
print(f"{Colors.RED}Fehler beim Erstellen des Eintrags:{Colors.RESET} {res}")
|
|
return 1
|
|
iid = res.get("id") or res.get("item_id")
|
|
print(f"{Colors.GREEN}Item #{iid} erfolgreich angelegt!{Colors.RESET}")
|
|
return 0
|
|
|
|
|
|
def cmd_roadmap(client: DcClient, args):
|
|
query_params = {
|
|
"action": args.action or "list",
|
|
"project": args.project,
|
|
"limit": args.limit
|
|
}
|
|
url = "/api/bugtracker/v1/roadmap?" + urllib.parse.urlencode(query_params)
|
|
res = client.request(url)
|
|
if res.get("_error"):
|
|
print(f"{Colors.RED}Fehler beim Abrufen der Roadmap:{Colors.RESET} {res}")
|
|
return 1
|
|
|
|
items = res.get("items", [])
|
|
print(f"{Colors.BOLD}Roadmap für {args.project} ({len(items)} Items):{Colors.RESET}\n")
|
|
for it in items:
|
|
iid = it.get("id")
|
|
epic = it.get("epic", "-")
|
|
rank = it.get("roadmap_rank", "-")
|
|
status = format_status(it.get("status", ""))
|
|
title = it.get("title", "")
|
|
print(f"#{Colors.BOLD}{iid:<4}{Colors.RESET} [{Colors.CYAN}{epic}{Colors.RESET} #{rank}] [{status}] {title}")
|
|
return 0
|
|
|
|
|
|
def cmd_roadmap_import(client: DcClient, args):
|
|
"""
|
|
Die Begruendungsprosa einer Roadmap-Datei ins Deploymentcenter holen.
|
|
|
|
Einmalig gedacht. Bis 2.9.0 stand die Begruendung nur in der Datei - die
|
|
Roadmap-Tafel konnte sie nicht zeigen, und ein Agent in einem anderen
|
|
Projekt kam gar nicht an sie heran, obwohl die Werkzeugbeschreibung ihn
|
|
anwies, sie zu lesen.
|
|
"""
|
|
pfad = Path(args.file)
|
|
if not pfad.is_file():
|
|
print(f"{Colors.RED}FEHLER:{Colors.RESET} Datei nicht gefunden: {pfad}")
|
|
return 1
|
|
|
|
inhalt = pfad.read_text(encoding="utf-8")
|
|
|
|
url = "/api/bugtracker/v1/roadmap?" + urllib.parse.urlencode({"action": "import_rationale"})
|
|
res = client.request(url, method="POST", data={
|
|
"project_slug": args.project,
|
|
"content": inhalt,
|
|
"overwrite": bool(args.overwrite),
|
|
})
|
|
|
|
if res.get("_error") or res.get("status") != "success":
|
|
print(f"{Colors.RED}Fehler beim Einlesen:{Colors.RESET} {res}")
|
|
return 1
|
|
|
|
bericht = res.get("report", {})
|
|
items = bericht.get("items", {})
|
|
epics = bericht.get("epics", {})
|
|
|
|
print(f"{Colors.BOLD}{res.get('message', '')}{Colors.RESET}")
|
|
print(f" Vorhaben : {items.get('gespeichert', 0)} uebernommen, "
|
|
f"{items.get('unveraendert', 0)} unveraendert")
|
|
print(f" Bereiche : {epics.get('gespeichert', 0)} uebernommen, "
|
|
f"{epics.get('unveraendert', 0)} unveraendert")
|
|
|
|
uebersprungen = list(items.get("uebersprungen", [])) + list(epics.get("uebersprungen", []))
|
|
if uebersprungen:
|
|
print(f"\n{Colors.YELLOW}Uebersprungen:{Colors.RESET}")
|
|
for zeile in uebersprungen:
|
|
print(f" - {zeile}")
|
|
if not args.overwrite and any("overwrite=false" in z for z in uebersprungen):
|
|
print(f"\n Mit {Colors.BOLD}--overwrite{Colors.RESET} wuerde der Stand der Datei gewinnen.")
|
|
|
|
return 0
|
|
|
|
|
|
def cmd_roadmap_write(client: DcClient, args):
|
|
"""
|
|
Die Roadmap-Datei aus dem Deploymentcenter erzeugen.
|
|
|
|
Beide erzeugten Bloecke werden eingesetzt: der Zustand zwischen den
|
|
dc:roadmap-Markern und die Begruendungen zwischen den dc:rationale-
|
|
Markern. Was ausserhalb steht, bleibt unberuehrt.
|
|
|
|
Das Einsetzen macht der Server - dieselbe Markersuche zweimal umzusetzen
|
|
hiesse, sie beim zweiten Mal anders umzusetzen.
|
|
"""
|
|
pfad = Path(args.file)
|
|
if not pfad.is_file():
|
|
print(f"{Colors.RED}FEHLER:{Colors.RESET} Datei nicht gefunden: {pfad}")
|
|
return 1
|
|
|
|
vorher = pfad.read_text(encoding="utf-8")
|
|
|
|
url = "/api/bugtracker/v1/roadmap?" + urllib.parse.urlencode({"action": "render_file"})
|
|
res = client.request(url, method="POST", data={
|
|
"project_slug": args.project,
|
|
"content": vorher,
|
|
})
|
|
|
|
if res.get("_error") or res.get("status") != "success":
|
|
print(f"{Colors.RED}Fehler beim Erzeugen:{Colors.RESET} {res}")
|
|
return 1
|
|
|
|
nachher = res.get("markdown", "")
|
|
|
|
if not res.get("changed"):
|
|
print(f"{Colors.GREEN}Unveraendert{Colors.RESET} - die Datei ist auf dem Stand des Deploymentcenters.")
|
|
return 0
|
|
|
|
if args.dry_run:
|
|
alt, neu = len(vorher.splitlines()), len(nachher.splitlines())
|
|
print(f"{Colors.YELLOW}Probelauf:{Colors.RESET} {pfad} wuerde sich aendern "
|
|
f"({alt} -> {neu} Zeilen). Nichts geschrieben.")
|
|
return 0
|
|
|
|
# Zeilenenden der vorhandenen Datei beibehalten - sonst steht nach dem
|
|
# ersten Lauf die ganze Datei als Aenderung im Diff.
|
|
if "\r\n" in vorher:
|
|
nachher = nachher.replace("\r\n", "\n").replace("\n", "\r\n")
|
|
|
|
pfad.write_text(nachher, encoding="utf-8", newline="")
|
|
print(f"{Colors.GREEN}Geschrieben:{Colors.RESET} {pfad} "
|
|
f"({len(nachher.splitlines())} Zeilen)")
|
|
return 0
|
|
|
|
|
|
def cmd_projects(client: DcClient, args):
|
|
res = client.request("/api/bugtracker/v1/projects")
|
|
if res.get("_error"):
|
|
print(f"{Colors.RED}Fehler beim Abrufen der Projekte:{Colors.RESET} {res}")
|
|
return 1
|
|
projects = res.get("projects", [])
|
|
print(f"{Colors.BOLD}Verfügbare Projekte ({len(projects)}):{Colors.RESET}\n")
|
|
for p in projects:
|
|
slug = p.get("slug", "")
|
|
name = p.get("name", "")
|
|
open_bugs = p.get("open_bugs", 0)
|
|
monitors = p.get("active_monitors", 0)
|
|
print(f"- {Colors.BOLD}{slug:<16}{Colors.RESET} {name} (Offene Bugs: {open_bugs}, Monitore: {monitors})")
|
|
return 0
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Deploymentcenter CLI (dc.py) — Direkter Zugriff auf Bugtracker, Roadmap & Services",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
)
|
|
parser.add_argument("--base-url", default=os.environ.get("DC_BASE_URL", DEFAULT_BASE_URL), help="Basis-URL (Vorgabe: https://dc.mhdf.de)")
|
|
parser.add_argument("--token", default=None, help="Deploymentcenter API-Token (Vorgabe: $DC_TOKEN oder .mcp.json)")
|
|
parser.add_argument("--no-color", action="store_true", help="Farbige Terminalausgabe deaktivieren")
|
|
|
|
subparsers = parser.add_subparsers(dest="command", help="Befehle")
|
|
|
|
# list
|
|
p_list = subparsers.add_parser("list", help="Items auflisten")
|
|
p_list.add_argument("--project", "-p", default=DEFAULT_PROJECT, help=f"Projekt-Slug (Vorgabe: {DEFAULT_PROJECT}, 'all' für alle)")
|
|
p_list.add_argument("--status", "-s", default="open", help="Status-Filter (open, in_progress, triage_external, resolved, all)")
|
|
p_list.add_argument("--type", "-t", default="all", help="Typ-Filter (bug, feature_request, idea, all)")
|
|
p_list.add_argument("--search", "-q", default=None, help="Suchbegriff")
|
|
p_list.add_argument("--limit", "-n", type=int, default=50, help="Maximale Trefferzahl (Vorgabe: 50)")
|
|
p_list.add_argument("--sort", choices=["id_desc", "id_asc", "severity"], default="id_desc", help="Sortierung")
|
|
|
|
# get
|
|
p_get = subparsers.add_parser("get", help="Details zu einem Item anzeigen (inkl. Kommentare)")
|
|
p_get.add_argument("id", type=int, help="ID des Bugtracker-Items")
|
|
|
|
# comments
|
|
p_comms = subparsers.add_parser("comments", help="Nur Kommentare & Diskussionsverlauf eines Items anzeigen")
|
|
p_comms.add_argument("id", type=int, help="ID des Bugtracker-Items")
|
|
|
|
# comment
|
|
p_comm = subparsers.add_parser("comment", help="Kommentar zu einem Item hinzufügen")
|
|
p_comm.add_argument("id", type=int, help="ID des Bugtracker-Items")
|
|
p_comm.add_argument("text", help="Kommentartext")
|
|
p_comm.add_argument("--action", "-a", choices=["investigated", "fix_proposed", "pr_opened", "needs_human", "blocked", "commented"], default="commented", help="Aktion")
|
|
|
|
# resolve
|
|
p_res = subparsers.add_parser("resolve", help="Item als gelöst markieren")
|
|
p_res.add_argument("id", type=int, help="ID des Bugtracker-Items")
|
|
p_res.add_argument("--build", "-b", required=True, help="Build-Version, in der der Fehler behoben wurde")
|
|
p_res.add_argument("--notes", help="Zusammenfassung der Änderung / Notizen")
|
|
|
|
# release
|
|
p_rel = subparsers.add_parser("release", help="Reserviertes Item freigeben")
|
|
p_rel.add_argument("id", type=int, help="ID des Bugtracker-Items")
|
|
p_rel.add_argument("--note", help="Grund der Freigabe")
|
|
|
|
# next
|
|
p_next = subparsers.add_parser("next", help="Nächste Aufgabe aus der Warteschlange übernehmen")
|
|
p_next.add_argument("--project", "-p", default=DEFAULT_PROJECT, help="Projekt-Slug")
|
|
p_next.add_argument("--limit", "-n", type=int, default=1, help="Anzahl Items")
|
|
|
|
# report
|
|
p_rep = subparsers.add_parser("report", help="Neuen Fehler oder Wunsch melden")
|
|
p_rep.add_argument("--title", required=True, help="Titel")
|
|
p_rep.add_argument("--desc", required=True, help="Beschreibung")
|
|
p_rep.add_argument("--project", "-p", default=DEFAULT_PROJECT, help="Projekt-Slug")
|
|
p_rep.add_argument("--type", choices=["bug", "feature_request", "idea"], default="bug", help="Typ")
|
|
p_rep.add_argument("--severity", choices=["critical", "high", "medium", "low", "wishlist", "idea"], default="medium", help="Schweregrad")
|
|
p_rep.add_argument("--file", help="Betroffener Dateipfad")
|
|
p_rep.add_argument("--line", type=int, help="Zeilennummer")
|
|
p_rep.add_argument("--ref", help="Eindeutige client_ref zur Duplikaterkennung")
|
|
p_rep.add_argument("--version", help="Build-Version")
|
|
p_rep.add_argument("--env", default="development", help="Umgebung")
|
|
|
|
# roadmap
|
|
p_road = subparsers.add_parser("roadmap", help="Roadmap anzeigen")
|
|
p_road.add_argument("--project", "-p", default=DEFAULT_PROJECT, help="Projekt-Slug")
|
|
p_road.add_argument("--action", choices=["list", "next", "render", "stats"], default="list", help="Roadmap-Aktion")
|
|
p_road.add_argument("--limit", "-n", type=int, default=30, help="Anzahl Einträge")
|
|
|
|
# roadmap-import: Begründungen aus der Datei ins Deploymentcenter holen
|
|
p_imp = subparsers.add_parser(
|
|
"roadmap-import",
|
|
help="Begründungsprosa aus einer Roadmap-Datei ins Deploymentcenter einlesen (einmalig)")
|
|
p_imp.add_argument("--project", "-p", default=DEFAULT_PROJECT, help="Projekt-Slug")
|
|
p_imp.add_argument("--file", "-f", default="docs/ROADMAP.md", help="Pfad zur Roadmap-Datei")
|
|
p_imp.add_argument("--overwrite", action="store_true",
|
|
help="Bereits hinterlegte Begründungen durch den Stand der Datei ersetzen")
|
|
|
|
# roadmap-write: Datei aus dem Deploymentcenter erzeugen
|
|
p_wr = subparsers.add_parser(
|
|
"roadmap-write",
|
|
help="Roadmap-Datei aus dem Deploymentcenter erzeugen (Zustand + Begründungen)")
|
|
p_wr.add_argument("--project", "-p", default=DEFAULT_PROJECT, help="Projekt-Slug")
|
|
p_wr.add_argument("--file", "-f", default="docs/ROADMAP.md", help="Pfad zur Roadmap-Datei")
|
|
p_wr.add_argument("--dry-run", "-n", action="store_true",
|
|
help="Nur anzeigen, ob sich etwas ändern würde")
|
|
|
|
# projects
|
|
subparsers.add_parser("projects", help="Alle Projekte auflisten")
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.no_color:
|
|
Colors.disable()
|
|
|
|
token = args.token or find_token()
|
|
if not token:
|
|
print(f"{Colors.RED}FEHLER: Kein Deploymentcenter-Token gefunden.{Colors.RESET}")
|
|
print("Bitte setze die Umgebungsvariable DC_TOKEN oder gib --token an.")
|
|
sys.exit(1)
|
|
|
|
client = DcClient(base_url=args.base_url, token=token)
|
|
|
|
if args.command == "list":
|
|
sys.exit(cmd_list(client, args))
|
|
elif args.command == "get":
|
|
sys.exit(cmd_get(client, args))
|
|
elif args.command == "comments":
|
|
sys.exit(cmd_comments(client, args))
|
|
elif args.command == "comment":
|
|
sys.exit(cmd_comment(client, args))
|
|
elif args.command == "resolve":
|
|
sys.exit(cmd_resolve(client, args))
|
|
elif args.command == "release":
|
|
sys.exit(cmd_release(client, args))
|
|
elif args.command == "next":
|
|
sys.exit(cmd_next(client, args))
|
|
elif args.command == "report":
|
|
sys.exit(cmd_report(client, args))
|
|
elif args.command == "roadmap":
|
|
sys.exit(cmd_roadmap(client, args))
|
|
elif args.command == "roadmap-import":
|
|
sys.exit(cmd_roadmap_import(client, args))
|
|
elif args.command == "roadmap-write":
|
|
sys.exit(cmd_roadmap_write(client, args))
|
|
elif args.command == "projects":
|
|
sys.exit(cmd_projects(client, args))
|
|
else:
|
|
parser.print_help()
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|