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>
435 lines
16 KiB
PHP
435 lines
16 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', 'check', 'board'];
|
|
const WRITE_ACTIONS = ['set', 'move', 'reorder', 'epic', 'rank_between'];
|
|
|
|
$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);
|
|
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, $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'] 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 '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;
|
|
}
|