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 ? 'bugtracker:manage' : 'bugtracker:read'); $author = $context['actor']; $boundProject = ApiAuth::projectFilter($context); $itemId = resolveItemId(); switch ($action) { // ---------------------------------------------------------------- lesen case 'projects': Http::ok(['projects' => $repo->getProjects()]); // no break - Http::ok beendet die Anfrage case 'stats': $slug = Http::str('project_slug') ?? $boundProject; Http::ok(['stats' => $repo->getStats($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']); Http::ok(['item' => $item]); case 'list': $filters = collectFilters($boundProject); $result = $repo->getItems($filters); Http::ok([ 'count' => count($result['items']), 'total' => $result['total'], 'limit' => $result['limit'], 'offset' => $result['offset'], 'has_more' => $result['has_more'], 'filters' => $filters, 'items' => $result['items'], ]); // -------------------------------------------------------------- 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); $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); assertProject($repo, $context, $itemId); 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); $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); assertProject($repo, $context, $itemId); $status = Http::str('status'); if ($status === null) { Http::fail(400, 'missing_status', 'Das Feld "status" fehlt.', null, [ 'allowed' => BugRepo::STATUSES, ]); } if (!$repo->updateStatus($itemId, $status, Http::str('notes'), $author)) { 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 'update': requireItemId($itemId); assertProject($repo, $context, $itemId); if (!$repo->updateItemDetails($itemId, Http::body(), $author)) { 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. */ function assertProject(BugRepo $repo, array $context, int $itemId): void { if (ApiAuth::projectFilter($context) === null) { return; } $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']); } /** * Sammelt Filter aus Query und Body. * * @return array */ function collectFilters(?string $boundProject): array { $filters = [ 'project_slug' => Http::str('project_slug') ?? Http::str('project') ?? '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), ]; if (Http::input('unclaimed_only') !== null) { $filters['unclaimed_only'] = filter_var(Http::input('unclaimed_only'), FILTER_VALIDATE_BOOLEAN); } // Ein projektgebundenes Token kann den Projektfilter nicht umgehen. if ($boundProject !== null) { $filters['project_slug'] = $boundProject; } return $filters; }