feat(2.7.0): Roadmap im DC, Testinstanz mit Testtor, Supervisor und MCP
Sammelcommit fuer den ueber mehrere Sitzungen gewachsenen, bisher nicht
eingecheckten Stand. Getrennte Commits waeren ehrlicher gewesen, sind es
nachtraeglich aber nicht mehr: BugRepo, changelog.json und die Doku tragen
Aenderungen aus allen Straengen gleichzeitig, und keine Zwischenstufe waere
lauffaehig.
ROADMAP IM DEPLOYMENTCENTER (2.7.0)
Planungsstand, Reihenfolge und Zustaendigkeit lagen in handgepflegten
Markdown-Dateien neben einem Bugtracker, der genau diese drei Dinge bereits
fuehrt. Zwei Pfleger fuer dasselbe Feld - Drift war eine Frage der Zeit.
Ab jetzt: Zustand im DC, Begruendung in der Datei. Kein Feld hat zwei Pfleger.
- Migration 016: epic, roadmap_rank, target_build, defer_trigger,
doc_anchor, Status "deferred", Tabelle roadmap_epics.
KEINE Roadmap-Tabelle - ein Eintrag ist ein bugtracker_item.
- Zugehoerigkeit ist ein Feld: Ein Item steht auf der Roadmap, wenn es ein
epic hat. Ein zweites Kennzeichen daneben koennte auseinanderlaufen.
- Kennung ist die Item-ID. Handvergebene Kuerzel ("A12") erzeugen die
schlimmste Fehlerart: Beide Seiten stimmig, Verschiedenes gemeint, weil
die Kennung nach einer Loeschung neu vergeben wurde.
- Zurueckstellen verlangt defer_trigger. Ohne benannten Anlass ist ein
Vorhaben nicht zurueckgestellt, sondern vergessen. Die Regel sitzt im
Repository, damit sie auf allen drei Schreibwegen gleich greift.
- Modules/Bugtracker/Roadmap.php, /api/bugtracker/v1/roadmap.php,
WebUI-Modul mit Filtern, cli/roadmap.php --write/--check,
dc_roadmap und dc_roadmap_set im MCP-Adapter.
- cli/roadmap.php --write ERZEUGT den Zustandsteil der Markdown-Datei;
--check prueft nur die Prosa. Was erzeugt wird, kann nicht driften.
- docs/ROADMAP_MIGRATION.md: Uebernahme fuer die anderen Projekte.
TESTINSTANZ UND TESTTOR
- dc-dev.mhdf.de: zweite Installation, eigene DB, eigene Schluessel,
Basic-Auth davor, Rocket.Chat aus.
- 124 Testfaelle in tests/e2e gegen API, Rechte, Migrationen, Sperre.
- scripts/deploy.py bringt vor jedem Upload erst den anstehenden Stand auf
die Testinstanz und laesst dann den Testlauf laufen. Erst bei
Rueckgabewert 0 geht etwas auf die Produktion (--ohne-test als Notausgang).
Andersherum prueft der Lauf aelteren Code als den, der gleich hochgeht.
- tests/ ist aus dem Produktiv-Deploy ausgeschlossen; testctl.php kann
nicht versehentlich auf dc.mhdf.de landen.
- Ein Lauf mit mehr uebersprungenen als geprueften Faellen endet mit
Rueckgabewert 2 statt 0. Nichts ist kaputt, geprueft wurde es aber auch
nicht - der Unterschied darf nicht am Rueckgabewert verschwinden.
SUPERVISOR, PROJEKTLANDSCHAFT, NACHPRUEFUNG (2.6.0/2.6.1)
- Migration 013: projektuebergreifend melden (dev_agent, evidence,
triage_external, affects).
- Migration 014: supervisor_findings, Katalog, fuenf serverseitige
Detektoren im Tick.
- Migration 015: Nachpruefung als Arbeitsschlange statt Benachrichtigung.
- DEV-Lizenzschluessel zur Selbstbedienung, dc-testkit fuer die Test-VM.
- MCP-Adapter: 15 Werkzeuge, ohne Fremdabhaengigkeiten, mit Abgrenzung
von Fremddaten gegen Prompt-Injektion.
BEHOBEN
- observe und status im Supervisor legten den Zustand des Befundes als
"status" in die Antwort. Http::ok() merged ['status' => 'success']
darunter, also gewann der Befundzustand - eine angenommene Meldung kam
als status="open" an. Heisst jetzt finding_status. War Item #59.
Migrationen 013-016 sind auf dem Produktivserver noch NICHT ausgefuehrt.
Ohne sie bricht das WebUI, weil die Roadmap-Ansicht bei jedem Seitenaufruf
gegen bugtracker_items.epic abfragt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9d0261306c
commit
1d5ac92051
@@ -0,0 +1,863 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Modules\Bugtracker;
|
||||
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Die Roadmap - eine Sicht auf bugtracker_items, keine zweite Tabelle.
|
||||
*
|
||||
* DIE ARBEITSTEILUNG
|
||||
*
|
||||
* Kein Feld hat zwei Pfleger. Daraus folgt alles Weitere:
|
||||
*
|
||||
* Zustand - Status, Reihenfolge, Zielfassung, Bearbeiter
|
||||
* -> Wahrheit ist die Datenbank. Die Datei zeigt eine
|
||||
* maschinell erzeugte Kopie.
|
||||
* Begruendung - warum, verworfen, Ausloeser, Herleitung
|
||||
* -> Wahrheit ist die Datei. Das Item haelt nur den Anker.
|
||||
*
|
||||
* Wird ein Feld auf beiden Seiten von Hand gepflegt, ist Drift eingebaut.
|
||||
* Deshalb gibt es das hier nicht.
|
||||
*
|
||||
* DIE KENNUNG
|
||||
*
|
||||
* Handvergebene Kennungen ("A12") sind der Grund fuer die schlimmste Fehlerart:
|
||||
* Beide Seiten sind fuer sich stimmig und meinen Verschiedenes, weil die
|
||||
* Kennung nach einer Loeschung neu vergeben wurde. Deshalb ist die Kennung
|
||||
* `bugtracker_items.id` - AUTO_INCREMENT vergibt nie zweimal dieselbe Nummer.
|
||||
* Der Buchstabe bleibt, aber nur als Themenbereich:
|
||||
*
|
||||
* ### [A] #142 - Symbol-Upload und dc_symbolicate
|
||||
*/
|
||||
final class Roadmap
|
||||
{
|
||||
/**
|
||||
* Status -> Zeichen in der erzeugten Datei.
|
||||
*
|
||||
* Dieselben Zeichen, die die abgeloeste handgepflegte ROADMAP.md benutzt
|
||||
* hat. Absicht: Wer die Datei nach der Umstellung aufschlaegt, liest
|
||||
* dasselbe wie vorher - nur stimmt es jetzt.
|
||||
*/
|
||||
public const MARKERS = [
|
||||
'open' => '📋',
|
||||
'planned' => '📋',
|
||||
'in_progress' => '🔨',
|
||||
'awaiting_verification' => '🔍',
|
||||
'in_verification' => '🔍',
|
||||
'fix_insufficient' => '⚠️',
|
||||
'resolved' => '✅',
|
||||
'closed' => '✅',
|
||||
'deferred' => '🧊',
|
||||
'rejected' => '❌',
|
||||
'ignored' => '🔇',
|
||||
'triage_external' => '📨',
|
||||
];
|
||||
|
||||
/** Klartext je Status, fuer Oberflaeche und Legende. */
|
||||
public const STATUS_LABELS = [
|
||||
'open' => 'Offen',
|
||||
'planned' => 'Geplant',
|
||||
'in_progress' => 'In Arbeit',
|
||||
'awaiting_verification' => 'Wartet auf Nachpruefung',
|
||||
'in_verification' => 'In Nachpruefung',
|
||||
'fix_insufficient' => 'Fix trug nicht',
|
||||
'resolved' => 'Erledigt',
|
||||
'closed' => 'Abgeschlossen',
|
||||
'deferred' => 'Zurueckgestellt',
|
||||
'rejected' => 'Verworfen',
|
||||
'ignored' => 'Stummgeschaltet',
|
||||
'triage_external' => 'Fremdmeldung, ungesichtet',
|
||||
];
|
||||
|
||||
/**
|
||||
* Grobe Zusammenfassung der Status fuer den Filter.
|
||||
*
|
||||
* Elf Status sind fuer die Ablage richtig und fuer eine Auswahlliste zu
|
||||
* viele. Wer "was ist offen?" fragt, meint nicht einen ENUM-Wert, sondern
|
||||
* eine Lage.
|
||||
*/
|
||||
public const STATUS_GROUPS = [
|
||||
// "aktiv" ist die uebliche Frage und deshalb die Voreinstellung: alles,
|
||||
// was noch zu tun ist. Eine Roadmap, die mit sechzig erledigten
|
||||
// Eintraegen beginnt, beantwortet sie nicht.
|
||||
'aktiv' => ['open', 'planned', 'in_progress',
|
||||
'awaiting_verification', 'in_verification', 'fix_insufficient'],
|
||||
'offen' => ['open', 'planned'],
|
||||
'arbeit' => ['in_progress', 'awaiting_verification', 'in_verification', 'fix_insufficient'],
|
||||
'erledigt' => ['resolved', 'closed'],
|
||||
'zurueckgestellt' => ['deferred'],
|
||||
'verworfen' => ['rejected'],
|
||||
];
|
||||
|
||||
/** Status, die als noch zu tun gelten - die eigentliche Roadmap. */
|
||||
public const PENDING_STATUSES = [
|
||||
'open', 'planned', 'in_progress',
|
||||
'awaiting_verification', 'in_verification', 'fix_insufficient',
|
||||
];
|
||||
|
||||
/**
|
||||
* Marker der erzeugten Bloecke in der Markdown-Datei.
|
||||
*
|
||||
* Alles zwischen START und ENDE wird bei jedem Lauf ueberschrieben, alles
|
||||
* ausserhalb nie angefasst. Damit kann der Zustandsteil per Konstruktion
|
||||
* nicht driften - er wird nicht gepflegt, sondern erzeugt.
|
||||
*/
|
||||
public const BLOCK_START = '<!-- dc:roadmap';
|
||||
public const BLOCK_END = '<!-- /dc:roadmap -->';
|
||||
|
||||
/**
|
||||
* Erkennt einen Verweis auf ein Item im Fliesstext: "#142".
|
||||
*
|
||||
* Die Wortgrenze davor verhindert Treffer in Ankern und Farbwerten
|
||||
* (`#a4c8ff`), die Ziffernpruefung dahinter Treffer in Ueberschriften-IDs.
|
||||
*/
|
||||
private const ANCHOR_PATTERN = '/(?<![\w#])#(\d{1,9})\b/';
|
||||
|
||||
private PDO $db;
|
||||
|
||||
public function __construct(PDO $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Lesen
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Themenbereiche eines Projekts samt Zaehlung je Lage.
|
||||
*
|
||||
* Die Zaehlung kommt aus demselben Durchlauf wie die Bereiche: Ein Bereich
|
||||
* ohne Items ist eine Ueberschrift ohne Inhalt und soll als solche
|
||||
* sichtbar sein, statt in der Liste zu fehlen.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function epics(?string $projectSlug = null): array
|
||||
{
|
||||
$where = '';
|
||||
$params = [];
|
||||
|
||||
if ($projectSlug !== null && $projectSlug !== '' && $projectSlug !== 'all') {
|
||||
$where = ' WHERE e.project_slug = :slug';
|
||||
$params[':slug'] = $projectSlug;
|
||||
}
|
||||
|
||||
$sql = '
|
||||
SELECT e.project_slug, e.epic, e.title, e.summary, e.sort_order, e.is_closed,
|
||||
COALESCE(c.total, 0) AS total,
|
||||
COALESCE(c.pending, 0) AS pending,
|
||||
COALESCE(c.working, 0) AS working,
|
||||
COALESCE(c.done, 0) AS done,
|
||||
COALESCE(c.deferred, 0) AS deferred,
|
||||
COALESCE(c.rejected, 0) AS rejected
|
||||
FROM roadmap_epics e
|
||||
LEFT JOIN (
|
||||
SELECT project_slug, epic,
|
||||
COUNT(*) AS total,
|
||||
SUM(status IN ("open","planned")) AS pending,
|
||||
SUM(status IN ("in_progress","awaiting_verification",
|
||||
"in_verification","fix_insufficient")) AS working,
|
||||
SUM(status IN ("resolved","closed")) AS done,
|
||||
SUM(status = "deferred") AS deferred,
|
||||
SUM(status = "rejected") AS rejected
|
||||
FROM bugtracker_items
|
||||
WHERE epic IS NOT NULL AND epic <> ""
|
||||
GROUP BY project_slug, epic
|
||||
) c ON c.project_slug = e.project_slug AND c.epic = e.epic'
|
||||
. $where
|
||||
. ' ORDER BY e.project_slug ASC, e.sort_order ASC, e.epic ASC';
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return $stmt->fetchAll() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bereiche, die an Items haengen, aber in roadmap_epics fehlen.
|
||||
*
|
||||
* Entsteht, wenn jemand ein Item auf einen Bereich setzt, den es nicht
|
||||
* gibt - ein Tippfehler erzeugt sonst lautlos einen zwoelften Bereich, der
|
||||
* in keiner Uebersicht auftaucht.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function orphanEpics(?string $projectSlug = null): array
|
||||
{
|
||||
$where = 'i.epic IS NOT NULL AND i.epic <> ""';
|
||||
$params = [];
|
||||
|
||||
if ($projectSlug !== null && $projectSlug !== '' && $projectSlug !== 'all') {
|
||||
$where .= ' AND i.project_slug = :slug';
|
||||
$params[':slug'] = $projectSlug;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT i.project_slug, i.epic, COUNT(*) AS items
|
||||
FROM bugtracker_items i
|
||||
LEFT JOIN roadmap_epics e
|
||||
ON e.project_slug = i.project_slug AND e.epic = i.epic
|
||||
WHERE ' . $where . ' AND e.id IS NULL
|
||||
GROUP BY i.project_slug, i.epic
|
||||
ORDER BY items DESC
|
||||
');
|
||||
$stmt->execute($params);
|
||||
|
||||
return $stmt->fetchAll() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Kennzahlen fuer die Kopfzeile der Ansicht.
|
||||
*
|
||||
* @return array<string,int>
|
||||
*/
|
||||
public function stats(?string $projectSlug = null): array
|
||||
{
|
||||
$where = 'epic IS NOT NULL AND epic <> ""';
|
||||
$params = [];
|
||||
|
||||
if ($projectSlug !== null && $projectSlug !== '' && $projectSlug !== 'all') {
|
||||
$where .= ' AND project_slug = :slug';
|
||||
$params[':slug'] = $projectSlug;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(status IN ("open","planned")) AS pending,
|
||||
SUM(status IN ("in_progress","awaiting_verification",
|
||||
"in_verification","fix_insufficient")) AS working,
|
||||
SUM(status IN ("resolved","closed")) AS done,
|
||||
SUM(status = "deferred") AS deferred,
|
||||
SUM(status = "rejected") AS rejected,
|
||||
SUM(claimed_by IS NOT NULL AND lease_until > UTC_TIMESTAMP()) AS claimed,
|
||||
SUM(doc_anchor IS NULL OR doc_anchor = "") AS without_doc
|
||||
FROM bugtracker_items
|
||||
WHERE ' . $where
|
||||
);
|
||||
$stmt->execute($params);
|
||||
|
||||
$row = $stmt->fetch() ?: [];
|
||||
|
||||
return array_map(static fn($v): int => (int)$v, [
|
||||
'total' => $row['total'] ?? 0,
|
||||
'pending' => $row['pending'] ?? 0,
|
||||
'working' => $row['working'] ?? 0,
|
||||
'done' => $row['done'] ?? 0,
|
||||
'deferred' => $row['deferred'] ?? 0,
|
||||
'rejected' => $row['rejected'] ?? 0,
|
||||
'claimed' => $row['claimed'] ?? 0,
|
||||
'without_doc' => $row['without_doc'] ?? 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Als Naechstes" - die vorderen Eintraege der Warteschlange.
|
||||
*
|
||||
* Genau die Liste, die in der abgeloesten Datei von Hand oben stand und
|
||||
* regelmaessig veraltete. Sie ergibt sich jetzt aus Reihenfolge und
|
||||
* Status, ohne dass jemand sie fortschreibt.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function nextUp(?string $projectSlug = null, int $limit = 5): array
|
||||
{
|
||||
$where = 'epic IS NOT NULL AND epic <> "" AND status IN ("open","planned","in_progress")';
|
||||
$params = [];
|
||||
|
||||
if ($projectSlug !== null && $projectSlug !== '' && $projectSlug !== 'all') {
|
||||
$where .= ' AND project_slug = :slug';
|
||||
$params[':slug'] = $projectSlug;
|
||||
}
|
||||
|
||||
$limit = max(1, min($limit, 25));
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT id, project_slug, epic, roadmap_rank, title, status, severity,
|
||||
target_build, claimed_by, lease_until, doc_anchor
|
||||
FROM bugtracker_items
|
||||
WHERE ' . $where . '
|
||||
ORDER BY roadmap_rank IS NULL ASC, roadmap_rank ASC,
|
||||
FIELD(severity, "critical","high","medium","low","wishlist","idea") ASC,
|
||||
id ASC
|
||||
LIMIT ' . $limit
|
||||
);
|
||||
$stmt->execute($params);
|
||||
|
||||
return $stmt->fetchAll() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wer arbeitet gerade woran - ueber alle Projekte.
|
||||
*
|
||||
* Die Frage, die den Anstoss zu dieser Umstellung gab. Sie brauchte keine
|
||||
* neue Tabelle: claimed_by und lease_until gibt es seit dem
|
||||
* Agenten-Workflow, nur gefragt hat sie niemand.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function activeWork(?string $projectSlug = null): array
|
||||
{
|
||||
$where = 'claimed_by IS NOT NULL AND lease_until > UTC_TIMESTAMP()';
|
||||
$params = [];
|
||||
|
||||
if ($projectSlug !== null && $projectSlug !== '' && $projectSlug !== 'all') {
|
||||
$where .= ' AND project_slug = :slug';
|
||||
$params[':slug'] = $projectSlug;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT id, project_slug, epic, title, status, claimed_by, claimed_at,
|
||||
lease_until, target_build,
|
||||
TIMESTAMPDIFF(MINUTE, UTC_TIMESTAMP(), lease_until) AS lease_minutes_left
|
||||
FROM bugtracker_items
|
||||
WHERE ' . $where . '
|
||||
ORDER BY project_slug ASC, lease_until ASC
|
||||
');
|
||||
$stmt->execute($params);
|
||||
|
||||
return $stmt->fetchAll() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Roadmap-Eintraege eines Projekts, nach Bereich gruppiert.
|
||||
*
|
||||
* @return array<string,list<array<string,mixed>>> Bereich => Eintraege
|
||||
*/
|
||||
public function byEpic(string $projectSlug, bool $includeFinished = true): array
|
||||
{
|
||||
$where = 'project_slug = :slug AND epic IS NOT NULL AND epic <> ""';
|
||||
if (!$includeFinished) {
|
||||
$where .= ' AND status NOT IN ("resolved","closed")';
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT * FROM bugtracker_items
|
||||
WHERE ' . $where . '
|
||||
ORDER BY epic ASC, roadmap_rank IS NULL ASC, roadmap_rank ASC, id ASC
|
||||
');
|
||||
$stmt->execute([':slug' => $projectSlug]);
|
||||
|
||||
$grouped = [];
|
||||
foreach ($stmt->fetchAll() ?: [] as $row) {
|
||||
$grouped[(string)$row['epic']][] = $row;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Reihenfolge
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Vergibt die Reihenfolge eines Bereichs neu, in Zehnerschritten.
|
||||
*
|
||||
* Die Luecken sind der Zweck: Ein Eintrag laesst sich spaeter
|
||||
* dazwischenschieben (Rang 15), ohne dass alle folgenden neu geschrieben
|
||||
* werden muessen. Erst wenn die Luecke aufgebraucht ist, laeuft das hier
|
||||
* noch einmal.
|
||||
*
|
||||
* @param list<int> $orderedIds Item-IDs in der gewuenschten Reihenfolge
|
||||
*/
|
||||
public function reorder(string $projectSlug, string $epic, array $orderedIds): int
|
||||
{
|
||||
$stmt = $this->db->prepare('
|
||||
UPDATE bugtracker_items
|
||||
SET roadmap_rank = :rank
|
||||
WHERE id = :id AND project_slug = :slug AND epic = :epic
|
||||
');
|
||||
|
||||
$touched = 0;
|
||||
$rank = 10;
|
||||
|
||||
foreach ($orderedIds as $id) {
|
||||
$stmt->execute([
|
||||
':rank' => $rank,
|
||||
':id' => (int)$id,
|
||||
':slug' => $projectSlug,
|
||||
':epic' => $epic,
|
||||
]);
|
||||
$touched += $stmt->rowCount();
|
||||
$rank += 10;
|
||||
}
|
||||
|
||||
return $touched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schiebt einen Eintrag innerhalb seines Bereichs um eine Position.
|
||||
*
|
||||
* Tauscht die Raenge mit dem Nachbarn, statt sie durchzuzaehlen: Das ist
|
||||
* ein UPDATE auf zwei Zeilen und bleibt auch bei einem Bereich mit
|
||||
* hundert Eintraegen billig.
|
||||
*/
|
||||
public function move(int $itemId, string $direction): bool
|
||||
{
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT id, project_slug, epic, roadmap_rank
|
||||
FROM bugtracker_items WHERE id = :id
|
||||
');
|
||||
$stmt->execute([':id' => $itemId]);
|
||||
$item = $stmt->fetch();
|
||||
|
||||
if (!is_array($item) || ($item['epic'] ?? null) === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ein ungereihter Eintrag hat keinen Nachbarn, mit dem sich tauschen
|
||||
// liesse. Er bekommt erst einmal einen Rang - hinter allen anderen,
|
||||
// denn dort steht er schon.
|
||||
if ($item['roadmap_rank'] === null) {
|
||||
$max = $this->db->prepare('
|
||||
SELECT COALESCE(MAX(roadmap_rank), 0) FROM bugtracker_items
|
||||
WHERE project_slug = :slug AND epic = :epic
|
||||
');
|
||||
$max->execute([':slug' => $item['project_slug'], ':epic' => $item['epic']]);
|
||||
|
||||
$newRank = ((int)$max->fetchColumn()) + 10;
|
||||
$this->setRank($itemId, $newRank);
|
||||
$item['roadmap_rank'] = $newRank;
|
||||
}
|
||||
|
||||
$comparison = $direction === 'up' ? '<' : '>';
|
||||
$sort = $direction === 'up' ? 'DESC' : 'ASC';
|
||||
|
||||
$neighbour = $this->db->prepare('
|
||||
SELECT id, roadmap_rank FROM bugtracker_items
|
||||
WHERE project_slug = :slug AND epic = :epic
|
||||
AND roadmap_rank IS NOT NULL
|
||||
AND roadmap_rank ' . $comparison . ' :rank
|
||||
ORDER BY roadmap_rank ' . $sort . ', id ' . $sort . '
|
||||
LIMIT 1
|
||||
');
|
||||
$neighbour->execute([
|
||||
':slug' => $item['project_slug'],
|
||||
':epic' => $item['epic'],
|
||||
':rank' => (int)$item['roadmap_rank'],
|
||||
]);
|
||||
$other = $neighbour->fetch();
|
||||
|
||||
if (!is_array($other)) {
|
||||
return false; // schon ganz oben bzw. ganz unten
|
||||
}
|
||||
|
||||
$this->setRank((int)$item['id'], (int)$other['roadmap_rank']);
|
||||
$this->setRank((int)$other['id'], (int)$item['roadmap_rank']);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function setRank(int $itemId, int $rank): void
|
||||
{
|
||||
$stmt = $this->db->prepare('UPDATE bugtracker_items SET roadmap_rank = :rank WHERE id = :id');
|
||||
$stmt->execute([':rank' => $rank, ':id' => $itemId]);
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Bereiche verwalten
|
||||
// ==================================================================
|
||||
|
||||
public function saveEpic(string $projectSlug, string $epic, string $title, ?string $summary, int $sortOrder): void
|
||||
{
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO roadmap_epics (project_slug, epic, title, summary, sort_order)
|
||||
VALUES (:slug, :epic, :title, :summary, :sort)
|
||||
ON DUPLICATE KEY UPDATE title = VALUES(title),
|
||||
summary = VALUES(summary),
|
||||
sort_order = VALUES(sort_order)
|
||||
');
|
||||
$stmt->execute([
|
||||
':slug' => $projectSlug,
|
||||
':epic' => mb_substr(trim($epic), 0, 24),
|
||||
':title' => mb_substr(trim($title), 0, 160),
|
||||
':summary' => ($summary !== null && trim($summary) !== '') ? trim($summary) : null,
|
||||
':sort' => $sortOrder,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loescht einen Bereich - aber nur, solange kein Eintrag daran haengt.
|
||||
*
|
||||
* Sonst blieben Items mit einem Bereich zurueck, den es nicht mehr gibt:
|
||||
* genau die haengende Referenz, die diese Umstellung vermeiden soll.
|
||||
*/
|
||||
public function deleteEpic(string $projectSlug, string $epic): bool
|
||||
{
|
||||
$count = $this->db->prepare('
|
||||
SELECT COUNT(*) FROM bugtracker_items
|
||||
WHERE project_slug = :slug AND epic = :epic
|
||||
');
|
||||
$count->execute([':slug' => $projectSlug, ':epic' => $epic]);
|
||||
|
||||
if ((int)$count->fetchColumn() > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
DELETE FROM roadmap_epics WHERE project_slug = :slug AND epic = :epic
|
||||
');
|
||||
$stmt->execute([':slug' => $projectSlug, ':epic' => $epic]);
|
||||
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Markdown erzeugen
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Erzeugt den Zustandsblock fuer ein Projekt.
|
||||
*
|
||||
* Das Ergebnis wird zwischen die Marker geschrieben und bei jedem Lauf
|
||||
* vollstaendig ersetzt. Es enthaelt bewusst NUR Zustand - keine
|
||||
* Begruendung, keine Herleitung. Die stehen in der Prosa darunter und
|
||||
* werden nie angefasst.
|
||||
*/
|
||||
public function renderBlock(string $projectSlug): string
|
||||
{
|
||||
$epics = $this->epics($projectSlug);
|
||||
$grouped = $this->byEpic($projectSlug);
|
||||
$stats = $this->stats($projectSlug);
|
||||
|
||||
$lines = [];
|
||||
$lines[] = sprintf(
|
||||
'%s stand=%s items=%d projekt=%s -->',
|
||||
self::BLOCK_START,
|
||||
gmdate('Y-m-d\TH:i\Z'),
|
||||
$stats['total'],
|
||||
$projectSlug
|
||||
);
|
||||
$lines[] = '';
|
||||
$lines[] = '<!-- ERZEUGT - nicht von Hand aendern. Dieser Block wird bei jedem Lauf von';
|
||||
$lines[] = ' `php cli/roadmap.php --write` ueberschrieben. Der Zustand wird im';
|
||||
$lines[] = ' Deploymentcenter gepflegt, nicht hier. Prosa gehoert AUSSERHALB der';
|
||||
$lines[] = ' Marker - dort bleibt sie unberuehrt. -->';
|
||||
$lines[] = '';
|
||||
|
||||
$next = $this->nextUp($projectSlug, 4);
|
||||
if ($next !== []) {
|
||||
$lines[] = '### Als Naechstes';
|
||||
$lines[] = '';
|
||||
foreach ($next as $i => $item) {
|
||||
$lines[] = sprintf(
|
||||
'%d. **[%s] #%d — %s**%s',
|
||||
$i + 1,
|
||||
(string)$item['epic'],
|
||||
(int)$item['id'],
|
||||
(string)$item['title'],
|
||||
$item['claimed_by'] !== null ? ' — in Arbeit: ' . (string)$item['claimed_by'] : ''
|
||||
);
|
||||
}
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
foreach ($epics as $epic) {
|
||||
$key = (string)$epic['epic'];
|
||||
$items = $grouped[$key] ?? [];
|
||||
|
||||
$lines[] = sprintf('### %s — %s', $key, (string)$epic['title']);
|
||||
$lines[] = '';
|
||||
|
||||
if ($items === []) {
|
||||
$lines[] = '_Noch keine Eintraege._';
|
||||
$lines[] = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
$lines[] = '| | Vorhaben | Stand | Ziel |';
|
||||
$lines[] = '|---|---|---|---|';
|
||||
|
||||
foreach ($items as $item) {
|
||||
$status = (string)$item['status'];
|
||||
$marker = self::MARKERS[$status] ?? '·';
|
||||
|
||||
$stand = $marker . ' ' . (self::STATUS_LABELS[$status] ?? $status);
|
||||
|
||||
if ($item['claimed_by'] !== null && $status === 'in_progress') {
|
||||
$stand .= ' — ' . (string)$item['claimed_by'];
|
||||
}
|
||||
if ($status === 'deferred' && !empty($item['defer_trigger'])) {
|
||||
$stand .= ' — Ausloeser: ' . self::inlineText((string)$item['defer_trigger']);
|
||||
}
|
||||
|
||||
$ziel = '';
|
||||
if ($status === 'resolved' || $status === 'closed') {
|
||||
$ziel = (string)($item['resolved_in_build'] ?? '');
|
||||
} elseif (!empty($item['target_build'])) {
|
||||
$ziel = (string)$item['target_build'];
|
||||
}
|
||||
|
||||
$lines[] = sprintf(
|
||||
'| #%d | %s | %s | %s |',
|
||||
(int)$item['id'],
|
||||
self::inlineText((string)$item['title']),
|
||||
$stand,
|
||||
$ziel !== '' ? '`' . $ziel . '`' : '—'
|
||||
);
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
$lines[] = self::BLOCK_END;
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt den erzeugten Block in eine bestehende Datei ein.
|
||||
*
|
||||
* Findet sich kein Marker, wird der Block angehaengt statt die Datei zu
|
||||
* ersetzen - eine vorhandene Roadmap darf durch einen ersten Lauf nicht
|
||||
* verloren gehen.
|
||||
*/
|
||||
public static function spliceBlock(string $content, string $block): string
|
||||
{
|
||||
$start = strpos($content, self::BLOCK_START);
|
||||
$end = strpos($content, self::BLOCK_END);
|
||||
|
||||
if ($start === false || $end === false || $end < $start) {
|
||||
$separator = rtrim($content) === '' ? '' : "\n\n";
|
||||
return rtrim($content) . $separator . $block . "\n";
|
||||
}
|
||||
|
||||
return substr($content, 0, $start)
|
||||
. $block
|
||||
. substr($content, $end + strlen(self::BLOCK_END));
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Abgleich
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Vergleicht die Prosa einer Datei gegen die Items eines Projekts.
|
||||
*
|
||||
* Der Zustandsblock wird dabei ausgeklammert - er ist erzeugt und kann
|
||||
* nicht abweichen. Geprueft wird nur, was von Hand geschrieben wurde:
|
||||
* Verweist ein Absatz auf ein Item, das es nicht gibt? Gehoert das Item
|
||||
* einem anderen Projekt? Heisst es inzwischen anders?
|
||||
*
|
||||
* Die Gegenrichtung - ein Item ohne Absatz - ist KEIN Befund. Nicht jedes
|
||||
* Vorhaben braucht eine Herleitung; die meisten sind eine Zeile wert und
|
||||
* mehr nicht.
|
||||
*
|
||||
* @return array{ok:bool,findings:list<array<string,string>>,checked:int,stale:?string}
|
||||
*/
|
||||
public function check(string $projectSlug, string $file): array
|
||||
{
|
||||
$findings = [];
|
||||
|
||||
if (!is_file($file)) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'findings' => [[
|
||||
'level' => 'error',
|
||||
'code' => 'file_missing',
|
||||
'message' => sprintf('Datei "%s" nicht gefunden.', $file),
|
||||
]],
|
||||
'checked' => 0,
|
||||
'stale' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$content = (string)file_get_contents($file);
|
||||
|
||||
// Zustandsblock ausklammern - die dortigen IDs stammen aus derselben
|
||||
// Abfrage und wuerden jede Pruefung trivial bestehen lassen.
|
||||
$prose = $content;
|
||||
$start = strpos($content, self::BLOCK_START);
|
||||
$end = strpos($content, self::BLOCK_END);
|
||||
if ($start !== false && $end !== false && $end > $start) {
|
||||
$prose = substr($content, 0, $start) . substr($content, $end + strlen(self::BLOCK_END));
|
||||
}
|
||||
|
||||
$stale = self::blockAge($content);
|
||||
|
||||
preg_match_all(self::ANCHOR_PATTERN, $prose, $matches);
|
||||
$ids = array_values(array_unique(array_map('intval', $matches[1] ?? [])));
|
||||
|
||||
if ($ids === []) {
|
||||
return ['ok' => true, 'findings' => [], 'checked' => 0, 'stale' => $stale];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT id, project_slug, title, status, epic
|
||||
FROM bugtracker_items WHERE id IN (' . $placeholders . ')
|
||||
');
|
||||
$stmt->execute($ids);
|
||||
|
||||
$known = [];
|
||||
foreach ($stmt->fetchAll() ?: [] as $row) {
|
||||
$known[(int)$row['id']] = $row;
|
||||
}
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if (!isset($known[$id])) {
|
||||
$findings[] = [
|
||||
'level' => 'error',
|
||||
'code' => 'unknown_item',
|
||||
'message' => sprintf(
|
||||
'#%d wird im Text erwaehnt, existiert aber nicht. '
|
||||
. 'Item geloescht oder Nummer vertippt.',
|
||||
$id
|
||||
),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$item = $known[$id];
|
||||
|
||||
if ((string)$item['project_slug'] !== $projectSlug) {
|
||||
$findings[] = [
|
||||
'level' => 'error',
|
||||
'code' => 'foreign_item',
|
||||
'message' => sprintf(
|
||||
'#%d gehoert zu Projekt "%s", die Datei beschreibt "%s". '
|
||||
. 'Verwechslung oder ein Verweis, der als solcher gekennzeichnet gehoert.',
|
||||
$id,
|
||||
(string)$item['project_slug'],
|
||||
$projectSlug
|
||||
),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($item['epic'] === null || (string)$item['epic'] === '') {
|
||||
$findings[] = [
|
||||
'level' => 'warning',
|
||||
'code' => 'not_on_roadmap',
|
||||
'message' => sprintf(
|
||||
'#%d hat einen Absatz, steht aber nicht auf der Roadmap (kein Bereich). '
|
||||
. 'Entweder Bereich setzen oder den Absatz ins Archiv.',
|
||||
$id
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// Ueberschrift gegen Titel. Nur eine Warnung: Eine Ueberschrift
|
||||
// darf kuerzer sein als der Item-Titel. Gemeldet wird, wenn sie
|
||||
// gar nichts mehr gemeinsam haben.
|
||||
if (!self::headingMatches($prose, $id, (string)$item['title'])) {
|
||||
$findings[] = [
|
||||
'level' => 'warning',
|
||||
'code' => 'title_drift',
|
||||
'message' => sprintf(
|
||||
'#%d heisst im DC "%s" - die Ueberschrift in der Datei sagt etwas anderes.',
|
||||
$id,
|
||||
(string)$item['title']
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Zurueckgestelltes und Verworfenes gehoert in die Prosa, damit die
|
||||
// Begruendung mitgeht. Fehlt dort der Absatz, steht die Entscheidung
|
||||
// nur als Statuswert da - und der naechste Agent schlaegt es erneut vor.
|
||||
foreach ($this->undocumentedDecisions($projectSlug) as $row) {
|
||||
if (in_array((int)$row['id'], $ids, true)) {
|
||||
continue;
|
||||
}
|
||||
$findings[] = [
|
||||
'level' => 'warning',
|
||||
'code' => 'decision_undocumented',
|
||||
'message' => sprintf(
|
||||
'#%d ist "%s", aber in der Datei steht keine Begruendung. '
|
||||
. 'Genau die ist der Wert des Eintrags.',
|
||||
(int)$row['id'],
|
||||
self::STATUS_LABELS[(string)$row['status']] ?? (string)$row['status']
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
$hasError = false;
|
||||
foreach ($findings as $finding) {
|
||||
if ($finding['level'] === 'error') {
|
||||
$hasError = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => !$hasError,
|
||||
'findings' => $findings,
|
||||
'checked' => count($ids),
|
||||
'stale' => $stale,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Zurueckgestellte und verworfene Vorhaben eines Projekts.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function undocumentedDecisions(string $projectSlug): array
|
||||
{
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT id, title, status FROM bugtracker_items
|
||||
WHERE project_slug = :slug
|
||||
AND epic IS NOT NULL AND epic <> ""
|
||||
AND status IN ("deferred","rejected")
|
||||
ORDER BY id ASC
|
||||
');
|
||||
$stmt->execute([':slug' => $projectSlug]);
|
||||
|
||||
return $stmt->fetchAll() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft, ob eine Ueberschrift zu "#<id>" noch etwas mit dem Titel zu tun hat.
|
||||
*
|
||||
* Bewusst grosszuegig: Ein Wort mit mindestens vier Zeichen reicht.
|
||||
* Strenger waere unbrauchbar - "Symbol-Upload und dc_symbolicate" und
|
||||
* "Symbol-Upload" sollen als dasselbe durchgehen.
|
||||
*/
|
||||
private static function headingMatches(string $prose, int $id, string $title): bool
|
||||
{
|
||||
if (!preg_match('/^#{1,6}[^\n]*#' . $id . '\b([^\n]*)$/m', $prose, $m)) {
|
||||
return true; // kein Ueberschriften-Verweis, nur eine Erwaehnung im Text
|
||||
}
|
||||
|
||||
$heading = mb_strtolower($m[1]);
|
||||
$words = preg_split('/[^\p{L}\p{N}_]+/u', mb_strtolower($title)) ?: [];
|
||||
|
||||
foreach ($words as $word) {
|
||||
if (mb_strlen($word) >= 4 && str_contains($heading, $word)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return $words === [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den Zeitstempel aus dem Blockmarker.
|
||||
*
|
||||
* Ein veralteter Block ist kein Fehler - er ist eine Kopie, und Kopien
|
||||
* altern. Gemeldet wird er trotzdem, damit niemand eine Woche alte Liste
|
||||
* fuer den Stand von heute haelt.
|
||||
*/
|
||||
private static function blockAge(string $content): ?string
|
||||
{
|
||||
if (!preg_match('/' . preg_quote(self::BLOCK_START, '/') . '\s+stand=(\S+)/', $content, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
/** Macht einen Text tabellensicher: keine Zeilenumbrueche, keine rohen Pipes. */
|
||||
private static function inlineText(string $value): string
|
||||
{
|
||||
$value = preg_replace('/\s+/u', ' ', trim($value)) ?? $value;
|
||||
$value = str_replace('|', '\\|', $value);
|
||||
|
||||
return mb_strlen($value) > 120 ? mb_substr($value, 0, 117) . '...' : $value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user