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; }