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>
318 lines
11 KiB
PHP
318 lines
11 KiB
PHP
<?php
|
|
|
|
/**
|
|
* /api/bugtracker/v1/roadmap.php
|
|
*
|
|
* Die Roadmap - Vorhaben mit Themenbereich, Reihenfolge und Zielfassung.
|
|
*
|
|
* GET ?action=list Eintraege, gefiltert (bugtracker:read)
|
|
* GET ?action=epics Themenbereiche samt Zaehlung (bugtracker:read)
|
|
* GET ?action=next "Als Naechstes" (bugtracker:read)
|
|
* GET ?action=active Wer arbeitet gerade woran (bugtracker:read)
|
|
* GET ?action=render Der erzeugte Markdown-Block (bugtracker:read)
|
|
* POST ?action=set Bereich/Rang/Ziel/Anker setzen (bugtracker:manage)
|
|
* POST ?action=move Eintrag eine Position schieben (bugtracker:manage)
|
|
* POST ?action=reorder Reihenfolge eines Bereichs neu (bugtracker:manage)
|
|
* POST ?action=epic Themenbereich anlegen/aendern (bugtracker:manage)
|
|
*
|
|
* KEINE eigene Datenhaltung. Ein Roadmap-Eintrag ist ein bugtracker_item mit
|
|
* gesetztem `epic`; dieser Endpunkt ist die Sicht darauf. Anlegen laeuft
|
|
* weiterhin ueber /report, Abschliessen ueber /manage - sonst gaebe es zwei
|
|
* Wege zu denselben Daten und damit zwei Stellen, an denen Regeln gelten
|
|
* muessten.
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../../../../src/bootstrap.php';
|
|
|
|
use Deploymentcenter\Core\ApiAuth;
|
|
use Deploymentcenter\Core\Db;
|
|
use Deploymentcenter\Core\Http;
|
|
use Deploymentcenter\Modules\Bugtracker\BugRepo;
|
|
use Deploymentcenter\Modules\Bugtracker\Roadmap;
|
|
|
|
Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
|
|
|
|
const READ_ACTIONS = ['list', 'epics', 'next', 'active', 'render', 'check'];
|
|
const WRITE_ACTIONS = ['set', 'move', 'reorder', 'epic'];
|
|
|
|
$action = strtolower(Http::str('action') ?? 'list');
|
|
|
|
if (!in_array($action, READ_ACTIONS, true) && !in_array($action, WRITE_ACTIONS, true)) {
|
|
Http::fail(404, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [
|
|
'available' => array_merge(READ_ACTIONS, WRITE_ACTIONS),
|
|
]);
|
|
}
|
|
|
|
$isWrite = in_array($action, WRITE_ACTIONS, true);
|
|
|
|
if ($isWrite && Http::method() !== 'POST') {
|
|
Http::fail(405, 'method_not_allowed', sprintf('Die Aktion "%s" erwartet POST.', $action));
|
|
}
|
|
|
|
$db = Db::init();
|
|
$repo = new BugRepo($db);
|
|
$roadmap = new Roadmap($db);
|
|
|
|
$context = ApiAuth::requireScope($db, $isWrite ? 'bugtracker:manage' : 'bugtracker:read');
|
|
$author = $context['actor'];
|
|
|
|
// Lesen darf die Landschaft sehen, Schreiben nur das eigene Projekt.
|
|
//
|
|
// Dieselbe Trennung wie in /manage, und aus demselben Grund: Ein Agent soll
|
|
// erkennen koennen, wo sein Projekt in der Gesamtplanung steht - aber die
|
|
// Reihenfolge fremder Vorhaben legt er nicht fest.
|
|
$readProject = ApiAuth::projectFilter($context, true);
|
|
$writeProject = ApiAuth::projectFilter($context);
|
|
|
|
$requested = Http::str('project_slug') ?? Http::str('project') ?? 'all';
|
|
$scope = $readProject ?? $requested;
|
|
|
|
switch ($action) {
|
|
case 'epics':
|
|
Http::ok([
|
|
'epics' => $roadmap->epics($scope),
|
|
'orphans' => $roadmap->orphanEpics($scope),
|
|
'stats' => $roadmap->stats($scope),
|
|
]);
|
|
|
|
case 'next':
|
|
Http::ok([
|
|
'items' => $roadmap->nextUp($scope, Http::int('limit', 5) ?: 5),
|
|
]);
|
|
|
|
case 'active':
|
|
Http::ok([
|
|
'items' => $roadmap->activeWork($scope),
|
|
]);
|
|
|
|
case 'render': {
|
|
$project = requireProject($scope);
|
|
Http::ok([
|
|
'project_slug' => $project,
|
|
'markdown' => $roadmap->renderBlock($project),
|
|
]);
|
|
}
|
|
|
|
case 'check': {
|
|
// Der Abgleich braucht den Dateiinhalt. Er kommt mitgeschickt, statt
|
|
// dass der Server ihn liest: Die Datei liegt im Repository des
|
|
// Projekts, oft auf einer anderen Maschine. Der Server kennt sie nicht
|
|
// und soll auch nicht anfangen, in Verzeichnissen zu suchen.
|
|
$project = requireProject($scope);
|
|
$content = Http::str('content');
|
|
|
|
if ($content === null) {
|
|
Http::fail(400, 'missing_content', 'Das Feld "content" mit dem Dateiinhalt fehlt.');
|
|
}
|
|
|
|
$tmp = tempnam(sys_get_temp_dir(), 'dcrm');
|
|
if ($tmp === false) {
|
|
Http::fail(500, 'tmp_failed', 'Konnte den Inhalt nicht zwischenspeichern.');
|
|
}
|
|
|
|
file_put_contents($tmp, $content);
|
|
$result = $roadmap->check($project, $tmp);
|
|
@unlink($tmp);
|
|
|
|
Http::ok($result);
|
|
}
|
|
|
|
case 'list': {
|
|
$filters = [
|
|
'project_slug' => $scope,
|
|
'on_roadmap' => true,
|
|
'epic' => Http::str('epic') ?? 'all',
|
|
'status' => resolveStatusFilter(Http::str('status')),
|
|
'type' => Http::str('type') ?? 'all',
|
|
'severity' => Http::str('severity') ?? 'all',
|
|
'target_build' => Http::str('target_build') ?? 'all',
|
|
'claimed_by' => Http::str('claimed_by') ?? '',
|
|
'search' => Http::str('q') ?? '',
|
|
'order' => 'roadmap',
|
|
'limit' => Http::int('limit', 200) ?: 200,
|
|
'offset' => Http::int('offset', 0) ?: 0,
|
|
];
|
|
|
|
$result = $repo->getItems($filters);
|
|
$result['stats'] = $roadmap->stats($scope);
|
|
|
|
Http::ok($result);
|
|
}
|
|
|
|
case 'set': {
|
|
$itemId = Http::int('id', 0) ?: 0;
|
|
if ($itemId < 1) {
|
|
Http::fail(400, 'missing_id', 'Das Feld "id" fehlt.');
|
|
}
|
|
|
|
assertOwnProject($repo, $writeProject, $itemId);
|
|
|
|
// Nur die Roadmap-Felder. Titel, Schweregrad und Status laufen
|
|
// weiterhin ueber /manage - ein zweiter Schreibweg auf dieselben
|
|
// Spalten waere eine zweite Stelle, an der Regeln gelten muessten.
|
|
$updates = [];
|
|
foreach (['epic', 'target_build', 'defer_trigger', 'doc_anchor'] as $field) {
|
|
if (Http::input($field) !== null) {
|
|
$updates[$field] = Http::str($field);
|
|
}
|
|
}
|
|
|
|
// Ein leerer Wert loescht die Reihenfolge, statt sie auf 0 zu setzen.
|
|
// Sonst waere "keine Meinung" nicht mehr ausdrueckbar - und Rang 0
|
|
// sortierte den Eintrag ausgerechnet an die Spitze.
|
|
if (Http::input('roadmap_rank') !== null) {
|
|
$raw = Http::str('roadmap_rank');
|
|
$updates['roadmap_rank'] = ($raw !== null && is_numeric($raw)) ? (int)$raw : null;
|
|
}
|
|
|
|
// Zurueckstellen laeuft ueber diesen Weg mit, weil Status und
|
|
// Ausloeser zusammen ankommen muessen - getrennt gesetzt entstuende
|
|
// genau der Eintrag ohne Ausloeser, den die Regel verhindern soll.
|
|
$status = Http::str('status');
|
|
if ($status !== null) {
|
|
$updates['status'] = $status;
|
|
}
|
|
|
|
if ($updates === []) {
|
|
Http::fail(400, 'nothing_to_do', 'Keines der Roadmap-Felder wurde mitgeschickt.', null, [
|
|
'fields' => ['epic', 'roadmap_rank', 'target_build', 'defer_trigger', 'doc_anchor', 'status'],
|
|
]);
|
|
}
|
|
|
|
try {
|
|
if (!$repo->updateItemDetails($itemId, $updates, $author)) {
|
|
Http::fail(404, 'not_found', sprintf('Item #%d existiert nicht.', $itemId));
|
|
}
|
|
} catch (\InvalidArgumentException $e) {
|
|
Http::fail(400, 'invalid_update', $e->getMessage());
|
|
}
|
|
|
|
Http::ok(['message' => sprintf('Roadmap-Angaben zu #%d gesetzt.', $itemId)]);
|
|
}
|
|
|
|
case 'move': {
|
|
$itemId = Http::int('id', 0) ?: 0;
|
|
if ($itemId < 1) {
|
|
Http::fail(400, 'missing_id', 'Das Feld "id" fehlt.');
|
|
}
|
|
|
|
assertOwnProject($repo, $writeProject, $itemId);
|
|
|
|
$direction = strtolower(Http::str('direction') ?? 'up');
|
|
if (!in_array($direction, ['up', 'down'], true)) {
|
|
Http::fail(400, 'invalid_direction', 'Erlaubt sind "up" und "down".');
|
|
}
|
|
|
|
if (!$roadmap->move($itemId, $direction)) {
|
|
Http::fail(409, 'cannot_move', sprintf(
|
|
'#%d laesst sich nicht weiter nach %s schieben - '
|
|
. 'schon am Rand des Bereichs, oder ohne Bereich.',
|
|
$itemId,
|
|
$direction === 'up' ? 'oben' : 'unten'
|
|
));
|
|
}
|
|
|
|
Http::ok(['message' => sprintf('#%d verschoben.', $itemId)]);
|
|
}
|
|
|
|
case 'reorder': {
|
|
$project = requireProject($writeProject ?? $requested);
|
|
$epic = Http::str('epic');
|
|
$ids = Http::input('ids');
|
|
|
|
if ($epic === null || !is_array($ids) || $ids === []) {
|
|
Http::fail(400, 'missing_fields', 'Es werden "epic" und "ids" (Liste) erwartet.');
|
|
}
|
|
|
|
$touched = $roadmap->reorder($project, $epic, array_map('intval', $ids));
|
|
|
|
Http::ok([
|
|
'message' => sprintf('%d Eintrag/Eintraege neu gereiht.', $touched),
|
|
'touched' => $touched,
|
|
]);
|
|
}
|
|
|
|
case 'epic': {
|
|
$project = requireProject($writeProject ?? $requested);
|
|
$epic = Http::str('epic');
|
|
$title = Http::str('title');
|
|
|
|
if ($epic === null || $title === null) {
|
|
Http::fail(400, 'missing_fields', 'Es werden "epic" und "title" erwartet.');
|
|
}
|
|
|
|
$roadmap->saveEpic(
|
|
$project,
|
|
$epic,
|
|
$title,
|
|
Http::str('summary'),
|
|
Http::int('sort_order', 100) ?: 100
|
|
);
|
|
|
|
Http::ok(['message' => sprintf('Themenbereich "%s" gespeichert.', $epic)]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verlangt ein konkretes Projekt.
|
|
*
|
|
* "all" ist beim Lesen sinnvoll und beim Schreiben oder Erzeugen nicht - eine
|
|
* Datei gehoert zu genau einem Repository.
|
|
*/
|
|
function requireProject(?string $slug): string
|
|
{
|
|
if ($slug === null || $slug === '' || $slug === 'all') {
|
|
Http::fail(400, 'missing_project', 'Diese Aktion braucht ein konkretes "project_slug".');
|
|
}
|
|
|
|
return $slug;
|
|
}
|
|
|
|
/**
|
|
* Ein Roadmap-Eintrag wird nur im eigenen Projekt umsortiert.
|
|
*
|
|
* Dieselbe Schranke wie beim Uebernehmen von Arbeit: Wer in Projekt X
|
|
* arbeitet, ordnet nicht die Planung von Projekt Y.
|
|
*/
|
|
function assertOwnProject(BugRepo $repo, ?string $bound, int $itemId): void
|
|
{
|
|
if ($bound === null) {
|
|
return; // Master-Token oder Oberflaeche
|
|
}
|
|
|
|
$item = $repo->getItemDetails($itemId);
|
|
if ($item === null) {
|
|
Http::fail(404, 'not_found', sprintf('Item #%d existiert nicht.', $itemId));
|
|
}
|
|
|
|
if ((string)$item['project_slug'] !== $bound) {
|
|
Http::fail(403, 'foreign_project', sprintf(
|
|
'Item #%d gehoert zu "%s". Fremde Roadmaps werden nicht umsortiert.',
|
|
$itemId,
|
|
(string)$item['project_slug']
|
|
));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Uebersetzt eine Lage-Auswahl in die zugehoerigen Status.
|
|
*
|
|
* Elf Status sind fuer die Ablage richtig und fuer einen Filter zu viele. Wer
|
|
* "offen" waehlt, meint keine ENUM-Konstante, sondern eine Lage - deshalb hier
|
|
* die Gruppen aus Roadmap::STATUS_GROUPS, mit den Einzelstatus als Rueckfall.
|
|
*/
|
|
function resolveStatusFilter(?string $value): string
|
|
{
|
|
if ($value === null || $value === '' || $value === 'all') {
|
|
return 'all';
|
|
}
|
|
|
|
if (isset(Roadmap::STATUS_GROUPS[$value])) {
|
|
return implode(',', Roadmap::STATUS_GROUPS[$value]);
|
|
}
|
|
|
|
return $value;
|
|
}
|