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 = ''; /** * 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 = '/(?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> */ 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> */ 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 */ 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> */ 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> */ 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>> 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 // ================================================================== /** * Ordnet die Eintraege eines Bereichs untereinander neu. * * WICHTIG - das war einmal anders, siehe #177: roadmap_rank gilt * PROJEKTWEIT, nicht innerhalb eines Bereichs. nextUp() sortiert danach * ohne jede Gruppierung nach epic, und die Tafel schneidet ihre Phasen * daraus. Die frueher hier vergebene Folge 10/20/30 je Bereich hatte * deshalb eine Wirkung, die niemand angefordert hatte: Eine Geste * innerhalb eines Bereichs schob dessen Eintraege gegenueber allen * anderen Bereichen durch die halbe Warteschlange. * * Jetzt werden ausschliesslich die Raenge vertauscht, die diese * Eintraege ohnehin schon halten. Ihre Plaetze in der projektweiten * Reihenfolge bleiben als Menge unveraendert - nur wer auf welchem * sitzt, aendert sich. Genau das ist die Geste, die gemeint war. * * @param list $orderedIds Item-IDs in der gewuenschten Reihenfolge */ public function reorder(string $projectSlug, string $epic, array $orderedIds): int { $orderedIds = array_values(array_map('intval', $orderedIds)); if ($orderedIds === []) { return 0; } $placeholders = implode(',', array_fill(0, count($orderedIds), '?')); $stmt = $this->db->prepare(' SELECT id, roadmap_rank FROM bugtracker_items WHERE project_slug = ? AND epic = ? AND id IN (' . $placeholders . ') '); $stmt->execute(array_merge([$projectSlug, $epic], $orderedIds)); $known = []; $ranks = []; foreach ($stmt->fetchAll() ?: [] as $row) { $known[(int)$row['id']] = true; if ($row['roadmap_rank'] !== null) { $ranks[] = (int)$row['roadmap_rank']; } } // Nur die Eintraege, die es wirklich gibt - und in der gewuenschten // Reihenfolge, nicht in der der Datenbank. $targets = array_values(array_filter($orderedIds, static fn ($id) => isset($known[$id]))); if ($targets === []) { return 0; } sort($ranks); // Ungereihte Eintraege im Bereich brauchen zusaetzliche Plaetze. // Sie kommen hinter den hoechsten vorhandenen Rang, in Zehnerschritten - // dort standen sie ohnehin schon, naemlich am Ende. $next = ($ranks === [] ? 0 : $ranks[count($ranks) - 1]); while (count($ranks) < count($targets)) { $next += 10; $ranks[] = $next; } $update = $this->db->prepare(' UPDATE bugtracker_items SET roadmap_rank = :rank WHERE id = :id AND project_slug = :slug '); $touched = 0; foreach ($targets as $i => $id) { $update->execute([':rank' => $ranks[$i], ':id' => $id, ':slug' => $projectSlug]); $touched += $update->rowCount(); } return $touched; } /** * Setzt einen Eintrag zwischen zwei Nachbarn - das Ziehen auf der Tafel. * * Der Rang ist die Mitte zwischen den Nachbarraengen. Ist dort kein Platz * mehr, wird das Projekt EINMAL neu durchnummeriert (Zehnerschritte) und * danach erneut die Mitte genommen. Dass das passiert ist, steht im * Rueckgabewert - eine stille Neuvergabe aller Raenge waere das, wovor * #177 warnt. * * @param int|null $prevId Nachbar davor (kleinerer Rang), null = ganz nach vorn * @param int|null $nextId Nachbar dahinter (groesserer Rang), null = ganz nach hinten * @param int|null $minRank Untere Grenze der Zielspalte, falls sie leer ist * @param int|null $maxRank Obere Grenze der Zielspalte, falls sie leer ist * @return array{rank:int,renumbered:bool} */ public function rankBetween( int $itemId, ?int $prevId, ?int $nextId, ?int $minRank = null, ?int $maxRank = null ): array { $stmt = $this->db->prepare('SELECT id, project_slug FROM bugtracker_items WHERE id = :id LIMIT 1'); $stmt->execute([':id' => $itemId]); $item = $stmt->fetch(); if (!is_array($item)) { throw new \RuntimeException(sprintf('Item #%d existiert nicht.', $itemId)); } $projectSlug = (string)$item['project_slug']; $bounds = $this->bounds($projectSlug, $itemId, $prevId, $nextId, $minRank, $maxRank); $rank = self::midpoint($bounds['lower'], $bounds['upper']); if ($rank !== null) { $this->setRank($itemId, $rank); return ['rank' => $rank, 'renumbered' => false]; } // Kein Platz zwischen den Nachbarn. Einmal Luft schaffen. $this->renumber($projectSlug); $bounds = $this->bounds($projectSlug, $itemId, $prevId, $nextId, $minRank, $maxRank); $rank = self::midpoint($bounds['lower'], $bounds['upper']); if ($rank === null) { throw new \RuntimeException('Zwischen den Nachbarn ist auch nach dem Neunummerieren kein Platz.'); } $this->setRank($itemId, $rank); return ['rank' => $rank, 'renumbered' => true]; } /** * Die Raenge der beiden Nachbarn. * * @return array{lower:?int,upper:?int} */ private function bounds( string $projectSlug, int $itemId, ?int $prevId, ?int $nextId, ?int $minRank, ?int $maxRank ): array { $rankOf = function (?int $id) use ($projectSlug, $itemId): ?int { if ($id === null || $id === $itemId) { return null; } $stmt = $this->db->prepare(' SELECT roadmap_rank FROM bugtracker_items WHERE id = :id AND project_slug = :slug LIMIT 1 '); $stmt->execute([':id' => $id, ':slug' => $projectSlug]); $rank = $stmt->fetchColumn(); return ($rank === false || $rank === null) ? null : (int)$rank; }; $lower = $rankOf($prevId); $upper = $rankOf($nextId); // Leere Zielspalte: Dann geben die Phasengrenzen den Rahmen vor. if ($lower === null && $minRank !== null) { $lower = $minRank - 1; } if ($upper === null && $maxRank !== null) { $upper = $maxRank + 1; } return ['lower' => $lower, 'upper' => $upper]; } /** * Die Mitte zwischen zwei Raengen, oder null wenn dort kein Platz ist. */ private static function midpoint(?int $lower, ?int $upper): ?int { if ($lower === null && $upper === null) { return 10; } if ($lower === null) { // Ganz nach vorn. Unter 1 geht nichts mehr. return $upper > 1 ? (int)max(1, intdiv($upper, 2)) : null; } if ($upper === null) { return $lower + 10; } if ($upper - $lower < 2) { return null; } return intdiv($lower + $upper, 2); } /** * Vergibt alle Raenge eines Projekts neu, in Zehnerschritten. * * Die bestehende Reihenfolge bleibt dabei erhalten - es wird nur Luft * zwischen die Eintraege gelassen. Laeuft ausschliesslich dann, wenn * zwischen zwei Nachbarn kein Platz mehr ist. */ public function renumber(string $projectSlug): int { $stmt = $this->db->prepare(' SELECT id FROM bugtracker_items WHERE project_slug = :slug AND roadmap_rank IS NOT NULL ORDER BY roadmap_rank ASC, id ASC '); $stmt->execute([':slug' => $projectSlug]); $update = $this->db->prepare('UPDATE bugtracker_items SET roadmap_rank = :rank WHERE id = :id'); $rank = 0; $touched = 0; foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) ?: [] as $id) { $rank += 10; $update->execute([':rank' => $rank, ':id' => (int)$id]); $touched++; } return $touched; } /** * Schiebt einen Eintrag um eine Position in der Reihenfolge des Projekts. * * Tauscht die Raenge mit dem Nachbarn, statt sie durchzuzaehlen: Das ist * ein UPDATE auf zwei Zeilen und bleibt auch bei hundert Eintraegen * billig. * * Der Nachbar wird PROJEKTWEIT gesucht und nicht mehr nur im eigenen * Bereich (#177). Zuvor tauschte ein Klick die Raenge zweier Eintraege * desselben Bereichs, die in der projektweiten Reihenfolge weit * auseinanderlagen - der Eintrag sprang dann in der Warteschlange ueber * beliebig viele fremde Vorhaben hinweg, obwohl die Geste "eine Position" * hiess. */ 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 '); $max->execute([':slug' => $item['project_slug']]); $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 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'], ':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 // ================================================================== /** * Legt einen Themenbereich an oder aendert ihn. * * $color und $highlight steuern die Spur auf der Tafel. Das Kennzeichen * heisst absichtlich "hervorheben" und nicht "Sicherheit": Welcher * Bereich ins Auge fallen soll, ist eine Entscheidung des Projekts. * Bei uns ist es die Sicherheit, anderswo heisst er anders - ein Name im * Code waere fuer genau ein Projekt richtig. * * $color und $highlight auf null zu lassen aendert sie nicht. Sonst * wuerde jeder Aufruf aus dem bestehenden Formular, das diese Felder * nicht kennt, die Farbe stillschweigend loeschen. */ public function saveEpic( string $projectSlug, string $epic, string $title, ?string $summary, int $sortOrder, ?string $color = null, ?bool $highlight = null ): void { $columns = 'project_slug, epic, title, summary, sort_order'; $values = ':slug, :epic, :title, :summary, :sort'; $updates = 'title = VALUES(title), summary = VALUES(summary), sort_order = VALUES(sort_order)'; $params = [ ':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, ]; if ($color !== null) { $columns .= ', color'; $values .= ', :color'; $updates .= ', color = VALUES(color)'; $params[':color'] = trim($color) !== '' ? mb_substr(trim($color), 0, 16) : null; } if ($highlight !== null) { $columns .= ', highlight'; $values .= ', :highlight'; $updates .= ', highlight = VALUES(highlight)'; $params[':highlight'] = $highlight ? 1 : 0; } $stmt = $this->db->prepare(' INSERT INTO roadmap_epics (' . $columns . ') VALUES (' . $values . ') ON DUPLICATE KEY UPDATE ' . $updates . ' '); $stmt->execute($params); } /** * 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[] = ''; $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, $end] = self::findeBlock($content); if ($start === null || $end === null) { $separator = rtrim($content) === '' ? '' : "\n\n"; return rtrim($content) . $separator . $block . "\n"; } return substr($content, 0, $start) . $block . substr($content, $end); } /** * Findet den erzeugten Block - und zwar den echten. * * Die erste Fassung nahm strpos() auf BLOCK_START, also die erste * Fundstelle im Text. Das ging so lange gut, bis eine Datei den Marker * ERWAEHNTE: Die Einleitung dieser Roadmap erklaert, was zwischen den * ``-Markern steht - und genau dort setzte der Splice * an. Ergebnis: Der halbe Kopf der Datei wurde vom Block ueberschrieben. * * Bitter daran ist, dass es ausgerechnet die Datei trifft, die das * Verfahren beschreibt. Jede gute Roadmap-Datei erwaehnt den Marker. * * Der echte Block traegt Angaben hinter dem Marker (stand=..., items=...) * und steht am Zeilenanfang. Eine Erwaehnung im Fliesstext tut beides * nicht. Danach wird gesucht - und zur Sicherheit auch beim Ende: das * naechste BLOCK_END NACH dem Anfang, nicht das erste im Text. * * @return array{0:?int,1:?int} Anfang und Ende (hinter dem Endmarker) */ private static function findeBlock(string $content): array { // Zeilenanfang, Marker, dann Leerraum und "stand=" - so schreibt ihn // renderBlock() und nur so. if (!preg_match('/^' . preg_quote(self::BLOCK_START, '/') . '\s+stand=/m', $content, $treffer, PREG_OFFSET_CAPTURE)) { return [null, null]; } $start = (int)$treffer[0][1]; $end = strpos($content, self::BLOCK_END, $start); if ($end === false) { return [null, null]; } return [$start, $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>,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. // Ueber findeBlock(), nicht ueber strpos: Eine Erwaehnung des Markers // im Fliesstext haette sonst den Anfang bestimmt, und ausgeklammert // waere ein Stueck Prosa gewesen statt des Blocks - also genau die // Absaetze, die geprueft werden sollen. $prose = $content; [$start, $end] = self::findeBlock($content); if ($start !== null && $end !== null) { $prose = substr($content, 0, $start) . substr($content, $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> */ 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 "#" 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; } }