Sammelcommit fuer den ueber mehrere Sitzungen gewachsenen, bisher nicht
eingecheckten Stand. Getrennte Commits waeren ehrlicher gewesen, sind es
nachtraeglich aber nicht mehr: BugRepo, changelog.json und die Doku tragen
Aenderungen aus allen Straengen gleichzeitig, und keine Zwischenstufe waere
lauffaehig.
ROADMAP IM DEPLOYMENTCENTER (2.7.0)
Planungsstand, Reihenfolge und Zustaendigkeit lagen in handgepflegten
Markdown-Dateien neben einem Bugtracker, der genau diese drei Dinge bereits
fuehrt. Zwei Pfleger fuer dasselbe Feld - Drift war eine Frage der Zeit.
Ab jetzt: Zustand im DC, Begruendung in der Datei. Kein Feld hat zwei Pfleger.
- Migration 016: epic, roadmap_rank, target_build, defer_trigger,
doc_anchor, Status "deferred", Tabelle roadmap_epics.
KEINE Roadmap-Tabelle - ein Eintrag ist ein bugtracker_item.
- Zugehoerigkeit ist ein Feld: Ein Item steht auf der Roadmap, wenn es ein
epic hat. Ein zweites Kennzeichen daneben koennte auseinanderlaufen.
- Kennung ist die Item-ID. Handvergebene Kuerzel ("A12") erzeugen die
schlimmste Fehlerart: Beide Seiten stimmig, Verschiedenes gemeint, weil
die Kennung nach einer Loeschung neu vergeben wurde.
- Zurueckstellen verlangt defer_trigger. Ohne benannten Anlass ist ein
Vorhaben nicht zurueckgestellt, sondern vergessen. Die Regel sitzt im
Repository, damit sie auf allen drei Schreibwegen gleich greift.
- Modules/Bugtracker/Roadmap.php, /api/bugtracker/v1/roadmap.php,
WebUI-Modul mit Filtern, cli/roadmap.php --write/--check,
dc_roadmap und dc_roadmap_set im MCP-Adapter.
- cli/roadmap.php --write ERZEUGT den Zustandsteil der Markdown-Datei;
--check prueft nur die Prosa. Was erzeugt wird, kann nicht driften.
- docs/ROADMAP_MIGRATION.md: Uebernahme fuer die anderen Projekte.
TESTINSTANZ UND TESTTOR
- dc-dev.mhdf.de: zweite Installation, eigene DB, eigene Schluessel,
Basic-Auth davor, Rocket.Chat aus.
- 124 Testfaelle in tests/e2e gegen API, Rechte, Migrationen, Sperre.
- scripts/deploy.py bringt vor jedem Upload erst den anstehenden Stand auf
die Testinstanz und laesst dann den Testlauf laufen. Erst bei
Rueckgabewert 0 geht etwas auf die Produktion (--ohne-test als Notausgang).
Andersherum prueft der Lauf aelteren Code als den, der gleich hochgeht.
- tests/ ist aus dem Produktiv-Deploy ausgeschlossen; testctl.php kann
nicht versehentlich auf dc.mhdf.de landen.
- Ein Lauf mit mehr uebersprungenen als geprueften Faellen endet mit
Rueckgabewert 2 statt 0. Nichts ist kaputt, geprueft wurde es aber auch
nicht - der Unterschied darf nicht am Rueckgabewert verschwinden.
SUPERVISOR, PROJEKTLANDSCHAFT, NACHPRUEFUNG (2.6.0/2.6.1)
- Migration 013: projektuebergreifend melden (dev_agent, evidence,
triage_external, affects).
- Migration 014: supervisor_findings, Katalog, fuenf serverseitige
Detektoren im Tick.
- Migration 015: Nachpruefung als Arbeitsschlange statt Benachrichtigung.
- DEV-Lizenzschluessel zur Selbstbedienung, dc-testkit fuer die Test-VM.
- MCP-Adapter: 15 Werkzeuge, ohne Fremdabhaengigkeiten, mit Abgrenzung
von Fremddaten gegen Prompt-Injektion.
BEHOBEN
- observe und status im Supervisor legten den Zustand des Befundes als
"status" in die Antwort. Http::ok() merged ['status' => 'success']
darunter, also gewann der Befundzustand - eine angenommene Meldung kam
als status="open" an. Heisst jetzt finding_status. War Item #59.
Migrationen 013-016 sind auf dem Produktivserver noch NICHT ausgefuehrt.
Ohne sie bricht das WebUI, weil die Roadmap-Ansicht bei jedem Seitenaufruf
gegen bugtracker_items.epic abfragt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
414 lines
15 KiB
PHP
414 lines
15 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', 'status', 'update', 'resolve', 'bulk_update'];
|
|
|
|
$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 ? '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);
|
|
|
|
$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.
|
|
$slug = $readProject ?? Http::str('project_slug');
|
|
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'], true);
|
|
Http::ok(['item' => $item]);
|
|
|
|
case 'list':
|
|
$filters = collectFilters($readProject);
|
|
$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);
|
|
|
|
// 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 '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.
|
|
*
|
|
* @return array<string,mixed>
|
|
*/
|
|
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;
|
|
}
|