Zwei angeforderte Funktionen, dazu zwei Befunde, die ihnen im Weg lagen. == Zweite Stufe der Anmeldung (#666) == Nach korrektem Passwort verlangt das WebUI einen registrierten FIDO2-Schluessel. Die Pflicht haengt je Konto daran, OB ein Schluessel registriert ist - nicht an einem Schalter. Ein Schalter, den man vor der Registrierung umlegt, sperrt den Betreiber aus seiner eigenen Verwaltung aus. Ohne Fremdbibliothek gebaut (das Projekt hat bewusst kein Composer): src/Core/Webauthn/ mit CBOR-Leser, COSE-nach-DER-Wandlung und Pruefung ueber openssl_verify (ES256, RS256). Auth::login() heisst jetzt Auth::startLogin() und liefert drei Zustaende statt eines bool - aus "wahr heisst drin" ist "wahr heisst vielleicht drin" geworden, und ein uebersehener Aufrufer soll an einem fehlenden Methodennamen scheitern statt an einer truthy Zeichenkette. Die Setup-API der Zielsysteme geht ueber verifyCredentials() daran vorbei und bleibt unberuehrt. Notfalltoken als Ersatzweg: nur als Hash in der Datenbank, Klartext einmalig angezeigt, Einmalgebrauch. Laeuft als gewoehnliches Formular ohne JavaScript - ein Ersatzweg mit denselben Voraussetzungen wie der Hauptweg waere keiner. Fehlversuche auf der zweiten Stufe laufen in dieselbe Konto- und IP-Sperre wie das Passwort; zuvor zaehlte nur die erste Stufe. Benutzerverwaltung kann Konten umbenennen. Beim Loeschen eines Kontos und beim Zuruecksetzen der Testinstanz werden seine Schluessel mitgenommen: credential_id ist geraeteweit eindeutig, eine verwaiste Zeile haette denselben Yubikey dauerhaft unbrauchbar gemacht. == Roadmap-Tafel (#664) == Neuer Unterreiter Roadmap -> Tafel. Der Server liefert die fertige Gliederung (RoadmapBoard, ?action=board), die Seite rendert nur - sonst schneidet das WebUI anders als die API und ein spaeterer Export ein drittes Mal anders. Phasen stehen nicht im Code: gepflegt in der neuen Tabelle roadmap_phases, sonst an den groessten Luecken der Rangfolge geschnitten und sichtbar als geraten gekennzeichnet. Links die gekappte Spalte des Erledigten, rechts "Wird ueberlegt". Nachpruefung ist ein eigener Zustand und wird nicht als erledigt gezeichnet. Klick oeffnet ein Seitenpanel: Volltext, Verlauf, Kommentieren mit action_taken, Aendern von Bereich, Rang, Zielfassung, Status und Ausloeser - ohne Seitenwechsel. Das war der eigentliche Gewinn: Wer etwas bemerkte, musste sich bisher die Nummer merken und im Bugtracker danach suchen. Tiefe Verweise #roadmap/<projekt>/item/<id>. Ziehen zwischen Phasen ueber ?action=rank_between. Fremdtext wird ausschliesslich ueber textContent gesetzt, nie ueber innerHTML. == Befund: roadmap_rank war projektweit und bereichslokal zugleich (#177) == Entschieden: projektweit. move() sucht den Nachbarn ohne Bereichsfilter, reorder() schreibt keinen ganzen Bereich mehr auf 10/20/30 um, sondern vertauscht nur die Raenge, die die Eintraege ohnehin halten. rank_between rechnet die Mitte zwischen zwei Nachbarn und nummeriert nur dann einmal neu - unter Erhalt der Reihenfolge -, wenn dort kein Platz mehr ist; die Antwort sagt es dann. == Befund: CSRF an sitzungsauthentifizierten API-Schreibzugriffen == ApiAuth akzeptiert die WebUI-Sitzung, die Endpunkte prueften dabei kein CSRF-Token. Der Kommentar in Http::beginJson - "ausschliesslich per Token-Header, niemals per Cookie" - traf auf die API nie zu. Schreibende Anfragen im Sitzungsweg verlangen jetzt csrf_token bzw. X-CSRF-Token. Token-Clients sind nicht betroffen und wurden gegengeprueft. == Geprueft == tests/e2e/suite_tafel.py neu (16 Faelle), Suite gesamt 172 von 172 gegen die Testinstanz. Die WebAuthn-Zeremonien zusaetzlich gegen einen Software- Authenticator mit echtem P-256 und echtem CBOR: Registrieren, Anmelden, verfaelschte Signatur, veraltete Herausforderung und Zaehler-Rueckfall als Klonverdacht werden je mit 403 abgewiesen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1138 lines
41 KiB
PHP
1138 lines
41 KiB
PHP
<?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
|
|
// ==================================================================
|
|
|
|
/**
|
|
* 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<int> $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[] = '<!-- 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, $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
|
|
* `<!-- dc:roadmap -->`-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<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.
|
|
// 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<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;
|
|
}
|
|
}
|