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); // Ohne Angabe gilt das Heimatprojekt - nicht "alle". // // Hier stand "?? 'all'". Ein Werkbank-Token faellt beim Lesen absichtlich // nicht unter $readProject (es soll die Landschaft sehen duerfen), also griff // die Vorgabe, und dc_roadmap lieferte die Vorhaben aller Projekte. Da // CLAUDE.md dc_roadmap zum ersten Aufruf jeder Sitzung macht, war das der // erste Eindruck: ein fremder Backlog, den derselbe Agent nach derselben // Regel nicht bearbeiten darf. // // project_slug=all bleibt moeglich und ist jetzt eine Ansage statt einer // Voreinstellung. $angefragt = Http::str('project_slug') ?? Http::str('project'); $scope = $readProject ?? $angefragt ?? ApiAuth::homeProject($context) ?? 'all'; 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 'board': { // Die fertige Gliederung der Tafel: Spalten, Spuren, Kaertchen. // // Ein Projekt, nicht "alle": Die Phasen werden aus roadmap_rank // geschnitten, und der Rang gilt je Projekt. Zwei Projekte // uebereinanderzulegen ergaebe Spalten, die in keinem von beiden // stimmen. $project = requireProject($scope, $context); Http::ok((new RoadmapBoard($db))->build($project, [ 'done_limit' => Http::int('done_limit', RoadmapBoard::DONE_LIMIT), ])); } case 'render': { $project = requireProject($scope, $context); // Zwei erzeugte Bloecke: der Zustand (Vorgabe) und die // Begruendungen. Getrennt abrufbar, weil sie an verschiedenen // Stellen der Datei stehen und je ein eigenes Markerpaar haben. $part = strtolower(Http::str('part') ?? 'state'); if (!in_array($part, ['state', 'rationale'], true)) { Http::fail(400, 'unknown_part', 'Erlaubt sind "state" und "rationale".'); } Http::ok([ 'project_slug' => $project, 'part' => $part, 'markdown' => $part === 'rationale' ? $roadmap->renderRationaleBlock($project) : $roadmap->renderBlock($project), ]); } case 'render_file': { // Die ganze Datei, beide erzeugten Bloecke eingesetzt. // // Das Einsetzen laeuft hier und nicht im Aufrufer: Die Markersuche // hat Feinheiten - ein im Fliesstext ERWAEHNTER Marker darf nicht // gefunden werden -, und die zweimal umzusetzen hiesse, sie beim // zweiten Mal anders umzusetzen. Der Aufrufer schickt den // Dateiinhalt und schreibt zurueck, was er bekommt. $project = requireProject($scope, $context); $content = Http::str('content'); if ($content === null) { Http::fail(400, 'missing_content', 'Das Feld "content" mit dem Dateiinhalt fehlt.'); } $neu = Roadmap::spliceBlock($content, $roadmap->renderBlock($project)); $neu = Roadmap::spliceRationaleBlock($neu, $roadmap->renderRationaleBlock($project)); Http::ok([ 'project_slug' => $project, 'markdown' => $neu, 'changed' => $neu !== $content, ]); } 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, $context); $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, // Standardmaessig die schmale Sicht. // // Vorher kam der volle Datensatz mit rund fuenfzig Feldern je // Eintrag - fuer 57 Vorhaben etwa 137.000 Zeichen. Da CLAUDE.md // diesen Aufruf an den Anfang jeder Sitzung stellt, fuellte // ausgerechnet er das Kontextfenster, bevor die Arbeit begann. // // Eine Roadmap beantwortet "was ist dran, wer sitzt dran". Die // Begruendung steht ohnehin nicht hier, sondern hinter doc_anchor; // die Einzelheiten holt dc_get_item. fields=full liefert weiterhin // alles, fuer den Fall, dass jemand genau das braucht. 'fields' => Http::str('fields') === 'full' ? 'full' : 'compact', ]; $result = $repo->getItems($filters); $result['fields'] = $filters['fields']; $result['stats'] = $roadmap->stats($scope); // Wenn ein gebundenes Token nach einem fremden Projekt fragt, bekommt // es die eigene Roadmap - das ist richtig, darf aber nicht wie eine // Auskunft ueber das angefragte Projekt aussehen (#131). Http::ok(array_merge($result, ApiAuth::scopeNotice($angefragt, $scope))); } 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', 'rationale'] 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 'rank_between': { // Das Ziehen auf der Tafel. Der Aufrufer sagt, zwischen welche // beiden Nachbarn der Eintrag gehoert; den Rang rechnet der Server // aus. Andersherum - der Client schickt einen fertigen Rang - waere // die Rangvergabe an zwei Stellen umgesetzt, und die zweite wuesste // nichts von #177. $itemId = Http::int('id', 0) ?: 0; if ($itemId < 1) { Http::fail(400, 'missing_id', 'Das Feld "id" fehlt.'); } assertOwnProject($repo, $writeProject, $itemId); $prevId = Http::input('prev_id') !== null ? Http::int('prev_id', 0) : null; $nextId = Http::input('next_id') !== null ? Http::int('next_id', 0) : null; $minRank = Http::input('min_rank') !== null ? Http::int('min_rank', 0) : null; $maxRank = Http::input('max_rank') !== null ? Http::int('max_rank', 0) : null; try { $result = $roadmap->rankBetween( $itemId, $prevId !== null && $prevId > 0 ? $prevId : null, $nextId !== null && $nextId > 0 ? $nextId : null, $minRank, $maxRank ); } catch (\RuntimeException $e) { Http::fail(409, 'cannot_rank', $e->getMessage()); } $repo->addComment( $itemId, 'user:' . $author, sprintf( 'Reihenfolge auf der Tafel geaendert: Rang %d.%s', $result['rank'], $result['renumbered'] ? ' Dafuer wurden die Raenge des Projekts einmal neu vergeben (Zehnerschritte) -' . ' die Reihenfolge blieb dabei erhalten.' : '' ), 'commented' ); Http::ok([ 'id' => $itemId, 'rank' => $result['rank'], 'renumbered' => $result['renumbered'], 'message' => $result['renumbered'] ? sprintf('#%d eingeordnet (Rang %d). Die Raenge des Projekts wurden dabei neu vergeben.', $itemId, $result['rank']) : sprintf('#%d eingeordnet (Rang %d).', $itemId, $result['rank']), ]); } case 'import_rationale': { // Einmaliger Weg herauf: Die Begruendungen, die bisher nur in der // Projektdatei standen, werden eingelesen. Der Dateiinhalt kommt // mitgeschickt - genau wie bei action=check und aus demselben Grund: // Die Datei liegt im Repository des Projekts, oft auf einer anderen // Maschine, und der Server soll nicht anfangen, in Verzeichnissen zu // suchen. $project = requireProject($writeProject ?? $angefragt, $context); $content = Http::str('content'); if ($content === null) { Http::fail(400, 'missing_content', 'Das Feld "content" mit dem Dateiinhalt fehlt.'); } // Ohne overwrite bleibt stehen, was schon hinterlegt ist. Ein zweiter // Lauf soll eine im Deploymentcenter ueberarbeitete Begruendung nicht // stillschweigend auf den Stand der Datei zuruecksetzen. $overwrite = (bool)Http::input('overwrite', false); $bericht = $roadmap->importRationale($project, $content, $overwrite); Http::ok([ 'project_slug' => $project, 'overwrite' => $overwrite, 'report' => $bericht, 'message' => sprintf( '%d Abschnitt(e) gelesen, %d Vorhaben und %d Bereich(e) uebernommen.', $bericht['gefunden'], $bericht['items']['gespeichert'], $bericht['epics']['gespeichert'] ), ]); } case 'reorder': { $project = requireProject($writeProject ?? $angefragt, $context); $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 ?? $angefragt, $context); $epic = Http::str('epic'); $title = Http::str('title'); if ($epic === null || $title === null) { Http::fail(400, 'missing_fields', 'Es werden "epic" und "title" erwartet.'); } // Nicht mitgeschickte Felder bleiben unveraendert - ein Aufruf, der // nur den Titel richtigstellt, soll nicht die Farbe loeschen. $highlight = Http::input('highlight'); $roadmap->saveEpic( $project, $epic, $title, Http::str('summary'), Http::int('sort_order', 100) ?: 100, Http::input('color') !== null ? (string)Http::str('color', '') : null, $highlight !== null ? (bool)$highlight : null ); 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. * * Der Rueckfall auf das Heimatprojekt ist nicht Bequemlichkeit, sondern * behebt einen Widerspruch: Beim Lesen laesst projectFilter() ein * Werkbank-Token bewusst durch (es soll die Landschaft sehen), liefert also * null - und damit wurde $scope zu "all". Ausgerechnet der Tokentyp, den der * MCP-Adapter verlangt, konnte seine eigene Roadmap nicht rendern. * * Das Token ist trotzdem an ein Projekt gebunden; diese Bindung liefert * homeProject(). "Meine Roadmap" ohne Angabe heisst genau das. */ function requireProject(?string $slug, array $context = []): string { if ($slug !== null && $slug !== '' && $slug !== 'all') { return $slug; } $home = ApiAuth::homeProject($context); if ($home !== null) { return $home; } Http::fail(400, 'missing_project', 'Diese Aktion braucht ein konkretes "project_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; }