'bugtracker:verify', 'next' => 'bugtracker:verify', 'release' => 'bugtracker:verify', 'comment' => 'bugtracker:verify', 'confirm' => 'bugtracker:verify', 'status' => 'bugtracker:manage', 'update' => 'bugtracker:manage', 'resolve' => 'bugtracker:manage', 'bulk_update' => 'bugtracker:manage', ]; $db = Db::init(); $repo = new BugRepo($db); $action = resolveAction(); $method = Http::method(); 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 && $method !== 'POST') { Http::fail(405, 'method_not_allowed', sprintf('Die Aktion "%s" erwartet POST.', $action)); } $context = ApiAuth::requireScope( $db, $isWrite ? (ACTION_SCOPES[$action] ?? 'bugtracker:manage') : 'bugtracker:read' ); $author = $context['actor']; // Zwei Einschraenkungen, weil Lesen und Schreiben hier verschieden weit reichen. // // $boundProject gilt beim Schreiben und kennt keine Ausnahme: auch ein // Werkbank-Token uebernimmt und schliesst nur Items im eigenen Projekt. Sonst // zoege ein Agent, der in Projekt X arbeitet, ueber ?action=next Arbeit aus // einem fremden Backlog an sich. // // $readProject gilt beim Lesen und laesst Werkbank-Token durch: ein Agent, der // entscheiden soll, ob ein Fehler ins eigene oder ins fremde Projekt gehoert, // muss die Landschaft sehen. $boundProject = ApiAuth::projectFilter($context); $readProject = ApiAuth::projectFilter($context, true); $homeProject = ApiAuth::homeProject($context); // Was der Aufrufer ANGEFRAGT hat - unabhaengig davon, was er bekommt. // Gebraucht fuer den Hinweis auf eine Verengung, siehe verengungsHinweis(). $angefragtesProjekt = Http::str('project_slug') ?? Http::str('project'); $itemId = resolveItemId(); switch ($action) { // ---------------------------------------------------------------- lesen case 'projects': Http::ok(['projects' => $repo->getProjects()]); // no break - Http::ok beendet die Anfrage case 'stats': // Die Bindung geht der Anfrage vor. Vorher stand hier // "Http::str('project_slug') ?? $boundProject" - damit konnte ein an // Projekt X gebundenes Token die Zahlen jedes anderen Projekts abrufen, // indem es den Slug einfach mitschickte. // // Die Vorgabe ohne Angabe ist das Heimatprojekt, nicht "alle": Ein // Werkbank-Token faellt beim Lesen nicht unter $readProject, bekam // also die Zahlen aller Projekte in einem Topf - dieselbe stille // Verwechslung wie bei list und roadmap. $slug = $readProject ?? Http::str('project_slug') ?? $homeProject; Http::ok(array_merge( ['stats' => $repo->getStats($slug)], ApiAuth::scopeNotice($angefragtesProjekt, (string)$slug) )); case 'get': requireItemId($itemId); $item = $repo->getItemDetails($itemId); if ($item === null) { Http::fail(404, 'not_found', sprintf('Item #%d existiert nicht.', $itemId)); } ApiAuth::enforceProject($context, (string)$item['project_slug'], true); Http::ok(['item' => $item]); case 'list': $filters = collectFilters($readProject, $homeProject); $result = $repo->getItems($filters); Http::ok(array_merge([ 'count' => count($result['items']), 'total' => $result['total'], 'limit' => $result['limit'], 'offset' => $result['offset'], 'has_more' => $result['has_more'], 'filters' => $filters, 'items' => $result['items'], ], ApiAuth::scopeNotice($angefragtesProjekt, (string)$filters['project_slug']))); // -------------------------------------------------------------- schreiben case 'claim': requireItemId($itemId); assertProject($repo, $context, $itemId); $claimed = $repo->claimItem($itemId, $author, Http::int('lease_minutes', 0) ?: null); if ($claimed === null) { Http::fail(409, 'already_claimed', sprintf( 'Item #%d ist bereits vergeben oder nicht mehr offen.', $itemId )); } Http::ok(['item' => $claimed, 'message' => sprintf('Item #%d uebernommen.', $itemId)]); case 'next': $filters = collectFilters($boundProject, ApiAuth::homeProject($context)); // Der Rueckkanal: zusaetzlich zu den eigenen Items auch fremde, die auf // Nachpruefung warten und bei denen dieses Projekt als betroffen // vermerkt ist. Nur dort - siehe assertProject(). if ($boundProject !== null) { $filters['include_affected_verification'] = $boundProject; } $limit = Http::int('limit', 1); $claimedItems = $repo->claimNext($author, $filters, $limit); Http::ok([ 'count' => count($claimedItems), 'items' => $claimedItems, 'message' => $claimedItems === [] ? 'Aktuell keine offenen Items verfuegbar.' : sprintf('%d Item(s) uebernommen.', count($claimedItems)), ]); case 'release': requireItemId($itemId); // Zurueckgeben duerfen heisst: was man haelt, auch loslassen koennen. assertProject($repo, $context, $itemId, false, true); if (!$repo->releaseItem($itemId, $author, Http::str('note'))) { Http::fail(409, 'not_claimed', sprintf( 'Item #%d ist nicht von "%s" beansprucht.', $itemId, $author )); } Http::ok(['message' => sprintf('Item #%d freigegeben.', $itemId)]); case 'comment': requireItemId($itemId); assertProject($repo, $context, $itemId, true, true); $comment = Http::str('comment'); if ($comment === null) { Http::fail(400, 'missing_comment', 'Das Feld "comment" darf nicht leer sein.'); } $meta = Http::input('meta'); $created = $repo->addComment( $itemId, $author, $comment, Http::str('action_taken') ?? 'commented', is_array($meta) ? $meta : null ); Http::ok(['comment' => $created], 201); case 'status': requireItemId($itemId); // Der Pruefende schliesst mit dem Status ab - auch bei einem fremden // Item, das er zur Nachpruefung haelt. assertProject($repo, $context, $itemId, false, true); $status = Http::str('status'); if ($status === null) { Http::fail(400, 'missing_status', 'Das Feld "status" fehlt.', null, [ 'allowed' => BugRepo::STATUSES, ]); } try { $changed = $repo->updateStatus($itemId, $status, Http::str('notes'), $author); } catch (\InvalidArgumentException $e) { // Etwa: zurueckstellen ohne Ausloeser. Das ist eine verletzte // Regel, kein unbekannter Status - und verdient deshalb eine // Meldung, die sagt, was fehlt. Http::fail(400, 'invalid_transition', $e->getMessage()); } if (!$changed) { Http::fail(400, 'invalid_status', sprintf( 'Status "%s" ist unbekannt oder Item #%d existiert nicht.', $status, $itemId ), null, ['allowed' => BugRepo::STATUSES]); } Http::ok(['message' => sprintf('Status von #%d auf "%s" gesetzt.', $itemId, $status)]); case 'confirm': requireItemId($itemId); // Wie bei "status": Auch ein fremdes Item darf bestaetigt werden, // solange es zur Nachpruefung gehalten wird. Wer den Fehler gemeldet // hat, ist oft der Einzige, der bestaetigen kann, dass der Fix bei ihm // wirkt - auch wenn das Item einem anderen Projekt gehoert. assertProject($repo, $context, $itemId, false, true); try { $confirmed = $repo->confirmItem( $itemId, Http::str('verified_in_build') ?? Http::str('build'), Http::str('notes'), $author ); } catch (\InvalidArgumentException $e) { // Ein Item, an dem es nichts zu bestaetigen gibt. Das ist eine // verletzte Regel, kein fehlendes Item - und die Meldung sagt, // welche Zustaende in Frage kaemen. Http::fail(409, 'not_verifiable', $e->getMessage()); } if (!$confirmed) { Http::fail(404, 'not_found', sprintf('Item #%d existiert nicht.', $itemId)); } Http::ok(['message' => sprintf('Nachpruefung von #%d bestaetigt, Item geschlossen.', $itemId)]); case 'update': requireItemId($itemId); assertProject($repo, $context, $itemId); try { $updated = $repo->updateItemDetails($itemId, Http::body(), $author); } catch (\InvalidArgumentException $e) { Http::fail(400, 'invalid_update', $e->getMessage()); } if (!$updated) { Http::fail(404, 'not_found', sprintf('Item #%d existiert nicht.', $itemId)); } Http::ok(['message' => sprintf('Item #%d aktualisiert.', $itemId)]); case 'resolve': requireItemId($itemId); assertProject($repo, $context, $itemId); $build = Http::str('resolved_in_build'); if ($build === null) { Http::fail(400, 'missing_build', 'Das Feld "resolved_in_build" wird benoetigt.'); } if (!$repo->resolveItem($itemId, $build, Http::str('resolution_notes'), $author)) { Http::fail(404, 'not_found', sprintf('Item #%d existiert nicht.', $itemId)); } Http::ok(['message' => sprintf('Item #%d in Build "%s" geloest.', $itemId, $build)]); case 'bulk_update': $ids = Http::input('ids'); if (!is_array($ids) || $ids === []) { Http::fail(400, 'missing_ids', 'Das Feld "ids" muss eine nicht leere Liste sein.'); } if (count($ids) > 200) { Http::fail(400, 'too_many_ids', 'Maximal 200 Items pro Aufruf.'); } $updates = Http::input('updates'); if (!is_array($updates) || $updates === []) { Http::fail(400, 'missing_updates', 'Das Feld "updates" muss die zu setzenden Felder enthalten.'); } $result = $repo->bulkUpdate($ids, $updates, $author); Http::ok([ 'updated' => $result['updated'], 'failed' => $result['failed'], 'message' => sprintf('%d Item(s) aktualisiert.', $result['updated']), ]); } // ====================================================================== // Hilfsfunktionen // ====================================================================== /** * Ermittelt die Aktion aus ?action= oder aus dem letzten Pfadsegment. * Ohne Angabe: "get" bei vorhandener ID, sonst "list". */ function resolveAction(): string { $explicit = $_GET['action'] ?? null; if (is_string($explicit) && $explicit !== '') { return strtolower(trim($explicit)); } // Pfadform: /manage/items/42/comment -> "comment" $path = trim(Http::path(), '/'); $segments = array_values(array_filter(explode('/', $path), static fn(string $s): bool => $s !== '')); $last = end($segments); if (is_string($last)) { $candidate = strtolower($last); if (in_array($candidate, READ_ACTIONS, true) || in_array($candidate, WRITE_ACTIONS, true)) { return $candidate; } } return resolveItemId() > 0 ? 'get' : 'list'; } /** Item-ID aus Query, Body oder Pfad (/items/42). */ function resolveItemId(): int { $fromRequest = $_GET['id'] ?? null; if (is_numeric($fromRequest)) { return (int)$fromRequest; } if (Http::method() === 'POST') { $body = Http::body(); if (isset($body['id']) && is_numeric($body['id'])) { return (int)$body['id']; } if (isset($body['item_id']) && is_numeric($body['item_id'])) { return (int)$body['item_id']; } } if (preg_match('#/items/(\d+)#', Http::path(), $m) === 1) { return (int)$m[1]; } return 0; } function requireItemId(int $itemId): void { if ($itemId <= 0) { Http::fail(400, 'missing_id', 'Es wurde keine Item-ID uebergeben (?id=... oder /items/).'); } } /** * Stellt sicher, dass ein projektgebundenes Token das Item anfassen darf. * * $allowWorkbench laesst Werkbank-Token auch an fremde Items. Das ist nur fuer * Kommentare gesetzt: wer aus einem anderen Projekt heraus melden darf, muss * auch nachreichen koennen, was er inzwischen herausgefunden hat. Uebernehmen, * abschliessen oder den Status aendern bleibt dem besitzenden Projekt * vorbehalten - dort weiss man, was ein "resolved" bedeutet. */ /** * Projektschranke fuer Schreibzugriffe auf ein einzelnes Item. * * $allowLeaseHolder oeffnet sie fuer den, der das Item gerade haelt. Das ist * kein Schlupfloch, sondern die Kehrseite der Nachpruefung: Ein Agent kann ein * FREMDES Item nur ueber ?action=next in die Hand bekommen, und dort gibt * BugRepo nur Items heraus, die auf Nachpruefung warten und bei denen sein * Projekt als betroffen vermerkt ist (siehe include_affected_verification). * Wer auf diesem Weg etwas haelt, muss es auch kommentieren, im Status setzen * und zurueckgeben duerfen - sonst haette er eine Aufgabe, die er nicht * abschliessen kann. * * Bewusst NICHT geoeffnet: resolve und update. Was in einem fremden Projekt * als "behoben in Build X" gilt, entscheidet dieses Projekt. Der Pruefende * bestaetigt (status) oder meldet erneut - mehr braucht er nicht. */ function assertProject( BugRepo $repo, array $context, int $itemId, bool $allowWorkbench = false, bool $allowLeaseHolder = false ): void { if (ApiAuth::projectFilter($context, $allowWorkbench) === null) { return; } $item = $repo->getItemDetails($itemId); if ($item === null) { Http::fail(404, 'not_found', sprintf('Item #%d existiert nicht.', $itemId)); } if ($allowLeaseHolder && ($item['claimed_by'] ?? null) === ($context['actor'] ?? null) && $item['lease_until'] !== null && strtotime((string)$item['lease_until']) > time() ) { return; } ApiAuth::enforceProject($context, (string)$item['project_slug'], $allowWorkbench); } /** * Sammelt Filter aus Query und Body. * * @param ?string $boundProject Zwang: aus dieser Bindung kommt das Token nicht heraus. * @param ?string $homeProject Vorgabe: wo der Aufrufer zuhause ist. * @return array */ function collectFilters(?string $boundProject, ?string $homeProject = null): array { // WELCHES PROJEKT, WENN KEINES GENANNT IST? // // Hier stand schlicht 'all'. Das klingt harmlos und war es nicht: Ein // Werkbank-Token IST an ein Projekt gebunden, faellt beim Lesen aber // absichtlich nicht unter die Schranke - es soll die Landschaft sehen // duerfen (siehe $readProject weiter oben). Damit war $boundProject null, // die Vorgabe griff, und der Agent bekam die Items aller Projekte. // // Ausgerechnet am Sitzungsanfang: CLAUDE.md macht dc_roadmap zum ersten // Aufruf. Der Agent sah einen fremden Backlog - den er nach derselben // Regel nicht bearbeiten darf. Die Werkzeugbeschreibung sagte dazu das // Gegenteil ("Ohne project_slug das eigene Projekt"), was den Widerspruch // lange verdeckt hat. // // Jetzt gilt: ein ausdruecklich genannter Slug, sonst das Heimatprojekt, // und erst ohne beides "alle". Wer die Landschaft sehen will, schickt // weiterhin project_slug=all - nur ist das jetzt eine Entscheidung statt // eines Nebeneffekts. $angefragt = Http::str('project_slug') ?? Http::str('project'); $filters = [ 'project_slug' => $angefragt ?? $homeProject ?? 'all', 'environment' => Http::str('environment') ?? Http::str('env') ?? 'all', 'type' => Http::str('type') ?? 'all', 'status' => Http::str('status') ?? 'all', 'severity' => Http::str('severity') ?? 'all', 'push_id' => Http::str('push_id') ?? '', 'target_agent' => Http::str('target_agent') ?? Http::str('agent') ?? '', 'claimed_by' => Http::str('claimed_by') ?? '', 'search' => Http::str('search') ?? Http::str('q') ?? '', 'updated_since' => Http::str('updated_since') ?? '', 'order' => Http::str('order') ?? 'newest', 'limit' => Http::int('limit', 100), 'offset' => Http::int('offset', 0), // Hier bleibt der volle Datensatz die Vorgabe: Wer sucht, will die // Fundstelle sehen, nicht nur ihren Titel. fields=compact schneidet // auf die Uebersichtsfelder zurueck - dieselbe Auswahl, die die // Roadmap standardmaessig liefert (BugRepo::COMPACT_FIELDS). 'fields' => Http::str('fields') === 'compact' ? 'compact' : 'full', ]; if (Http::input('unclaimed_only') !== null) { $filters['unclaimed_only'] = filter_var(Http::input('unclaimed_only'), FILTER_VALIDATE_BOOLEAN); } if (Http::input('on_roadmap') !== null && Http::input('on_roadmap') !== '') { $filters['on_roadmap'] = filter_var(Http::input('on_roadmap'), FILTER_VALIDATE_BOOLEAN); } // Ein projektgebundenes Token kann den Projektfilter nicht umgehen. if ($boundProject !== null) { $filters['project_slug'] = $boundProject; } return $filters; }