Files
Deploymentcenter Bot 087f4392a9 feat: UpdateAgent Selbstaktualisierung & kopflose Installation, .NET 10 Migration, Backend- & SDK-Fixes
- .NET 10 Migration: Alle .NET-Projekte auf net10.0 umgestellt (#459)
- Client-SDK: ResolveAgentPath bereinigt und DLL-Pruefung ergaenzt (#458)
- Packager: Commit-Ermittlung aus publishDir / Assembly InformationalVersion (#456)
- BuildInfo.targets: ExitCode-Abfang und Regex-Validierung gegen Git-Fehlertexte (#363)
- UpdateService: Re-Publish erhaelt bestehende Manifeste und Werte (#457)
- Bugtracker API: on_roadmap-Filter in manage?action=list durchgereicht (#349)
- Watchdog API: project_slug-Filter in GET /status ergaenzt (#420)
- UpdateService API: no_release_for_platform mit verfuegbaren Plattformen (#421)
- Bugtracker Core: Fehler-Normalisierung gegen zersplitterte error_hashes (#419)
- UpdateAgent: Kopfloser Installationsmodus via --answers (#89)
- Installer & Agent: Automatische Selbstaktualisierung beim Start
- Tooling: AGENTS.md, CLAUDE.md und Standalone-CLI scripts/dc.py hinzugefuegt
2026-09-02 23:43:37 +02:00

519 lines
20 KiB
PHP

<?php
/**
* Bugtracker Management-API - die Schnittstelle fuer Coding-Agenten.
*
* Basis: /api/bugtracker/v1/manage
*
* Lesen (Scope bugtracker:read):
* GET ?action=list Gefilterte Liste mit Pagination und Delta-Abfrage
* GET ?action=get&id=42 Einzelnes Item samt Kommentar-Historie
* GET ?action=stats Kennzahlen
* GET ?action=projects Projektliste
*
* Schreiben (Scope bugtracker:manage):
* POST ?action=claim&id=42 Item exklusiv uebernehmen
* POST ?action=next Naechste offene Items holen und uebernehmen
* POST ?action=release&id=42 Item wieder freigeben
* POST ?action=comment&id=42 Kommentar / Ermittlungsschritt anhaengen
* POST ?action=status&id=42 Status setzen
* POST ?action=update&id=42 Mehrere Felder aendern
* POST ?action=resolve&id=42 Als geloest markieren
* POST ?action=bulk_update Mehrere Items auf einmal aendern
*
* ROUTING-AENDERUNG: Die Zuordnung erfolgt jetzt ueber eine feste Aktionsliste.
* Zuvor wurde per str_contains() im Pfad gesucht, wodurch jede URL, die
* zufaellig "/status" oder "/update" enthielt, die Route uebernahm.
*/
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;
Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
const READ_ACTIONS = ['list', 'get', 'stats', 'projects'];
const WRITE_ACTIONS = ['claim', 'next', 'release', 'comment', 'confirm', 'status', 'update', 'resolve', 'bulk_update'];
// Welches Recht eine schreibende Aktion verlangt.
//
// Bis 2.7.1 war das eine einzige Antwort: bugtracker:manage fuer alles. Wer
// eine Nachpruefung bestaetigen durfte, durfte damit auch resolve, update,
// bulk_update und die Roadmap umschreiben - und ein Pruefagent, der beheben
// darf, faengt an zu beheben. Die Trennung stand nur in seinem Prompt.
//
// bugtracker:verify deckt ab, was ein Pruefstand braucht: ein Item
// uebernehmen, kommentieren, bestaetigen, zurueckgeben. Nicht mehr.
// bugtracker:manage schliesst verify ein (IMPLIED_SCOPES), vorhandene Token
// verlieren also nichts.
//
// "status" bleibt bewusst bei manage: Darueber liesse sich jeder Zustand
// setzen, auch "resolved" - das waere der Umweg um genau diese Trennung.
// Traegt ein Fix nicht, ist die Antwort des Pruefenden eine erneute Meldung
// (bugtracker:report, in verify enthalten); das Item wechselt dann von selbst
// nach fix_insufficient.
const ACTION_SCOPES = [
'claim' => '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/<id>).');
}
}
/**
* 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<string,mixed>
*/
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;
}