#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Eine handgepflegte ROADMAP.md ins Deploymentcenter uebernehmen. Einmalig je Projekt. Das Verfahren steht in docs/ROADMAP_MIGRATION.md; dieses Skript nimmt davon die Schritte 3 und 4 ab - Items anlegen und einplanen. python scripts/roadmap_import.py --lesen Nur auswerten und zeigen, was gefunden wurde. Kein Netzverkehr. python scripts/roadmap_import.py --probe Gegen die Testinstanz laufen lassen. Braucht ein Werkbank-Token, das das Skript sich ueber testctl selbst ausstellt. python scripts/roadmap_import.py --uebernehmen Gegen die Produktion. DC_TOKEN muss ein Werkbank-Token des Projekts sein (owner_type = dev_agent, Scope bugtracker:manage). Wiederholbar: Jedes Item traegt ein client_ref aus Projekt und alter Kennung ("roadmap-deploymentcenter-a4"). Ein zweiter Lauf legt deshalb nichts doppelt an, sondern findet dieselben Items wieder und schreibt nur die Roadmap-Felder neu. Bei sechzig Eintraegen laeuft irgendetwas immer zweimal. """ import argparse import json import os import re import sys import time import urllib.error import urllib.parse import urllib.request from pathlib import Path PROJEKT_DIR = Path(__file__).resolve().parent.parent ROADMAP = PROJEKT_DIR / 'docs' / 'ROADMAP.md' SLUG = 'deploymentcenter' ANKER_DATEI = 'docs/ROADMAP.md' # Zeichen -> (Status, ob als Vorhaben zaehlend) MARKER = { 'βœ…': 'resolved', 'πŸ”¨': 'in_progress', 'πŸ“‹': 'open', '🧊': 'deferred', '❌': 'rejected', 'βš™οΈ': 'open', # Betriebsaufgabe: kein Code, aber uebernehmbar } # Reihenfolge der Bereiche in roadmap_epics (Migration 016) BEREICHE = { 'A': 'Supervisor und Befunde', 'B': 'Oberflaeche', 'C': 'Projektlandschaft', 'D': 'Werkzeuganbindung (MCP)', 'E': 'Betrieb', 'F': 'Quelltext-Zugriff', 'G': 'Testumgebung', 'H': 'Verworfen', 'I': 'Roadmap im Deploymentcenter', } # --------------------------------------------------------------------------- # Auswerten # --------------------------------------------------------------------------- def entferne_auszeichnung(text): """Markdown-Auszeichnung raus - ein Item-Titel ist kein Fliesstext.""" text = re.sub(r'~~(.+?)~~', r'\1', text) text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) text = re.sub(r'`([^`]+)`', r'\1', text) text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text) return re.sub(r'\s+', ' ', text).strip() def lies_ausloeser(quelle, kennung): """ Holt den Ausloeser eines zurueckgestellten Vorhabens aus seinem Absatz. Die Datei schreibt ihn durchgaengig als "**Ausloeser:** ...". Fehlt er, ist der Eintrag nach der eigenen Regel gar nicht zurueckgestellt, sondern vergessen - dann meldet das Skript das, statt etwas zu erfinden. """ abschnitt = re.search( r'^###\s+%s\s+[β€”-][^\n]*\n(.*?)(?=^###|\Z)' % re.escape(kennung), quelle, re.M | re.S) if not abschnitt: return None m = re.search(r'\*\*Ausl[ΓΆo]ser:\*\*\s*(.+?)(?:\n\n|\Z)', abschnitt.group(1), re.S) if not m: return None return entferne_auszeichnung(m.group(1))[:400] def lies_fassung(stand): """Aus "βœ… 2.6.1 β€” als Arbeitsschlange" wird "2.6.1".""" m = re.search(r'(\d+\.\d+\.\d+)', stand) return m.group(1) if m else None def anker(kennung, titel): """ Verweis auf die Stelle, an der die Begruendung steht. Nach dem Umbau der Datei traegt die Ueberschrift die Item-Nummer; bis dahin zeigt der Anker auf die alte Kennung. Das ist Absicht: Der Anker soll auch waehrend der Umstellung auf etwas zeigen, das es gibt. """ stueck = re.sub(r'[^a-z0-9]+', '-', entferne_auszeichnung(titel).lower()).strip('-') return '%s#%s-%s' % (ANKER_DATEI, kennung.lower(), stueck[:60]) def lies_reihenfolge(quelle): """ Die Prioritaet aus dem Abschnitt "Als Naechstes". Sie steht dort als nummerierte Liste mit den Kennungen in eckigen Klammern ("1. **[B1] Befunde in der Oberflaeche**"). Sie hier auszulesen statt im Skript zu hinterlegen ist der Punkt: Solange die Datei die Quelle ist, soll auch die Prioritaet aus ihr kommen und nicht aus einer zweiten Liste, die daneben veraltet. Danach ergibt sich dieselbe Reihenfolge aus dem Rang im Deploymentcenter - und der Abschnitt in der Datei entfaellt. @return Liste der Kennungen in Prioritaetsreihenfolge """ m = re.search(r'^## Als N[Γ€a]chstes\b(.*?)(?=^---|\Z)', quelle, re.M | re.S) if not m: return [] return re.findall(r'^\d+\.\s*\*\*\[([A-Z]\d+)\]', m.group(1), re.M) def auswerten(): quelle = ROADMAP.read_text(encoding='utf-8') eintraege = [] gesehen = {} # --- Die nummerierten Vorhaben aus den Bereichstabellen ----------------- for m in re.finditer(r'^\| *([A-Z])(\d+) *\| *(.+?) *\| *(.+?) *\|\s*$', quelle, re.M): bereich, nummer, titel, stand = m.groups() kennung = bereich + nummer status = next((v for k, v in MARKER.items() if k in stand), 'open') betrieb = 'βš™οΈ' in stand # Doppelt vergebene Kennung: Genau der Fehler, den Item-IDs abschaffen. # Nicht stillschweigend zusammenfuehren - beide sind eigene Vorhaben. if kennung in gesehen: gesehen[kennung] += 1 kennung_eindeutig = '%s-%d' % (kennung, gesehen[kennung]) else: gesehen[kennung] = 1 kennung_eindeutig = kennung eintraege.append({ 'kennung': kennung_eindeutig, 'epic': bereich, 'titel': entferne_auszeichnung(titel), 'status': status, 'betrieb': betrieb, 'fassung': lies_fassung(stand), 'ausloeser': lies_ausloeser(quelle, kennung) if status == 'deferred' else None, 'stand_roh': stand, }) # --- Abschnitt H: Verworfenes ------------------------------------------ # # Ohne Kennung, zwei Spalten (Vorschlag | Warum nicht). Sie kommen mit ins # DC, damit dc_roadmap die Frage "wurde das schon abgelehnt?" beantworten # kann - sonst muesste ein Agent dafuer die Datei lesen, und genau das # tut er nicht zuverlaessig. Die Begruendung bleibt in der Datei. h = re.search(r'^## H β€” Verworfen\b(.*?)(?=^## |\Z)', quelle, re.M | re.S) if h: nr = 0 for zeile in re.finditer(r'^\| *(❌ *)?(.+?) *\| *(.+?) *\|\s*$', h.group(1), re.M): vorschlag = entferne_auszeichnung(zeile.group(2)) grund = entferne_auszeichnung(zeile.group(3)) if not vorschlag or vorschlag.startswith('---') or vorschlag == 'Vorschlag': continue nr += 1 eintraege.append({ 'kennung': 'H%d' % nr, 'epic': 'H', 'titel': vorschlag, 'status': 'rejected', 'betrieb': False, 'fassung': None, 'ausloeser': None, 'begruendung': grund, 'stand_roh': '❌', }) return eintraege # --------------------------------------------------------------------------- # Uebertragen # --------------------------------------------------------------------------- class Fehler(Exception): pass def ruf(basis, token, methode, pfad, query=None, body=None, versuche=6): """ Ein Aufruf, mit Geduld bei der Drosselung. /api/bugtracker/v1/report laesst 60 Meldungen je Minute zu. Eine Roadmap mit einundsechzig Eintraegen liegt genau darueber - der erste Probelauf brach beim letzten Eintrag ab. Andere Projekte werden groessere Dateien haben. Behandelt wird das so, wie es die Agentenanleitung von jedem Client verlangt: warten und das Intervall verdoppeln, nicht sofort wiederholen. Abbrechen und den Menschen bitten, es spaeter noch einmal zu versuchen, waere hier besonders unfreundlich - der Lauf ist zwar wiederholbar, aber er dauert. """ url = basis + pfad if query: url += ('&' if '?' in pfad else '?') + urllib.parse.urlencode( {k: v for k, v in query.items() if v is not None}) daten = json.dumps(body).encode('utf-8') if body is not None else None warte = 5 for versuch in range(1, versuche + 1): req = urllib.request.Request(url, data=daten, method=methode) req.add_header('Authorization', 'Bearer ' + token) req.add_header('Content-Type', 'application/json') req.add_header('Accept', 'application/json') try: with urllib.request.urlopen(req, timeout=60) as r: return json.loads(r.read().decode('utf-8') or '{}') except urllib.error.HTTPError as e: roh = e.read().decode('utf-8', 'replace') try: d = json.loads(roh) code = d.get('error', {}).get('code', str(e.code)) text = d.get('error', {}).get('message', roh)[:200] except ValueError: code, text = 'http_%d' % e.code, roh[:200] if code == 'rate_limited' and versuch < versuche: print(' gedrosselt - %d s warten (Versuch %d von %d)' % (warte, versuch, versuche)) time.sleep(warte) warte = min(warte * 2, 90) continue raise Fehler('%s: %s' % (code, text)) raise Fehler('rate_limited: auch nach %d Versuchen gedrosselt.' % versuche) def bereiche_anlegen(basis, token, eintraege, trocken=False): """ Sorgt dafuer, dass jeder benutzte Themenbereich auch angelegt ist. Ohne diesen Schritt entstehen Waisen: Items mit einem Bereich, den roadmap_epics nicht kennt. Die Uebersicht zeigt sie dann als Warnung an, weil ein Tippfehler sonst lautlos einen weiteren Bereich erzeugt - aber hier ist es kein Tippfehler, sondern ein fehlender Schritt. Beim Deploymentcenter faellt das nur bei H auf, weil Migration 016 die uebrigen Bereiche mitbringt. Fuer jedes andere Projekt fehlen sie alle. ON DUPLICATE KEY im Endpunkt macht den Aufruf wiederholbar. """ benutzt = [] for e in eintraege: if e['epic'] not in benutzt: benutzt.append(e['epic']) # Nur anlegen, was fehlt. # # Der Endpunkt schreibt mit ON DUPLICATE KEY, setzt also auch Titel, # Kurzbeschreibung und Sortierung neu. Ein Bereich, den jemand von Hand # nachgeschaerft hat - oder den Migration 016 mit einer Beschreibung # mitbrachte - verloere sie beim zweiten Lauf wieder. Ein Uebernahmeskript # darf anlegen; kuratierte Angaben ueberschreiben darf es nicht. vorhanden = set() if not trocken: antwort = ruf(basis, token, 'GET', '/api/bugtracker/v1/roadmap.php', query={'action': 'epics', 'project_slug': SLUG}) vorhanden = {e['epic'] for e in antwort.get('epics', [])} hoechste = 0 for i, kuerzel in enumerate(benutzt): if kuerzel in vorhanden: print(' Bereich [%s] besteht bereits - unveraendert' % kuerzel) continue titel = BEREICHE.get(kuerzel, 'Bereich %s' % kuerzel) if trocken: print(' wuerde Bereich anlegen: [%s] %s' % (kuerzel, titel)) continue hoechste += 1 ruf(basis, token, 'POST', '/api/bugtracker/v1/roadmap.php', query={'action': 'epic'}, body={'project_slug': SLUG, 'epic': kuerzel, 'title': titel, 'sort_order': (len(vorhanden) + hoechste) * 10}) print(' Bereich [%s] %s - neu angelegt' % (kuerzel, titel)) def uebertragen(basis, token, eintraege, trocken=False): angelegt = aktualisiert = 0 rang = {} bereiche_anlegen(basis, token, eintraege, trocken) print() # Prioritaet aus dem Abschnitt "Als Naechstes" der Datei. # # Der Rang dient zweierlei: innerhalb eines Bereichs ordnet er, ueber alle # Bereiche hinweg priorisiert er (nextUp sortiert global danach). Wer # ausdruecklich benannt ist, bekommt deshalb einen einstelligen Rang und # steht damit vor allem, was nur in Dateireihenfolge eingereiht wurde. prioritaet = lies_reihenfolge(ROADMAP.read_text(encoding='utf-8')) if prioritaet: print('Prioritaet aus "Als Naechstes": %s' % ', '.join(prioritaet)) print() for e in eintraege: rang[e['epic']] = rang.get(e['epic'], 0) + 10 client_ref = 'roadmap-%s-%s' % (SLUG, e['kennung'].lower()) beschreibung = ( 'Uebernommen aus %s, vormals Kennung %s.\n\n' 'Die Begruendung steht weiterhin in der Datei - hier steht nur der Zustand.' % (ANKER_DATEI, e['kennung']) ) if e.get('begruendung'): beschreibung = 'Verworfen: %s\n\n%s' % (e['begruendung'], beschreibung) if e['betrieb']: beschreibung = 'Betriebsaufgabe - kein Code.\n\n' + beschreibung if trocken: print(' wuerde anlegen: [%s] %-6s %s' % (e['epic'], e['kennung'], e['titel'][:60])) continue # 1. Item anlegen bzw. wiederfinden. client_ref macht das idempotent. antwort = ruf(basis, token, 'POST', '/api/bugtracker/v1/report', body={ 'project_slug': SLUG, 'type': 'feature_request', 'title': e['titel'][:250], 'description': beschreibung, 'severity': 'medium', 'environment': 'development', 'client_ref': client_ref, }) item_id = antwort.get('item_id') or antwort.get('id') if not item_id: raise Fehler('Keine Item-ID in der Antwort: %s' % json.dumps(antwort)[:200]) neu = bool(antwort.get('is_new', True)) angelegt += 1 if neu else 0 aktualisiert += 0 if neu else 1 # 2. Einplanen. Status und Ausloeser muessen zusammen ankommen - # getrennt gesetzt lehnt der Server das Zurueckstellen ab. # Ausdruecklich priorisierte Eintraege nach vorn - aber nur, solange # sie ueberhaupt noch offen sind. Ein erledigtes Vorhaben auf Rang 1 # stuende dauerhaft an der Spitze einer Liste, die "was ist als # Naechstes dran" beantworten soll. if e['kennung'] in prioritaet and e['status'] in ('open', 'planned', 'in_progress'): eigener_rang = prioritaet.index(e['kennung']) + 1 else: eigener_rang = rang[e['epic']] felder = { 'project_slug': SLUG, 'epic': e['epic'], 'roadmap_rank': eigener_rang, 'doc_anchor': anker(e['kennung'], e['titel']), 'status': e['status'], } if e['status'] == 'deferred': if not e['ausloeser']: raise Fehler( '%s ist zurueckgestellt, aber in der Datei steht kein ' '"**Ausloeser:**" - nach der eigenen Regel ist das kein ' 'zurueckgestelltes, sondern ein vergessenes Vorhaben.' % e['kennung']) felder['defer_trigger'] = e['ausloeser'] if e['fassung']: felder['target_build'] = e['fassung'] ruf(basis, token, 'POST', '/api/bugtracker/v1/roadmap.php', query={'action': 'set', 'id': item_id}, body=felder) # 3. Erledigte sauber abschliessen, damit resolved_in_build stimmt. if e['status'] == 'resolved' and e['fassung']: ruf(basis, token, 'POST', '/api/bugtracker/v1/manage', query={'action': 'resolve', 'id': item_id}, body={'resolved_in_build': e['fassung'], 'resolution_notes': 'Bei der Roadmap-Uebernahme als erledigt uebertragen.'}) print(' %s #%-5s [%s] %-10s %s' % ( 'neu ' if neu else 'akt.', item_id, e['epic'], e['status'], e['titel'][:52])) return angelegt, aktualisiert # --------------------------------------------------------------------------- def main(): p = argparse.ArgumentParser(description='ROADMAP.md ins Deploymentcenter uebernehmen') p.add_argument('--lesen', action='store_true', help='nur auswerten, nichts senden') p.add_argument('--probe', action='store_true', help='gegen die Testinstanz') p.add_argument('--uebernehmen', action='store_true', help='gegen die Produktion') p.add_argument('--trocken', action='store_true', help='zeigen, was gesendet wuerde') args = p.parse_args() eintraege = auswerten() zaehler = {} for e in eintraege: zaehler[e['status']] = zaehler.get(e['status'], 0) + 1 print('%d Eintraege aus %s' % (len(eintraege), ANKER_DATEI)) print(' ' + ', '.join('%s: %d' % kv for kv in sorted(zaehler.items()))) print() if args.lesen: bereich = None for e in eintraege: if e['epic'] != bereich: bereich = e['epic'] print('[%s] %s' % (bereich, BEREICHE.get(bereich, '?'))) zusatz = '' if e['fassung']: zusatz += ' -> %s' % e['fassung'] if e['ausloeser']: zusatz += ' Ausloeser: %s' % e['ausloeser'][:50] print(' %-6s %-10s %-52s%s' % (e['kennung'], e['status'], e['titel'][:52], zusatz)) return 0 if args.probe: sys.path.insert(0, str(PROJEKT_DIR / 'tests' / 'e2e')) import harness cfg = harness.lade_config(None) client = harness.Client(cfg['base_url'], cfg['basic_auth']['user'], cfg['basic_auth']['pass']) r = client.post('/testctl.php', body={'action': 'workbench_token', 'project_slug': SLUG, 'name': 'roadmap-import'}, token=cfg['keys']['shared_key']) if not r.ok: print('Werkbank-Token nicht erhalten: %s' % r.kurz(300)) return 2 basis, token = cfg['base_url'], r.feld('raw_token') print('Testinstanz %s, Token frisch ausgestellt.' % basis) elif args.uebernehmen: token = os.environ.get('DC_TOKEN', '') if not token: print('DC_TOKEN fehlt. Erwartet wird ein Werkbank-Token des Projekts') print('"%s" (owner_type = dev_agent, Scope bugtracker:manage).' % SLUG) return 2 basis = os.environ.get('DC_BASE_URL', 'https://dc.mhdf.de') print('Produktion %s' % basis) else: p.print_help() return 2 try: neu, alt = uebertragen(basis, token, eintraege, trocken=args.trocken) except Fehler as e: print('\nAbbruch: %s' % e) return 1 if not args.trocken: print('\n%d angelegt, %d aktualisiert.' % (neu, alt)) print('Naechster Schritt: php cli/roadmap.php --write --project=%s' % SLUG) return 0 if __name__ == '__main__': sys.exit(main())