Bis hierher galt eine Zweiteilung: Zustand im Deploymentcenter, Begruendung in docs/ROADMAP.md, verbunden ueber doc_anchor. Die Beschreibung der uebernommenen Eintraege sagte es woertlich - "Die Begruendung steht weiterhin in der Datei - hier steht nur der Zustand." Zwei Folgen, die erst im Betrieb sichtbar wurden: 1. Die Roadmap-Tafel kann die Begruendung nicht zeigen. Sie sieht die Datenbank; die Datei liegt auf einem Entwicklungsrechner. Im Seitenpanel stand deshalb nur doc_anchor als nicht anklickbarer Text mit dem Hinweis, er zeige auf etwas, das das WebUI nie zu sehen bekommt. 2. Schwerer: Die Beschreibung von dc_roadmap wies Agenten an, vor dem erneuten Vorschlagen eines zurueckgestellten Vorhabens "diesen Absatz" zu lesen. Ein Agent in polytrader oder clawddotnet kann ihn nicht lesen - er liegt in einem fremden Repository. Die Anweisung war nicht befolgbar. Dazu kam, dass cli/roadmap.php --check PHP braucht und auf dem Windows-Arbeitsplatz keines liegt: Die Haelfte der Pflegemaschinerie, die die Zweiteilung zusammenhalten sollte, lief dort nie. == Was sich aendert == bugtracker_items und roadmap_epics tragen rationale (Migration 020). Ein EIGENES Feld neben description, nicht statt dessen: description sagt, was gemeldet wurde, und kommt oft von einer fremden Maschine; rationale sagt, warum wir uns so entschieden haben. Zusammengelegt haette jede redaktionelle Ueberarbeitung den urspruenglichen Wortlaut der Meldung ueberschrieben, also die Grundlage, auf der entschieden wurde. Der Verlauf vermerkt nur, DASS die Begruendung sich geaendert hat und um wie viele Zeichen. Der ganze Absatz ein zweites Mal im Verlauf haette ihn nach der fuenften Ueberarbeitung unlesbar gemacht. == Der Weg herauf, einmalig == ?action=import_rationale liest eine mitgeschickte Roadmap-Datei. Drei Formen werden erkannt: Bereichsabschnitte, Vorhabensabschnitte und Tabellenzeilen. Die dritte ist kein Beiwerk - der Abschnitt "Verworfen" fuehrt seine zwoelf Ablehnungen als Tabelle, und ohne sie bliebe ausgerechnet das nicht am Item auffindbar, was am haeufigsten gebraucht wird: warum etwas schon einmal abgelehnt wurde. Ohne overwrite=true bleibt stehen, was im DC schon steht. == Der Weg herunter, dauerhaft == Ein zweites Markerpaar dc:rationale neben dc:roadmap. Bewusst ein zweites und keine Erweiterung des ersten: Zwischen den beiden steht in jeder gewachsenen Roadmap-Datei Handgeschriebenes - Einleitung, Leitlinie, Statuszeichen -, und ein einziger Block haette das beim naechsten Erzeugen verschluckt. ?action=render_file setzt beide Bloecke serverseitig ein, damit die Markersuche nur einmal umgesetzt ist. Sie hat eine Feinheit, die zweimal umgesetzt zweimal verschieden waere: Ein im Fliesstext ERWAEHNTER Marker darf nicht gefunden werden - die Einleitung dieser Datei erwaehnt jetzt beide. == Werkzeuge == scripts/dc.py roadmap-import und roadmap-write, in Python und nicht in cli/roadmap.php - sonst waeren sie auf dem Arbeitsplatz wieder nicht aufrufbar. roadmap-write behaelt die Zeilenenden der Datei bei. == Bestand == 35 Vorhaben und 6 Bereiche aus docs/ROADMAP.md uebernommen, 476 Zeilen handgepflegte Prosa durch den erzeugten Block ersetzt. Gegengeprueft auf Wortebene: Aus dem Fliesstext fehlt nichts, die einzigen Abweichungen sind die ersetzten Ueberschriften und die alte Einleitung. Kaertchen ohne hinterlegte Begruendung tragen auf der Tafel eine dezente Marke - erledigte nicht, dort waere sie ein Vorwurf ohne Adressaten. MCP-Adapter auf 1.3.0: dc_roadmap_set nimmt rationale, und die Beschreibung von dc_roadmap verweist nicht mehr auf eine Datei, die der Agent nicht erreichen kann. Geprueft: 7 neue Testfaelle, Suite gesamt 179 von 179. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
507 lines
19 KiB
PHP
507 lines
19 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;
|
|
use Deploymentcenter\Modules\Bugtracker\RoadmapBoard;
|
|
|
|
Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
|
|
|
|
const READ_ACTIONS = ['list', 'epics', 'next', 'active', 'render', 'render_file', 'check', 'board'];
|
|
const WRITE_ACTIONS = ['set', 'move', 'reorder', 'epic', 'rank_between', 'import_rationale'];
|
|
|
|
$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);
|
|
|
|
// 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;
|
|
}
|