Tickets aus mehreren Codeberg-Projekten anzeigen (monitor/Steuerkram/nc-gantt)
Der Codeberg-Sync liest nun Issues aus mehreren konfigurierbaren Projekten (Config::codebergRepos(), Default: fds2610/monitor gelb, fds2610/Steuerkram gruen, fds2610/nc-gantt blau) statt nur aus einem einzigen Repo. Damit external_ref (Issue-Nummer) projektuebergreifend nicht kollidiert, ist es jetzt zusammen mit dem neuen Ticket-Feld "repo" eindeutig; Migration 002_multi_repo_tickets.sql ergaenzt die Spalte und den Index. Da Migrationen dadurch nicht mehr rein additiv sind, trackt Database::migrate() jetzt angewandte Dateien in schema_migrations, damit ALTER TABLE nicht bei jedem Prozessstart erneut laeuft. Das Frontend baut Codeberg-Links jetzt aus dem repo-Feld jedes Tickets statt aus einer global fixen owner/repo-Angabe. Schliesst #26
This commit is contained in:
parent
523e8a4dcf
commit
3aaf6aea54
14 changed files with 250 additions and 104 deletions
|
|
@ -11,9 +11,12 @@ MONITOR_ADMIN_API_KEY=change-me
|
|||
# die relative URL /api/v1 verwendet).
|
||||
MONITOR_API_BASE=http://localhost:8091/api/v1
|
||||
|
||||
# Codeberg-Repo, aus dem Backlog/Done per Sync-Skript befuellt werden
|
||||
CODEBERG_OWNER=fds2610
|
||||
CODEBERG_REPO=monitor
|
||||
# Codeberg-Projekte, aus denen Backlog/Done per Sync-Skript befuellt werden. Leer lassen fuer
|
||||
# die Default-Projekte (fds2610/monitor gelb, fds2610/Steuerkram gruen, fds2610/nc-gantt
|
||||
# blau, siehe Config::codebergRepos()). Eigenes Format bei Bedarf:
|
||||
# "owner/repo:farbe,owner/repo:farbe,..." (Farbe mit oder ohne fuehrendes '#'), z.B.
|
||||
# CODEBERG_REPOS=fds2610/monitor:fbca04,fds2610/Steuerkram:28a745,fds2610/nc-gantt:0366d6
|
||||
CODEBERG_REPOS=
|
||||
# Optional: Token mit Lesezugriff, falls Rate-Limits ohne Token zu knapp sind - bei kleinem
|
||||
# MONITOR_SYNC_INTERVAL (haeufigem Sync) empfehlenswert
|
||||
CODEBERG_TOKEN=
|
||||
|
|
|
|||
35
API.md
35
API.md
|
|
@ -134,11 +134,16 @@ Response 200: aktualisiertes Agent-Objekt.
|
|||
Spalten (F3): `backlog`, `bereit`, `in_arbeit`, `qa`, `testing`, `fertig`, `done`.
|
||||
|
||||
- `backlog`, `bereit`, `in_arbeit`, `testing` und `done` werden regelmaessig aus
|
||||
Codeberg-Issues befuellt (siehe [Codeberg-Sync](#codeberg-sync-backlogdone)); `qa` und
|
||||
`fertig` sind fuer zukuenftige Labels reserviert und ergeben sich ausschliesslich aus
|
||||
Agenten-Meldungen (F4).
|
||||
- Ticket-Typ und Farbe (statt Prioritaet) werden bei Codeberg-Tickets aus dem ersten
|
||||
Issue-Label uebernommen (F4).
|
||||
Codeberg-Issues aus mehreren konfigurierten Projekten befuellt (siehe
|
||||
[Codeberg-Sync](#codeberg-sync-backlogdone)); `qa` und `fertig` sind fuer zukuenftige
|
||||
Labels reserviert und ergeben sich ausschliesslich aus Agenten-Meldungen (F4).
|
||||
- Ticket-Typ (statt Prioritaet) wird bei Codeberg-Tickets aus dem ersten Issue-Label
|
||||
uebernommen, die Farbe dagegen fest aus dem Quell-Projekt (F4/F26) - so bleiben Tickets aus
|
||||
unterschiedlichen Codeberg-Projekten auf einen Blick unterscheidbar, unabhaengig davon,
|
||||
welche Labels dort jeweils existieren.
|
||||
- `repo` (Format `owner/name`) identifiziert bei Codeberg-Tickets das Quell-Projekt und macht
|
||||
`external_ref` (die Issue-Nummer) nur je Projekt eindeutig - Issue #5 in `fds2610/monitor`
|
||||
und Issue #5 in `fds2610/Steuerkram` sind unterschiedliche Tickets.
|
||||
- Stapel-Darstellung ("aelteste 4 + Rest als Stapel", F4) ist reine Frontend-Logik;
|
||||
die API liefert Tickets dafuer sortiert nach Spalte und Alter.
|
||||
|
||||
|
|
@ -146,7 +151,7 @@ Spalten (F3): `backlog`, `bereit`, `in_arbeit`, `qa`, `testing`, `fertig`, `done
|
|||
|
||||
Query-Parameter: `column` (optional, filtert auf eine Spalte).
|
||||
|
||||
Response: `{ "tickets": [ { "id", "external_ref", "title", "description", "column_name", "type", "color", "assignee_agent_id", "source", "created_at", "updated_at" }, ... ] }`
|
||||
Response: `{ "tickets": [ { "id", "external_ref", "repo", "title", "description", "column_name", "type", "color", "assignee_agent_id", "source", "created_at", "updated_at" }, ... ] }`
|
||||
|
||||
### `POST /api/v1/tickets` — Ticket anlegen
|
||||
|
||||
|
|
@ -265,8 +270,10 @@ schliessen.
|
|||
|
||||
## Codeberg-Sync (Backlog/Done)
|
||||
|
||||
`api/bin/sync-codeberg.php` liest offene und geschlossene Issues aus dem konfigurierten
|
||||
Codeberg-Repo (`CODEBERG_OWNER`/`CODEBERG_REPO`) und gleicht sie ueber `external_ref`
|
||||
`api/bin/sync-codeberg.php` liest offene und geschlossene Issues aus den konfigurierten
|
||||
Codeberg-Projekten (`CODEBERG_REPOS`, siehe `.env.example` - ohne Override die drei
|
||||
Default-Projekte `fds2610/monitor` (gelb), `fds2610/Steuerkram` (gruen) und
|
||||
`fds2610/nc-gantt` (blau)) und gleicht sie je Projekt ueber `repo` + `external_ref`
|
||||
(= Issue-Nummer) mit den Tickets ab:
|
||||
|
||||
- Offene Issues → Spalte je nach Label: `wip` → `in_arbeit`, sonst `testing` → `testing`,
|
||||
|
|
@ -275,7 +282,7 @@ Codeberg-Repo (`CODEBERG_OWNER`/`CODEBERG_REPO`) und gleicht sie ueber `external
|
|||
- Nur falls das Ticket noch in einer der Sync-Spalten (`backlog`, `bereit`, `in_arbeit`,
|
||||
`testing`, `done`) ist - auf `qa`/`fertig` manuell verschobene Tickets werden nicht
|
||||
zurueckgesetzt.
|
||||
- Ticket-Typ/-Farbe kommen vom ersten Issue-Label (Name/Farbe).
|
||||
- Ticket-Typ kommt vom ersten Issue-Label, die Farbe fest vom Projekt (siehe oben).
|
||||
|
||||
Ausgefuehrt wird das Skript vom Compose-Service `sync` (siehe
|
||||
[docker-compose.yml](docker-compose.yml)) - kein Host-Cron noetig. Der Service startet mit
|
||||
|
|
@ -312,8 +319,14 @@ erfolgreichen Lauf gilt sofort wieder das regulaere Intervall. Im Log sieht das
|
|||
Damit Issue-Aenderungen nicht erst beim naechsten periodischen Lauf auf dem Board landen,
|
||||
kann Codeberg jede Aenderung aktiv melden. Der Endpunkt wertet den Payload **nicht** aus, er
|
||||
fordert nur einen regulaeren Sync an; der `sync`-Service greift die Anforderung im
|
||||
Sekundentakt auf und laedt den aktuellen Stand. Vom Aendern des Issues bis zum Board
|
||||
vergehen so wenige Sekunden (Webhook → Sync → SSE-Push ans Frontend).
|
||||
Sekundentakt auf und laedt den aktuellen Stand **aller** konfigurierten Projekte
|
||||
(`CODEBERG_REPOS`), nicht nur das des ausloesenden Repos. Vom Aendern des Issues bis zum
|
||||
Board vergehen so wenige Sekunden (Webhook → Sync → SSE-Push ans Frontend).
|
||||
|
||||
Bei mehreren Projekten muss der Webhook in **jedem** Repo eingerichtet werden, dessen
|
||||
Aenderungen sofort statt erst beim naechsten periodischen Lauf ankommen sollen - ein
|
||||
fehlender Webhook auf einem der Projekte faengt der periodische Sync (`MONITOR_SYNC_INTERVAL`)
|
||||
weiterhin ab.
|
||||
|
||||
Authentifizierung ist **kein** API-Key, sondern die HMAC-Signatur, die Codeberg selbst
|
||||
mitschickt: `X-Forgejo-Signature` (bzw. `X-Gitea-Signature`) enthaelt den HMAC-SHA256 des
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -22,11 +22,11 @@ die dazugehörigen Klärungen in [Detailfragen.md](Detailfragen.md) und
|
|||
ersatzweise Demo-Daten — kenntlich gemacht durch einen roten Banner „API nicht erreichbar!".
|
||||
Die Basis-URL der API kommt aus `MONITOR_API_BASE` (siehe `.env.example`).
|
||||
- Codeberg-Sync als Dauerläufer (Compose-Service `sync`): füllt Backlog und Done automatisch
|
||||
aus den Issues des konfigurierten Codeberg-Repos — periodisch alle `MONITOR_SYNC_INTERVAL`
|
||||
Sekunden und, wenn ein Webhook eingerichtet ist, innerhalb von Sekunden nach jeder
|
||||
Issue-Änderung (`POST /api/v1/webhooks/codeberg`, HMAC-signiert per
|
||||
`CODEBERG_WEBHOOK_SECRET`). Siehe [API.md](API.md) Abschnitt „Codeberg-Sync" —
|
||||
dort steht auch die Einrichtung im Codeberg-Repo.
|
||||
aus den Issues mehrerer konfigurierter Codeberg-Projekte (`CODEBERG_REPOS`, je Projekt mit
|
||||
eigener Ticket-Farbe) — periodisch alle `MONITOR_SYNC_INTERVAL` Sekunden und, wenn ein
|
||||
Webhook eingerichtet ist, innerhalb von Sekunden nach jeder Issue-Änderung
|
||||
(`POST /api/v1/webhooks/codeberg`, HMAC-signiert per `CODEBERG_WEBHOOK_SECRET`). Siehe
|
||||
[API.md](API.md) Abschnitt „Codeberg-Sync" — dort steht auch die Einrichtung im Codeberg-Repo.
|
||||
- Weiterhin: klassische Ping-Überwachung (`check_fds.sh`, `hosts.conf`, `mail_fds.py`) als
|
||||
eine Datenquelle für den System-Monitor; die alte tabellarische Statusseite ist mit
|
||||
Phase 7 entfallen (F16), `status.txt` speist weiterhin den System-Monitor
|
||||
|
|
|
|||
|
|
@ -19,8 +19,7 @@ $db = Database::forFile($config->dbPath());
|
|||
$service = new CodebergSyncService(
|
||||
new CurlCodebergClient($config->codebergApiBase(), $config->codebergToken()),
|
||||
new TicketRepository($db),
|
||||
$config->codebergOwner(),
|
||||
$config->codebergRepo(),
|
||||
$config->codebergRepos(),
|
||||
);
|
||||
|
||||
// Ohne --watch bleibt das Skript ein einmaliger Lauf (manueller Aufruf, siehe README).
|
||||
|
|
|
|||
|
|
@ -54,14 +54,49 @@ final class Config
|
|||
return rtrim((string) $this->get('CODEBERG_API_BASE', 'https://codeberg.org/api/v1'), '/');
|
||||
}
|
||||
|
||||
public function codebergOwner(): string
|
||||
/**
|
||||
* Default-Projekte fuer den Codeberg-Sync (Issue #26: "Tickets auch von anderen
|
||||
* Codeberg-Projekten anzeigen"). Jedes Projekt bekommt eine feste Farbe, an der sich
|
||||
* Tickets auf dem Board unabhaengig vom Issue-Label ihrem Ursprungsprojekt zuordnen
|
||||
* lassen.
|
||||
*/
|
||||
private const DEFAULT_REPOS = [
|
||||
['owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04'],
|
||||
['owner' => 'fds2610', 'repo' => 'Steuerkram', 'color' => '#28a745'],
|
||||
['owner' => 'fds2610', 'repo' => 'nc-gantt', 'color' => '#0366d6'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Codeberg-Projekte, aus denen der Sync Tickets liest. Override per
|
||||
* CODEBERG_REPOS="owner/repo:farbe,owner/repo:farbe,..." (Farbe mit oder ohne
|
||||
* fuehrendes "#"); ohne gesetzte/gueltige Variable gelten die drei Default-Projekte.
|
||||
*
|
||||
* @return array<int, array{owner: string, repo: string, color: string}>
|
||||
*/
|
||||
public function codebergRepos(): array
|
||||
{
|
||||
return (string) $this->get('CODEBERG_OWNER', 'fds2610');
|
||||
$raw = $this->get('CODEBERG_REPOS');
|
||||
if ($raw === null) {
|
||||
return self::DEFAULT_REPOS;
|
||||
}
|
||||
|
||||
public function codebergRepo(): string
|
||||
{
|
||||
return (string) $this->get('CODEBERG_REPO', 'monitor');
|
||||
$repos = [];
|
||||
foreach (explode(',', $raw) as $entry) {
|
||||
$entry = trim($entry);
|
||||
if ($entry === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$slug, $color] = array_pad(explode(':', $entry, 2), 2, null);
|
||||
[$owner, $repo] = array_pad(explode('/', (string) $slug, 2), 2, null);
|
||||
if ($owner === null || $owner === '' || $repo === null || $repo === '' || $color === null || $color === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$repos[] = ['owner' => $owner, 'repo' => $repo, 'color' => '#' . ltrim($color, '#')];
|
||||
}
|
||||
|
||||
return $repos === [] ? self::DEFAULT_REPOS : $repos;
|
||||
}
|
||||
|
||||
public function codebergToken(): ?string
|
||||
|
|
|
|||
|
|
@ -43,14 +43,36 @@ final class Database
|
|||
return $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuehrt jede Migration in migrations/ genau einmal aus (Tracking in schema_migrations).
|
||||
* Noetig, seit Migrationen nicht mehr rein additiv sind (z.B. ALTER TABLE ... ADD COLUMN
|
||||
* in 002_multi_repo_tickets.sql) - ein erneutes exec() bei jedem Request/Test-Setup
|
||||
* (forFile()/inMemory() rufen migrate() bei jeder Instanziierung auf) wuerde sonst ab
|
||||
* dem zweiten Aufruf mit "duplicate column name" abbrechen.
|
||||
*/
|
||||
public function migrate(): void
|
||||
{
|
||||
$this->pdo->exec(
|
||||
'CREATE TABLE IF NOT EXISTS schema_migrations (filename TEXT PRIMARY KEY, applied_at TEXT NOT NULL)'
|
||||
);
|
||||
$applied = $this->pdo->query('SELECT filename FROM schema_migrations')->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
$migrationsDir = __DIR__ . '/migrations';
|
||||
$files = glob($migrationsDir . '/*.sql') ?: [];
|
||||
sort($files);
|
||||
|
||||
$markApplied = $this->pdo->prepare(
|
||||
'INSERT INTO schema_migrations (filename, applied_at) VALUES (:filename, :applied_at)'
|
||||
);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$name = basename($file);
|
||||
if (in_array($name, $applied, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->pdo->exec((string) file_get_contents($file));
|
||||
$markApplied->execute(['filename' => $name, 'applied_at' => $this->now()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
11
api/src/Db/migrations/002_multi_repo_tickets.sql
Normal file
11
api/src/Db/migrations/002_multi_repo_tickets.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- F26: Tickets aus mehreren Codeberg-Projekten unterscheiden. "repo" speichert das
|
||||
-- Quell-Repo als "owner/name" und macht external_ref nur noch je Repo eindeutig - Issue #5
|
||||
-- in fds2610/monitor und Issue #5 in fds2610/Steuerkram sind zwei unterschiedliche Tickets.
|
||||
ALTER TABLE tickets ADD COLUMN repo TEXT;
|
||||
|
||||
-- Vor dieser Migration gab es nur den einen synchronisierten Repo.
|
||||
UPDATE tickets SET repo = 'fds2610/monitor' WHERE source = 'codeberg' AND repo IS NULL;
|
||||
|
||||
DROP INDEX IF EXISTS idx_tickets_external_ref;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_repo_external_ref ON tickets (repo, external_ref)
|
||||
WHERE external_ref IS NOT NULL;
|
||||
|
|
@ -33,11 +33,12 @@ final class TicketRepository
|
|||
|
||||
$now = $this->db->now();
|
||||
$stmt = $this->db->pdo()->prepare(
|
||||
'INSERT INTO tickets (external_ref, title, description, column_name, type, color, assignee_agent_id, source, created_at, updated_at)
|
||||
VALUES (:external_ref, :title, :description, :column_name, :type, :color, :assignee_agent_id, :source, :created_at, :updated_at)'
|
||||
'INSERT INTO tickets (external_ref, repo, title, description, column_name, type, color, assignee_agent_id, source, created_at, updated_at)
|
||||
VALUES (:external_ref, :repo, :title, :description, :column_name, :type, :color, :assignee_agent_id, :source, :created_at, :updated_at)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'external_ref' => $data['external_ref'] ?? null,
|
||||
'repo' => $data['repo'] ?? null,
|
||||
'title' => $data['title'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'column_name' => $column,
|
||||
|
|
@ -101,11 +102,17 @@ final class TicketRepository
|
|||
return $row === false ? null : $row;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
public function findByExternalRef(string $externalRef): ?array
|
||||
/**
|
||||
* external_ref (Issue-Nummer) ist nur je Repo eindeutig (F26: mehrere Codeberg-Projekte),
|
||||
* daher zusaetzlich ueber "repo" ("owner/name") gescoped. $repo bleibt optional/null fuer
|
||||
* Tickets ohne Codeberg-Projektbezug.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findByExternalRef(string $externalRef, ?string $repo = null): ?array
|
||||
{
|
||||
$stmt = $this->db->pdo()->prepare('SELECT * FROM tickets WHERE external_ref = :ref');
|
||||
$stmt->execute(['ref' => $externalRef]);
|
||||
$stmt = $this->db->pdo()->prepare('SELECT * FROM tickets WHERE external_ref = :ref AND repo IS :repo');
|
||||
$stmt->execute(['ref' => $externalRef, 'repo' => $repo]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return $row === false ? null : $row;
|
||||
|
|
@ -143,7 +150,7 @@ final class TicketRepository
|
|||
*/
|
||||
public function upsertFromCodeberg(array $data): array
|
||||
{
|
||||
$existing = $this->findByExternalRef((string) $data['external_ref']);
|
||||
$existing = $this->findByExternalRef((string) $data['external_ref'], $data['repo'] ?? null);
|
||||
if ($existing === null) {
|
||||
return $this->create($data + ['source' => 'codeberg']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,13 @@ namespace Monitor\Api\Sync;
|
|||
use Monitor\Api\Repository\TicketRepository;
|
||||
|
||||
/**
|
||||
* F4: "Backlog und Done werden regelmaessig von Codeberg ausgelesen." Offene Issues landen
|
||||
* (falls noch nicht vorhanden bzw. noch nicht manuell bewegt) je nach Label in Backlog,
|
||||
* Bereit, In Arbeit oder Testing, geschlossene in Done. Ticket-Typ und Farbe werden aus dem
|
||||
* ersten Codeberg-Label uebernommen (F4: "Ticket-Typen ... koennen mit den Codeberg-Farben
|
||||
* dargestellt werden").
|
||||
* F4/F26: Liest offene und geschlossene Issues aus mehreren konfigurierten Codeberg-Projekten
|
||||
* (Config::codebergRepos()) und legt daraus Tickets an bzw. aktualisiert sie. Offene Issues
|
||||
* landen (falls noch nicht vorhanden bzw. noch nicht manuell bewegt) je nach Label in
|
||||
* Backlog, Bereit, In Arbeit oder Testing, geschlossene in Done. Der Ticket-Typ (Badge-Text)
|
||||
* kommt vom ersten Codeberg-Label, die Ticket-Farbe dagegen fest vom Quell-Projekt (Issue
|
||||
* #26: "unterschiedliche Codeberg-Projekte sollen an fest zugeordneten Farben erkennbar
|
||||
* sein"), nicht mehr von der Label-Farbe.
|
||||
*/
|
||||
final class CodebergSyncService
|
||||
{
|
||||
|
|
@ -25,22 +27,33 @@ final class CodebergSyncService
|
|||
'automate' => 'bereit',
|
||||
];
|
||||
|
||||
/** @param array<int, array{owner: string, repo: string, color: string}> $repos */
|
||||
public function __construct(
|
||||
private readonly CodebergClient $client,
|
||||
private readonly TicketRepository $tickets,
|
||||
private readonly string $owner,
|
||||
private readonly string $repo,
|
||||
private readonly array $repos,
|
||||
) {
|
||||
}
|
||||
|
||||
public function sync(): void
|
||||
{
|
||||
foreach ($this->client->listIssues($this->owner, $this->repo, 'open') as $issue) {
|
||||
$this->tickets->upsertFromCodeberg($this->mapIssue($issue, $this->columnForOpenIssue($issue)));
|
||||
foreach ($this->repos as $repoConfig) {
|
||||
$this->syncRepo($repoConfig['owner'], $repoConfig['repo'], $repoConfig['color']);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->client->listIssues($this->owner, $this->repo, 'closed') as $issue) {
|
||||
$this->tickets->upsertFromCodeberg($this->mapIssue($issue, 'done'));
|
||||
private function syncRepo(string $owner, string $repo, string $color): void
|
||||
{
|
||||
$slug = $owner . '/' . $repo;
|
||||
|
||||
foreach ($this->client->listIssues($owner, $repo, 'open') as $issue) {
|
||||
$this->tickets->upsertFromCodeberg(
|
||||
$this->mapIssue($issue, $this->columnForOpenIssue($issue), $slug, $color)
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($this->client->listIssues($owner, $repo, 'closed') as $issue) {
|
||||
$this->tickets->upsertFromCodeberg($this->mapIssue($issue, 'done', $slug, $color));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -67,29 +80,18 @@ final class CodebergSyncService
|
|||
* @param array{number: int, title: string, body: ?string, labels: array<int, array{name: string, color: string}>} $issue
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapIssue(array $issue, string $column): array
|
||||
private function mapIssue(array $issue, string $column, string $repoSlug, string $color): array
|
||||
{
|
||||
$firstLabel = $issue['labels'][0] ?? null;
|
||||
|
||||
return [
|
||||
'external_ref' => (string) $issue['number'],
|
||||
'repo' => $repoSlug,
|
||||
'title' => $issue['title'],
|
||||
'description' => $issue['body'],
|
||||
'column' => $column,
|
||||
'type' => $firstLabel['name'] ?? null,
|
||||
'color' => self::normalizeColor($firstLabel['color'] ?? null),
|
||||
'color' => $color,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Codeberg liefert Label-Farben mal mit, mal ohne fuehrendes "#". Die Normalisierung
|
||||
* gehoert hierher und nicht in den Client, damit jede CodebergClient-Implementierung
|
||||
* dieselbe CSS-taugliche Farbe im Ticket ablegt.
|
||||
*/
|
||||
private static function normalizeColor(?string $color): ?string
|
||||
{
|
||||
$color = trim((string) $color);
|
||||
|
||||
return $color === '' ? null : '#' . ltrim($color, '#');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,11 @@ final class ConfigTest extends TestCase
|
|||
$config = new Config([]);
|
||||
|
||||
self::assertSame('https://codeberg.org/api/v1', $config->codebergApiBase());
|
||||
self::assertSame('fds2610', $config->codebergOwner());
|
||||
self::assertSame('monitor', $config->codebergRepo());
|
||||
self::assertSame([
|
||||
['owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04'],
|
||||
['owner' => 'fds2610', 'repo' => 'Steuerkram', 'color' => '#28a745'],
|
||||
['owner' => 'fds2610', 'repo' => 'nc-gantt', 'color' => '#0366d6'],
|
||||
], $config->codebergRepos());
|
||||
self::assertNull($config->codebergToken());
|
||||
self::assertNull($config->adminApiKey());
|
||||
self::assertSame(8, $config->deskCount());
|
||||
|
|
@ -43,8 +46,7 @@ final class ConfigTest extends TestCase
|
|||
{
|
||||
$config = new Config([
|
||||
'CODEBERG_API_BASE' => 'https://example.org/api/v1/',
|
||||
'CODEBERG_OWNER' => 'someone',
|
||||
'CODEBERG_REPO' => 'somerepo',
|
||||
'CODEBERG_REPOS' => 'someone/somerepo:ff00aa, other/thing:00ff00 ,malformed',
|
||||
'CODEBERG_TOKEN' => 'tok',
|
||||
'MONITOR_ADMIN_API_KEY' => 'secret',
|
||||
'MONITOR_DESK_COUNT' => '3',
|
||||
|
|
@ -53,12 +55,25 @@ final class ConfigTest extends TestCase
|
|||
]);
|
||||
|
||||
self::assertSame('https://example.org/api/v1', $config->codebergApiBase());
|
||||
self::assertSame('someone', $config->codebergOwner());
|
||||
self::assertSame('somerepo', $config->codebergRepo());
|
||||
self::assertSame([
|
||||
['owner' => 'someone', 'repo' => 'somerepo', 'color' => '#ff00aa'],
|
||||
['owner' => 'other', 'repo' => 'thing', 'color' => '#00ff00'],
|
||||
], $config->codebergRepos());
|
||||
self::assertSame('tok', $config->codebergToken());
|
||||
self::assertSame('secret', $config->adminApiKey());
|
||||
self::assertSame(3, $config->deskCount());
|
||||
self::assertSame(5, $config->sseMaxIterations());
|
||||
self::assertSame(1, $config->ssePollIntervalSeconds());
|
||||
}
|
||||
|
||||
public function testCodebergReposFallsBackToDefaultsWhenOverrideIsUnusable(): void
|
||||
{
|
||||
$config = new Config(['CODEBERG_REPOS' => 'nur-ein-slug-ohne-doppelpunkt']);
|
||||
|
||||
self::assertSame([
|
||||
['owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04'],
|
||||
['owner' => 'fds2610', 'repo' => 'Steuerkram', 'color' => '#28a745'],
|
||||
['owner' => 'fds2610', 'repo' => 'nc-gantt', 'color' => '#0366d6'],
|
||||
], $config->codebergRepos());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,23 +12,32 @@ use PHPUnit\Framework\TestCase;
|
|||
|
||||
final class FakeCodebergClient implements CodebergClient
|
||||
{
|
||||
/** @param array<string, array<int, array{number:int,title:string,body:?string,labels:array}>> $issuesByState */
|
||||
public function __construct(private readonly array $issuesByState)
|
||||
/** @param array<string, array<string, array<int, array{number:int,title:string,body:?string,labels:array}>>> $issuesByRepoAndState */
|
||||
public function __construct(private readonly array $issuesByRepoAndState)
|
||||
{
|
||||
}
|
||||
|
||||
public function listIssues(string $owner, string $repo, string $state): array
|
||||
{
|
||||
return $this->issuesByState[$state] ?? [];
|
||||
return $this->issuesByRepoAndState[$owner . '/' . $repo][$state] ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
final class CodebergSyncServiceTest extends TestCase
|
||||
{
|
||||
public function testSyncCreatesBacklogAndDoneTickets(): void
|
||||
private const MONITOR_REPO = ['owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04'];
|
||||
private const MONITOR_SLUG = 'fds2610/monitor';
|
||||
|
||||
/** @param array<string, array<int, array{number:int,title:string,body:?string,labels:array}>> $issuesByState */
|
||||
private static function clientForMonitor(array $issuesByState): FakeCodebergClient
|
||||
{
|
||||
return new FakeCodebergClient([self::MONITOR_SLUG => $issuesByState]);
|
||||
}
|
||||
|
||||
public function testSyncCreatesBacklogAndDoneTicketsColoredByProject(): void
|
||||
{
|
||||
$tickets = new TicketRepository(Database::inMemory());
|
||||
$client = new FakeCodebergClient([
|
||||
$client = self::clientForMonitor([
|
||||
'open' => [
|
||||
['number' => 1, 'title' => 'Offenes Issue', 'body' => 'Beschreibung', 'labels' => [['name' => 'bug', 'color' => 'ff0000']]],
|
||||
],
|
||||
|
|
@ -37,7 +46,7 @@ final class CodebergSyncServiceTest extends TestCase
|
|||
],
|
||||
]);
|
||||
|
||||
(new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync();
|
||||
(new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]))->sync();
|
||||
|
||||
$backlog = $tickets->all('backlog');
|
||||
$done = $tickets->all('done');
|
||||
|
|
@ -45,46 +54,64 @@ final class CodebergSyncServiceTest extends TestCase
|
|||
self::assertCount(1, $backlog);
|
||||
self::assertSame('Offenes Issue', $backlog[0]['title']);
|
||||
self::assertSame('bug', $backlog[0]['type']);
|
||||
self::assertSame('#ff0000', $backlog[0]['color']);
|
||||
self::assertSame('#fbca04', $backlog[0]['color']);
|
||||
self::assertSame(self::MONITOR_SLUG, $backlog[0]['repo']);
|
||||
self::assertSame('codeberg', $backlog[0]['source']);
|
||||
|
||||
self::assertCount(1, $done);
|
||||
self::assertSame('Geschlossenes Issue', $done[0]['title']);
|
||||
self::assertNull($done[0]['type']);
|
||||
self::assertNull($done[0]['color']);
|
||||
self::assertSame('#fbca04', $done[0]['color']);
|
||||
}
|
||||
|
||||
public function testSyncNormalizesLabelColorRegardlessOfLeadingHash(): void
|
||||
public function testSyncKeepsIssuesFromDifferentProjectsSeparateAndColorsThemPerProject(): void
|
||||
{
|
||||
$tickets = new TicketRepository(Database::inMemory());
|
||||
$client = new FakeCodebergClient([
|
||||
'open' => [
|
||||
['number' => 1, 'title' => 'Mit Raute', 'body' => null, 'labels' => [['name' => 'bug', 'color' => '#00ff00']]],
|
||||
],
|
||||
'fds2610/monitor' => [
|
||||
'open' => [['number' => 1, 'title' => 'Monitor-Issue 1', 'body' => null, 'labels' => []]],
|
||||
'closed' => [],
|
||||
],
|
||||
'fds2610/Steuerkram' => [
|
||||
'open' => [['number' => 1, 'title' => 'Steuerkram-Issue 1', 'body' => null, 'labels' => []]],
|
||||
'closed' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
(new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync();
|
||||
(new CodebergSyncService($client, $tickets, [
|
||||
self::MONITOR_REPO,
|
||||
['owner' => 'fds2610', 'repo' => 'Steuerkram', 'color' => '#28a745'],
|
||||
]))->sync();
|
||||
|
||||
self::assertSame('#00ff00', $tickets->all('backlog')[0]['color']);
|
||||
$backlog = $tickets->all('backlog');
|
||||
self::assertCount(2, $backlog, 'gleiche Issue-Nummer aus verschiedenen Projekten darf nicht zu einem Ticket verschmelzen');
|
||||
|
||||
$monitorTicket = $tickets->findByExternalRef('1', 'fds2610/monitor');
|
||||
$steuerkramTicket = $tickets->findByExternalRef('1', 'fds2610/Steuerkram');
|
||||
|
||||
self::assertSame('Monitor-Issue 1', $monitorTicket['title']);
|
||||
self::assertSame('#fbca04', $monitorTicket['color']);
|
||||
|
||||
self::assertSame('Steuerkram-Issue 1', $steuerkramTicket['title']);
|
||||
self::assertSame('#28a745', $steuerkramTicket['color']);
|
||||
}
|
||||
|
||||
public function testSyncDoesNotResetManuallyMovedTicket(): void
|
||||
{
|
||||
$tickets = new TicketRepository(Database::inMemory());
|
||||
$client = new FakeCodebergClient([
|
||||
$client = self::clientForMonitor([
|
||||
'open' => [['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => []]],
|
||||
'closed' => [],
|
||||
]);
|
||||
$service = new CodebergSyncService($client, $tickets, 'fds2610', 'monitor');
|
||||
$service = new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]);
|
||||
|
||||
$service->sync();
|
||||
$ticket = $tickets->findByExternalRef('1');
|
||||
$ticket = $tickets->findByExternalRef('1', self::MONITOR_SLUG);
|
||||
$tickets->update((int) $ticket['id'], ['column' => 'qa']);
|
||||
|
||||
$service->sync();
|
||||
|
||||
$stillQa = $tickets->findByExternalRef('1');
|
||||
$stillQa = $tickets->findByExternalRef('1', self::MONITOR_SLUG);
|
||||
self::assertSame('qa', $stillQa['column_name']);
|
||||
}
|
||||
|
||||
|
|
@ -102,65 +129,65 @@ final class CodebergSyncServiceTest extends TestCase
|
|||
public function testSyncMapsOpenIssueLabelToBoardColumn(string $label, string $expectedColumn): void
|
||||
{
|
||||
$tickets = new TicketRepository(Database::inMemory());
|
||||
$client = new FakeCodebergClient([
|
||||
$client = self::clientForMonitor([
|
||||
'open' => [
|
||||
['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => $label, 'color' => 'ff0000']]],
|
||||
],
|
||||
'closed' => [],
|
||||
]);
|
||||
|
||||
(new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync();
|
||||
(new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]))->sync();
|
||||
|
||||
$ticket = $tickets->findByExternalRef('1');
|
||||
$ticket = $tickets->findByExternalRef('1', self::MONITOR_SLUG);
|
||||
self::assertSame($expectedColumn, $ticket['column_name']);
|
||||
}
|
||||
|
||||
public function testSyncMovesTicketBetweenAutomatedColumnsWhenLabelChanges(): void
|
||||
{
|
||||
$tickets = new TicketRepository(Database::inMemory());
|
||||
$client = new FakeCodebergClient([
|
||||
$client = self::clientForMonitor([
|
||||
'open' => [
|
||||
['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'automate', 'color' => 'ff0000']]],
|
||||
],
|
||||
'closed' => [],
|
||||
]);
|
||||
$service = new CodebergSyncService($client, $tickets, 'fds2610', 'monitor');
|
||||
$service = new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]);
|
||||
|
||||
$service->sync();
|
||||
self::assertSame('bereit', $tickets->findByExternalRef('1')['column_name']);
|
||||
self::assertSame('bereit', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']);
|
||||
|
||||
$client = new FakeCodebergClient([
|
||||
$client = self::clientForMonitor([
|
||||
'open' => [
|
||||
['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'wip', 'color' => 'ff0000']]],
|
||||
],
|
||||
'closed' => [],
|
||||
]);
|
||||
(new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync();
|
||||
(new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]))->sync();
|
||||
|
||||
self::assertSame('in_arbeit', $tickets->findByExternalRef('1')['column_name']);
|
||||
self::assertSame('in_arbeit', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']);
|
||||
}
|
||||
|
||||
public function testSyncClosesTicketFromAnyAutomatedColumnIntoDone(): void
|
||||
{
|
||||
$tickets = new TicketRepository(Database::inMemory());
|
||||
$client = new FakeCodebergClient([
|
||||
$client = self::clientForMonitor([
|
||||
'open' => [
|
||||
['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'testing', 'color' => 'ff0000']]],
|
||||
],
|
||||
'closed' => [],
|
||||
]);
|
||||
$service = new CodebergSyncService($client, $tickets, 'fds2610', 'monitor');
|
||||
$service = new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]);
|
||||
$service->sync();
|
||||
self::assertSame('testing', $tickets->findByExternalRef('1')['column_name']);
|
||||
self::assertSame('testing', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']);
|
||||
|
||||
$client = new FakeCodebergClient([
|
||||
$client = self::clientForMonitor([
|
||||
'open' => [],
|
||||
'closed' => [
|
||||
['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'testing', 'color' => 'ff0000']]],
|
||||
],
|
||||
]);
|
||||
(new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync();
|
||||
(new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]))->sync();
|
||||
|
||||
self::assertSame('done', $tickets->findByExternalRef('1')['column_name']);
|
||||
self::assertSame('done', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,8 +22,10 @@ services:
|
|||
MONITOR_SSE_POLL_INTERVAL: 2
|
||||
MONITOR_SYNC_TRIGGER_FILE: /app/data/sync-requested
|
||||
CODEBERG_API_BASE: https://codeberg.org/api/v1
|
||||
CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610}
|
||||
CODEBERG_REPO: ${CODEBERG_REPO:-monitor}
|
||||
# Leer = Default-Projekte aus Config::codebergRepos() (fds2610/monitor, .../Steuerkram,
|
||||
# .../nc-gantt, siehe API.md "Codeberg-Sync"). Override-Format:
|
||||
# "owner/repo:farbe,owner/repo:farbe,...".
|
||||
CODEBERG_REPOS: ${CODEBERG_REPOS:-}
|
||||
CODEBERG_TOKEN: ${CODEBERG_TOKEN:-}
|
||||
# Secret des Codeberg-Webhooks. Ohne diesen Wert weist POST /api/v1/webhooks/codeberg
|
||||
# jede Anfrage mit 401 ab - der Endpunkt ist aus dem Internet erreichbar und die
|
||||
|
|
@ -57,8 +59,7 @@ services:
|
|||
MONITOR_SYNC_INTERVAL: ${MONITOR_SYNC_INTERVAL:-600}
|
||||
MONITOR_SYNC_TRIGGER_FILE: /app/data/sync-requested
|
||||
CODEBERG_API_BASE: https://codeberg.org/api/v1
|
||||
CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610}
|
||||
CODEBERG_REPO: ${CODEBERG_REPO:-monitor}
|
||||
CODEBERG_REPOS: ${CODEBERG_REPOS:-}
|
||||
CODEBERG_TOKEN: ${CODEBERG_TOKEN:-}
|
||||
command: php bin/sync-codeberg.php --watch
|
||||
|
||||
|
|
|
|||
|
|
@ -147,7 +147,9 @@ export function groupTicketsByColumn(tickets = []) {
|
|||
/**
|
||||
* Baut das Inhaltsmodell fuer das Board-Spalten-Modal (Visualization.md 4.2/4.5): echter
|
||||
* Titel/Beschreibung je Ticket, plus Link auf die Codeberg-Quelle sofern das Ticket per
|
||||
* Codeberg-Sync angelegt wurde (F4).
|
||||
* Codeberg-Sync angelegt wurde (F4). Seit F26 (Tickets aus mehreren Codeberg-Projekten)
|
||||
* traegt jedes Ticket sein Quell-Repo ("owner/name") selbst in ticket.repo; opts dient nur
|
||||
* noch als Fallback fuer aeltere Tickets ohne dieses Feld.
|
||||
*
|
||||
* @param {string} columnKey
|
||||
* @param {Array<object>} tickets
|
||||
|
|
@ -167,7 +169,7 @@ export function buildTicketModalContent(columnKey, tickets = [], opts = {}) {
|
|||
color: ticket.color || null,
|
||||
link:
|
||||
ticket.source === "codeberg" && ticket.external_ref
|
||||
? `https://codeberg.org/${codebergOwner}/${codebergRepo}/issues/${ticket.external_ref}`
|
||||
? `https://codeberg.org/${ticket.repo || `${codebergOwner}/${codebergRepo}`}/issues/${ticket.external_ref}`
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -174,6 +174,15 @@ test("buildTicketModalContent respektiert benutzerdefinierten owner/repo", () =>
|
|||
assert.equal(content.items[0].link, "https://codeberg.org/acme/widgets/issues/7");
|
||||
});
|
||||
|
||||
test("buildTicketModalContent baut den Link aus dem repo-Feld des Tickets, nicht aus opts (mehrere Codeberg-Projekte, F26)", () => {
|
||||
const content = buildTicketModalContent("backlog", [
|
||||
{ id: 1, title: "A", source: "codeberg", external_ref: "3", repo: "fds2610/Steuerkram", color: "#28a745" },
|
||||
{ id: 2, title: "B", source: "codeberg", external_ref: "9", repo: "fds2610/nc-gantt", color: "#0366d6" },
|
||||
]);
|
||||
assert.equal(content.items[0].link, "https://codeberg.org/fds2610/Steuerkram/issues/3");
|
||||
assert.equal(content.items[1].link, "https://codeberg.org/fds2610/nc-gantt/issues/9");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Phase 4 - Monitor-Wand live
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue