Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aeb6c69a71 | ||
|
|
8ff526262d | ||
|
|
e1b71f5fb6 | ||
|
|
6c788b3e7d | ||
|
|
76c064bd31 | ||
|
|
abd9f73405 | ||
|
|
97ba863356 | ||
|
|
b062eb36a1 | ||
|
|
4ce26e1e8d | ||
|
|
9fb86fcc61 | ||
|
|
d11dd9c4ac | ||
|
|
3fd6e41e16 | ||
|
|
2d191a54bc | ||
|
|
27b4dd83b7 |
@@ -85,6 +85,16 @@ SSRF-Schutz (`lib/security/ssrf.ts`).
|
|||||||
`100vh` beziehen sich weiter auf das volle. `components/ui/Modal.tsx` hängt
|
`100vh` beziehen sich weiter auf das volle. `components/ui/Modal.tsx` hängt
|
||||||
dafür an `window.visualViewport`; wer ein eigenes Overlay baut, braucht
|
dafür an `window.visualViewport`; wer ein eigenes Overlay baut, braucht
|
||||||
dasselbe.
|
dasselbe.
|
||||||
|
- **Ein verschluckter Fehler ist schlimmer als ein lauter.** `getJsonSafe` machte aus
|
||||||
|
jedem Fehlschlag eine leere Liste. Damit war ein 403 nicht mehr von „das Repository
|
||||||
|
hat keine Branches" zu unterscheiden: Der Abgleich meldete Erfolg, überschrieb den
|
||||||
|
letzten guten Stand mit Nullen, und die Ursache war von außen nicht zu ermitteln —
|
||||||
|
die Auskunft, die sie benannt hätte, war ja gerade weggeworfen worden. Wo ein
|
||||||
|
Fallback nötig ist, gehört der Grund festgehalten (`Teilabrufe` in `lib/git/http.ts`).
|
||||||
|
- **`Number()` auf einer Umgebungsvariable prüft nichts.** `Number("")` ist `0`,
|
||||||
|
`Number("abc")` ist `NaN`, und `setTimeout` löst bei beidem sofort aus. Eine
|
||||||
|
verrutschte Zeile in der `.env` konnte so den kompletten Abgleich stilllegen, ohne
|
||||||
|
dass irgendwo etwas stand.
|
||||||
- **Blättern über Cursor, nicht über `skip`.** Der Aktivitätsverlauf schreibt
|
- **Blättern über Cursor, nicht über `skip`.** Der Aktivitätsverlauf schreibt
|
||||||
mehrere Zeilen im selben Augenblick; ein Offset über so eine Grenze hinweg
|
mehrere Zeilen im selben Augenblick; ein Offset über so eine Grenze hinweg
|
||||||
überspringt oder verdoppelt lautlos. Muster in `lib/timeline.ts`:
|
überspringt oder verdoppelt lautlos. Muster in `lib/timeline.ts`:
|
||||||
|
|||||||
+49
@@ -259,6 +259,19 @@ Blockiert · Erledigt**.
|
|||||||
vor Beschreibung, Wiederholung, Frist und Labels, damit sich eine Spalte danach
|
vor Beschreibung, Wiederholung, Frist und Labels, damit sich eine Spalte danach
|
||||||
überfliegen lässt. Bewusst kein Link: der Weg zum Issue führt über den Dialog (4.6),
|
überfliegen lässt. Bewusst kein Link: der Weg zum Issue führt über den Dialog (4.6),
|
||||||
weil ein `<a>` innerhalb der anklickbaren Karte ungültiges Markup wäre.
|
weil ein `<a>` innerhalb der anklickbaren Karte ungültiges Markup wäre.
|
||||||
|
|
||||||
|
#### Wie viele Karten eine Spalte zeigt
|
||||||
|
|
||||||
|
Eine Spalte zeigt höchstens so viele Karten, wie unter *Admin-Einstellungen →
|
||||||
|
Aufgabenbrett* eingestellt ist (Voreinstellung 15, `0` schaltet die Begrenzung ab). Was
|
||||||
|
darüber hinausgeht, steht hinter einem Knopf „*n* weitere anzeigen"; entfaltet wird je
|
||||||
|
Spalte einzeln. Die Zahl neben der Spaltenüberschrift nennt weiterhin den **vollen**
|
||||||
|
Bestand — sonst wäre nicht zu erkennen, dass etwas fehlt.
|
||||||
|
|
||||||
|
**Begrenzt wird nur die Anzeige.** Das Umsortieren schickt je Spalte die vollständige
|
||||||
|
Liste an den Server; würde hier schon gekürzt, fielen die verborgenen Aufgaben beim
|
||||||
|
nächsten Ziehen aus dem Brett. Die Einstellung ist deshalb bewusst eine Sache der
|
||||||
|
Darstellung und keine der Abfrage — geladen wird ohnehin das ganze Projekt.
|
||||||
- Erledigtes wird durchgestrichen; der Zeitpunkt wird festgehalten und beim Zurückziehen
|
- Erledigtes wird durchgestrichen; der Zeitpunkt wird festgehalten und beim Zurückziehen
|
||||||
wieder gelöscht.
|
wieder gelöscht.
|
||||||
|
|
||||||
@@ -540,6 +553,42 @@ deshalb ohne Risiko.
|
|||||||
| Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung |
|
| Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung |
|
||||||
| Sprachen | Verteilung als Balken mit Prozentwerten |
|
| Sprachen | Verteilung als Balken mit Prozentwerten |
|
||||||
|
|
||||||
|
#### Wenn ein Teil nicht ankommt
|
||||||
|
|
||||||
|
Diese Listen werden gleichzeitig geholt, und einzelne davon können scheitern, während
|
||||||
|
der Rest durchgeht — ein 403 wegen fehlender Scopes, ein Rate-Limit, eine
|
||||||
|
Zeitüberschreitung.
|
||||||
|
|
||||||
|
**Ein zweiter Versuch, bevor etwas als fehlend gilt.** Zeitüberschreitungen und
|
||||||
|
Verbindungen, die gar nicht zustande kamen, werden einmal nachgeschlagen — einzeln
|
||||||
|
statt im Schwung, weil genau das der beobachtete Fall war: zwei von acht Aufrufen
|
||||||
|
liefen in eine Zeitüberschreitung nach 15 Sekunden, während dieselben Aufrufe einzeln
|
||||||
|
in 80 Millisekunden antworteten. Nachgeschlagen wird höchstens zweimal je Abgleich —
|
||||||
|
acht Zeitüberschreitungen hintereinander wären zwei Minuten. Bei `403`, `404` und `429`
|
||||||
|
gibt es keinen zweiten Versuch: fehlende Rechte werden durch Wiederholen nicht besser,
|
||||||
|
ein Rate-Limit wird schlimmer.
|
||||||
|
|
||||||
|
**Ein gescheiterter Abruf ist keine leere Liste.** Der Unterschied steht am Snapshot: zu
|
||||||
|
jedem Feld, das nicht geholt werden konnte, wird der Grund samt HTTP-Status festgehalten.
|
||||||
|
Daraus folgt dreierlei:
|
||||||
|
|
||||||
|
- Die vorhandenen Daten **bleiben stehen**. Ein Abgleich, der die Tags nicht holen konnte,
|
||||||
|
ersetzt die gespeicherten nicht durch eine leere Liste. Angezeigt wird dann der letzte
|
||||||
|
vollständige Stand, mit Datum.
|
||||||
|
- Die Meldung heißt „Teilweise aktualisiert – nicht geholt: …" statt „Snapshot
|
||||||
|
aktualisiert: 0 Branches, 0 Tags".
|
||||||
|
- Auf der Projektseite steht ein Hinweis am Repository, der jedes fehlende Feld mit
|
||||||
|
seinem Grund nennt.
|
||||||
|
|
||||||
|
Das gilt ausdrücklich **nicht** als Fehlschlag: `syncError` bleibt leer, der Zähler für
|
||||||
|
das Zurückhalten wird nicht erhöht, und der nächste Durchlauf versucht es normal wieder.
|
||||||
|
Der Rest des Standes ist ja frisch.
|
||||||
|
|
||||||
|
> Vor v1.11.0 wurde jeder Fehlschlag in eine leere Liste verwandelt. Ein Repository mit
|
||||||
|
> 65 Commits meldete „Snapshot aktualisiert: 0 Branches, 0 Tags", überschrieb dabei den
|
||||||
|
> letzten guten Stand, und aus der Oberfläche war nicht zu erkennen, dass überhaupt etwas
|
||||||
|
> schiefgegangen war.
|
||||||
|
|
||||||
Woher der CI-Status kommt:
|
Woher der CI-Status kommt:
|
||||||
|
|
||||||
| System | Quelle |
|
| System | Quelle |
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
|

|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
# ✦ Nebula
|
# ✦ Nebula
|
||||||
|
|
||||||
**Das Kontrollzentrum für deine Vibe-Coding Projekte.**
|
**Das Kontrollzentrum für deine Vibe-Coding Projekte.**
|
||||||
|
|
||||||
Ideen festhalten · Status pflegen · Notizen sammeln · Git-Repos live im Blick
|
|
||||||
|
|
||||||
<sub>Next.js 15 · TypeScript · Tailwind CSS 4 · Prisma · PostgreSQL · Docker</sub>
|
<sub>Next.js 15 · TypeScript · Tailwind CSS 4 · Prisma · PostgreSQL · Docker</sub>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +131,7 @@ Alle Variablen sind in [`.env.example`](.env.example) dokumentiert.
|
|||||||
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` |
|
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` |
|
||||||
| `POSTGRES_PORT` | Host-Port der Datenbank — wirkt nur mit `docker-compose.dev.yml`, sonst hat die DB keinen | `127.0.0.1:5432` |
|
| `POSTGRES_PORT` | Host-Port der Datenbank — wirkt nur mit `docker-compose.dev.yml`, sonst hat die DB keinen | `127.0.0.1:5432` |
|
||||||
| `ADMINER_PORT` | Host-Port von Adminer (`--profile tools`) | `127.0.0.1:8080` |
|
| `ADMINER_PORT` | Host-Port von Adminer (`--profile tools`) | `127.0.0.1:8080` |
|
||||||
| `GIT_API_TIMEOUT_MS` | Timeout für Git-API-Aufrufe | `15000` |
|
| `GIT_API_TIMEOUT_MS` | Timeout für Git-API-Aufrufe, in Millisekunden. Alles unter 1000 oder Unlesbares wird verworfen (mit Warnung im Log) — sonst bräche jeder Aufruf sofort ab | `15000` |
|
||||||
| `GIT_ALLOW_LOOPBACK` | Git-Instanz auf demselben Rechner erlauben | `false` |
|
| `GIT_ALLOW_LOOPBACK` | Git-Instanz auf demselben Rechner erlauben | `false` |
|
||||||
| `GIT_BLOCK_PRIVATE` | Zugriff ins private LAN sperren | `false` |
|
| `GIT_BLOCK_PRIVATE` | Zugriff ins private LAN sperren | `false` |
|
||||||
| `GIT_ALLOWED_HOSTS` | Strengster Modus: nur diese Hosts erlauben | leer |
|
| `GIT_ALLOWED_HOSTS` | Strengster Modus: nur diese Hosts erlauben | leer |
|
||||||
@@ -390,6 +390,7 @@ Container aus — auf dem Rechner selbst muss nichts installiert sein.
|
|||||||
|
|
||||||
```
|
```
|
||||||
nebula/
|
nebula/
|
||||||
|
├── docs/banner.png Titelbild dieser Datei
|
||||||
├── docker/entrypoint.sh Migrationen + Serverstart im Container
|
├── docker/entrypoint.sh Migrationen + Serverstart im Container
|
||||||
├── docker-compose.yml db (immer) · app (--profile full) · adminer (--profile tools)
|
├── docker-compose.yml db (immer) · app (--profile full) · adminer (--profile tools)
|
||||||
├── Dockerfile Multi-Stage-Build auf Basis des Next-Standalone-Outputs
|
├── Dockerfile Multi-Stage-Build auf Basis des Next-Standalone-Outputs
|
||||||
|
|||||||
+4
-3
@@ -7,7 +7,7 @@ Stück für Stück abarbeiten. Reihenfolge ist ein Vorschlag, keine Festlegung.
|
|||||||
|
|
||||||
Was bereits steht, listet [FEATURES.md](FEATURES.md).
|
Was bereits steht, listet [FEATURES.md](FEATURES.md).
|
||||||
|
|
||||||
**Stand:** v1.10.1. Alle Issues aus dem Dashboard sind abgearbeitet.
|
**Stand:** v1.11.0. Alle Issues aus dem Dashboard sind abgearbeitet.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ Voraussetzung, falls du das Projekt veröffentlichen willst.
|
|||||||
|
|
||||||
## 3 · Aufgefallen beim Bauen
|
## 3 · Aufgefallen beim Bauen
|
||||||
|
|
||||||
Beobachtungen aus der Arbeit an v1.6.0 bis v1.10.1 — festgehalten, damit sie
|
Beobachtungen aus der Arbeit an v1.6.0 bis v1.11.0 — festgehalten, damit sie
|
||||||
nicht verloren gehen. Nichts davon ist dringend.
|
nicht verloren gehen. Nichts davon ist dringend.
|
||||||
|
|
||||||
### 3.1 Anwendungsname bricht in der Kopfleiste ab `S`
|
### 3.1 Anwendungsname bricht in der Kopfleiste ab `S`
|
||||||
@@ -108,7 +108,7 @@ Beide stehen im Änderungsverlauf, existierten aber nie in der
|
|||||||
`package.json` — dort springt die Historie von 0.1.0 auf 0.4.0. Nachgetragen
|
`package.json` — dort springt die Historie von 0.1.0 auf 0.4.0. Nachgetragen
|
||||||
wurden sie in dem Commit, der den Verlauf einführte, und der liegt *nach*
|
wurden sie in dem Commit, der den Verlauf einführte, und der liegt *nach*
|
||||||
0.4.0. Es gibt also keinen ehrlichen Commit, an dem ein Tag hängen könnte.
|
0.4.0. Es gibt also keinen ehrlichen Commit, an dem ein Tag hängen könnte.
|
||||||
Die übrigen 18 Fassungen haben seit dem 18.08.2026 ein Release in Gitea.
|
Die übrigen 20 Fassungen haben seit dem 18.08.2026 ein Release in Gitea.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -179,6 +179,7 @@ Damit klar ist, was **nicht** kommt und warum:
|
|||||||
| **v1.9.0** Geschlossene Issues haken ihre Aufgabe auch ohne Webhook ab | ✓ |
|
| **v1.9.0** Geschlossene Issues haken ihre Aufgabe auch ohne Webhook ab | ✓ |
|
||||||
| **v1.10.0** Wochenrückblick, globale Zeitleiste, Suche über Aufgaben | ✓ |
|
| **v1.10.0** Wochenrückblick, globale Zeitleiste, Suche über Aufgaben | ✓ |
|
||||||
| **v1.10.1** Issue-Kennzeichen auf der Aufgabenkarte, Token-Rechte Aufruf für Aufruf belegt | ✓ |
|
| **v1.10.1** Issue-Kennzeichen auf der Aufgabenkarte, Token-Rechte Aufruf für Aufruf belegt | ✓ |
|
||||||
|
| **v1.11.0** Karten je Spalte begrenzen; der Abgleich verschluckt keine Fehler mehr und verliert dabei nichts | ✓ |
|
||||||
|
|
||||||
Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur
|
Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur
|
||||||
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
|
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 503 KiB |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nebula",
|
"name": "nebula",
|
||||||
"version": "1.10.1",
|
"version": "1.11.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nebula",
|
"name": "nebula",
|
||||||
"version": "1.10.1",
|
"version": "1.11.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nebula",
|
"name": "nebula",
|
||||||
"version": "1.10.1",
|
"version": "1.11.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
|
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "AppSettings" ADD COLUMN "taskColumnLimit" INTEGER NOT NULL DEFAULT 15;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "RepoSnapshot" ADD COLUMN "completeAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "missing" JSONB NOT NULL DEFAULT '{}';
|
||||||
@@ -266,6 +266,11 @@ model AppSettings {
|
|||||||
/// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms }
|
/// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms }
|
||||||
lastAutoSyncReport Json?
|
lastAutoSyncReport Json?
|
||||||
|
|
||||||
|
// ── Aufgabenbrett ──────────────────────────────────────────
|
||||||
|
/// Hoechstzahl Karten je Spalte. Was darueber hinausgeht, kommt hinter
|
||||||
|
/// einen Knopf – verborgen wird nichts. 0 schaltet die Begrenzung ab.
|
||||||
|
taskColumnLimit Int @default(15)
|
||||||
|
|
||||||
// ── Stillstand ─────────────────────────────────────────────
|
// ── Stillstand ─────────────────────────────────────────────
|
||||||
/// Ab wie vielen Tagen ohne Commit ein Projekt als stillstehend gilt.
|
/// Ab wie vielen Tagen ohne Commit ein Projekt als stillstehend gilt.
|
||||||
staleAfterDays Int @default(60)
|
staleAfterDays Int @default(60)
|
||||||
@@ -644,6 +649,19 @@ model RepoSnapshot {
|
|||||||
ciUrl String?
|
ciUrl String?
|
||||||
ciFinishedAt DateTime?
|
ciFinishedAt DateTime?
|
||||||
|
|
||||||
|
/// Teil-Abrufe, die beim letzten Abgleich nicht geklappt haben:
|
||||||
|
/// { "branches": "HTTP 403: Zugriff verweigert …" }. Leeres Objekt heisst,
|
||||||
|
/// dass alle Listen wirklich geholt wurden.
|
||||||
|
///
|
||||||
|
/// Ohne diese Angabe ist eine leere Liste zweideutig – "nicht geholt" sieht
|
||||||
|
/// aus wie "nichts vorhanden". Genau daran ist eine Fehlersuche einmal
|
||||||
|
/// haengen geblieben.
|
||||||
|
missing Json @default("{}")
|
||||||
|
|
||||||
pushedAt DateTime?
|
pushedAt DateTime?
|
||||||
fetchedAt DateTime @default(now())
|
fetchedAt DateTime @default(now())
|
||||||
|
/// Wann zuletzt ein Abgleich lief, bei dem alle Teile ankamen. Steht der
|
||||||
|
/// weiter zurueck als fetchedAt, ist ein Teil der Anzeige aelter als sie
|
||||||
|
/// vorgibt.
|
||||||
|
completeAt DateTime?
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-1
@@ -19,7 +19,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
const admin = await requireAdminPage();
|
const admin = await requireAdminPage();
|
||||||
|
|
||||||
const [settings, users, eligible, staleListeners] = await Promise.all([
|
const [settings, users, eligible, staleListeners, laengsteSpalte] = await Promise.all([
|
||||||
getAppSettings(),
|
getAppSettings(),
|
||||||
prisma.user.findMany({
|
prisma.user.findMany({
|
||||||
select: userSafeSelect,
|
select: userSafeSelect,
|
||||||
@@ -41,6 +41,18 @@ export default async function AdminPage() {
|
|||||||
prisma.notificationTarget.count({
|
prisma.notificationTarget.count({
|
||||||
where: { isActive: true, events: { has: "PROJECT_STALE" } },
|
where: { isActive: true, events: { has: "PROJECT_STALE" } },
|
||||||
}),
|
}),
|
||||||
|
// Laengste Spalte ueber alle Projekte. Ohne diese Zahl steht die
|
||||||
|
// Einstellung im Leeren: eine Grenze von 15 sagt nichts, wenn die
|
||||||
|
// groesste Spalte im Bestand aus drei Karten besteht.
|
||||||
|
//
|
||||||
|
// Wie beim Abgleich bewusst ueber alle Konten – gezaehlt wird nur, es
|
||||||
|
// wird nichts gelesen und nichts zugeordnet.
|
||||||
|
prisma.task.groupBy({
|
||||||
|
by: ["projectId", "status"],
|
||||||
|
_count: { _all: true },
|
||||||
|
orderBy: { _count: { projectId: "desc" } },
|
||||||
|
take: 1,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const data: AdminData = {
|
const data: AdminData = {
|
||||||
@@ -64,6 +76,10 @@ export default async function AdminPage() {
|
|||||||
lastCheckAt: settings.lastStaleCheckAt,
|
lastCheckAt: settings.lastStaleCheckAt,
|
||||||
listeners: staleListeners,
|
listeners: staleListeners,
|
||||||
},
|
},
|
||||||
|
tasks: {
|
||||||
|
columnLimit: settings.taskColumnLimit,
|
||||||
|
laengsteSpalte: laengsteSpalte[0]?._count._all ?? 0,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export async function PATCH(request: Request) {
|
|||||||
autoSyncActiveOnly,
|
autoSyncActiveOnly,
|
||||||
autoSyncBatchSize,
|
autoSyncBatchSize,
|
||||||
staleAfterDays,
|
staleAfterDays,
|
||||||
|
taskColumnLimit,
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
|
|
||||||
// Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren –
|
// Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren –
|
||||||
@@ -63,6 +64,7 @@ export async function PATCH(request: Request) {
|
|||||||
...(staleAfterDays !== undefined
|
...(staleAfterDays !== undefined
|
||||||
? { staleAfterDays, lastStaleCheckAt: null }
|
? { staleAfterDays, lastStaleCheckAt: null }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(taskColumnLimit !== undefined ? { taskColumnLimit } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const settings = await prisma.appSettings.upsert({
|
const settings = await prisma.appSettings.upsert({
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ProjectHeader } from "@/components/ProjectHeader";
|
|||||||
import { TaskBoard } from "@/components/tasks/TaskBoard";
|
import { TaskBoard } from "@/components/tasks/TaskBoard";
|
||||||
import { requireUserPage } from "@/lib/auth/guard";
|
import { requireUserPage } from "@/lib/auth/guard";
|
||||||
import { getSessionUser } from "@/lib/auth/session";
|
import { getSessionUser } from "@/lib/auth/session";
|
||||||
|
import { getAppSettings } from "@/lib/auth/settings";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { connectionSafeSelect, projectDetailInclude, webhookRepositoryIds } from "@/lib/projects";
|
import { connectionSafeSelect, projectDetailInclude, webhookRepositoryIds } from "@/lib/projects";
|
||||||
import type { ConnectionSafe, ProjectDetail } from "@/types";
|
import type { ConnectionSafe, ProjectDetail } from "@/types";
|
||||||
@@ -37,7 +38,7 @@ export default async function ProjectPage({ params }: PageProps) {
|
|||||||
const user = await requireUserPage();
|
const user = await requireUserPage();
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
const [project, connections] = await Promise.all([
|
const [project, connections, settings] = await Promise.all([
|
||||||
// findFirst mit Besitzerbedingung: ein fremdes Projekt fuehrt zu 404,
|
// findFirst mit Besitzerbedingung: ein fremdes Projekt fuehrt zu 404,
|
||||||
// nicht zu einer Fehlermeldung, die seine Existenz bestaetigt.
|
// nicht zu einer Fehlermeldung, die seine Existenz bestaetigt.
|
||||||
prisma.project.findFirst({
|
prisma.project.findFirst({
|
||||||
@@ -49,6 +50,7 @@ export default async function ProjectPage({ params }: PageProps) {
|
|||||||
select: connectionSafeSelect,
|
select: connectionSafeSelect,
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
}),
|
}),
|
||||||
|
getAppSettings(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!project) notFound();
|
if (!project) notFound();
|
||||||
@@ -83,6 +85,7 @@ export default async function ProjectPage({ params }: PageProps) {
|
|||||||
projectId={detail.id}
|
projectId={detail.id}
|
||||||
tasks={detail.tasks}
|
tasks={detail.tasks}
|
||||||
progressFromTasks={detail.progressFromTasks}
|
progressFromTasks={detail.progressFromTasks}
|
||||||
|
columnLimit={settings.taskColumnLimit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
|
|||||||
+208
-50
@@ -30,13 +30,14 @@ import { RepositoryDialog } from "@/components/RepositoryDialog";
|
|||||||
import { WebhookDialog } from "@/components/WebhookDialog";
|
import { WebhookDialog } from "@/components/WebhookDialog";
|
||||||
import { Sparkline } from "@/components/Sparkline";
|
import { Sparkline } from "@/components/Sparkline";
|
||||||
import { repoTitle } from "@/lib/git/meta";
|
import { repoTitle } from "@/lib/git/meta";
|
||||||
import { asArray, asCalendar, asRecord } from "@/types";
|
import { asArray, asCalendar, asRecord, asTextRecord } from "@/types";
|
||||||
import type {
|
import type {
|
||||||
BranchInfo,
|
BranchInfo,
|
||||||
CommitInfo,
|
CommitInfo,
|
||||||
ConnectionSafe,
|
ConnectionSafe,
|
||||||
ProjectDetail,
|
ProjectDetail,
|
||||||
ReleaseInfo,
|
ReleaseInfo,
|
||||||
|
RepoSnapshot,
|
||||||
RepositoryWithSnapshot,
|
RepositoryWithSnapshot,
|
||||||
TagInfo,
|
TagInfo,
|
||||||
TicketInfo,
|
TicketInfo,
|
||||||
@@ -69,17 +70,26 @@ export function GitPanel({
|
|||||||
const [tab, setTab] = useState<TabKey>("pulls");
|
const [tab, setTab] = useState<TabKey>("pulls");
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
|
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
const [webhookOpen, setWebhookOpen] = useState(false);
|
const [webhookOpen, setWebhookOpen] = useState(false);
|
||||||
|
|
||||||
const repositories = project.repositories;
|
const repositories = project.repositories;
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(repositories[0]?.id ?? null);
|
const [selectedId, setSelectedId] = useState<string | null>(
|
||||||
|
repositories[0]?.id ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
// Nach dem Entfernen zeigt die Auswahl ins Leere – dann auf das fuehrende
|
// Nach dem Entfernen zeigt die Auswahl ins Leere – dann auf das fuehrende
|
||||||
// zurueckfallen, statt eine leere Ansicht zu zeigen.
|
// zurueckfallen, statt eine leere Ansicht zu zeigen.
|
||||||
const repository = repositories.find((entry) => entry.id === selectedId) ?? repositories[0] ?? null;
|
const repository =
|
||||||
const webhookAktiv = repository ? webhookRepoIds.includes(repository.id) : false;
|
repositories.find((entry) => entry.id === selectedId) ??
|
||||||
|
repositories[0] ??
|
||||||
|
null;
|
||||||
|
const webhookAktiv = repository
|
||||||
|
? webhookRepoIds.includes(repository.id)
|
||||||
|
: false;
|
||||||
|
|
||||||
/** Offene Issues als Aufgaben anlegen – ohne erneuten Abruf beim Provider. */
|
/** Offene Issues als Aufgaben anlegen – ohne erneuten Abruf beim Provider. */
|
||||||
async function importIssues() {
|
async function importIssues() {
|
||||||
@@ -87,13 +97,17 @@ export function GitPanel({
|
|||||||
setImporting(true);
|
setImporting(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/projects/${project.id}/tasks/from-issues`, {
|
const response = await fetch(
|
||||||
method: "POST",
|
`/api/projects/${project.id}/tasks/from-issues`,
|
||||||
headers: { "Content-Type": "application/json" },
|
{
|
||||||
body: JSON.stringify({ repositoryId: repository.id }),
|
method: "POST",
|
||||||
});
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ repositoryId: repository.id }),
|
||||||
|
},
|
||||||
|
);
|
||||||
const body = await response.json().catch(() => ({}));
|
const body = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) throw new Error(body.error ?? "Uebernahme fehlgeschlagen.");
|
if (!response.ok)
|
||||||
|
throw new Error(body.error ?? "Uebernahme fehlgeschlagen.");
|
||||||
setMessage({
|
setMessage({
|
||||||
ok: true,
|
ok: true,
|
||||||
text:
|
text:
|
||||||
@@ -103,7 +117,10 @@ export function GitPanel({
|
|||||||
});
|
});
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setMessage({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
setMessage({
|
||||||
|
ok: false,
|
||||||
|
text: caught instanceof Error ? caught.message : String(caught),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
}
|
}
|
||||||
@@ -130,7 +147,10 @@ export function GitPanel({
|
|||||||
: `/api/projects/${project.id}/sync`;
|
: `/api/projects/${project.id}/sync`;
|
||||||
const response = await fetch(url, { method: "POST" });
|
const response = await fetch(url, { method: "POST" });
|
||||||
const payload = await response.json().catch(() => ({}));
|
const payload = await response.json().catch(() => ({}));
|
||||||
setMessage({ ok: response.ok, text: payload.message ?? "Unbekannte Antwort." });
|
setMessage({
|
||||||
|
ok: response.ok,
|
||||||
|
text: payload.message ?? "Unbekannte Antwort.",
|
||||||
|
});
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setMessage({
|
setMessage({
|
||||||
@@ -150,7 +170,9 @@ export function GitPanel({
|
|||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await fetch(`/api/repositories/${entry.id}`, { method: "DELETE" }).catch(() => undefined);
|
await fetch(`/api/repositories/${entry.id}`, { method: "DELETE" }).catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
setSelectedId(null);
|
setSelectedId(null);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
@@ -160,12 +182,19 @@ export function GitPanel({
|
|||||||
<>
|
<>
|
||||||
<div className="glass px-5 py-10 text-center">
|
<div className="glass px-5 py-10 text-center">
|
||||||
<GitBranch className="mx-auto h-8 w-8 text-slate-600" />
|
<GitBranch className="mx-auto h-8 w-8 text-slate-600" />
|
||||||
<h3 className="mt-3 text-sm font-semibold text-slate-200">Kein Repository verknuepft</h3>
|
<h3 className="mt-3 text-sm font-semibold text-slate-200">
|
||||||
|
Kein Repository verknuepft
|
||||||
|
</h3>
|
||||||
<p className="mx-auto mt-1 max-w-sm text-sm text-slate-500">
|
<p className="mx-auto mt-1 max-w-sm text-sm text-slate-500">
|
||||||
Verknuepfe ein Repository, um Branches, Tags, Commits und Releases hier zu sehen.
|
Verknuepfe ein Repository, um Branches, Tags, Commits und Releases
|
||||||
Ein Projekt darf auch mehrere haben – etwa Frontend und Backend.
|
hier zu sehen. Ein Projekt darf auch mehrere haben – etwa Frontend
|
||||||
|
und Backend.
|
||||||
</p>
|
</p>
|
||||||
<button type="button" className="btn btn-primary mt-4" onClick={() => setAddOpen(true)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary mt-4"
|
||||||
|
onClick={() => setAddOpen(true)}
|
||||||
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Repository verknuepfen
|
Repository verknuepfen
|
||||||
</button>
|
</button>
|
||||||
@@ -180,7 +209,10 @@ export function GitPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const languageTotal = Object.values(languages).reduce((sum, value) => sum + value, 0);
|
const languageTotal = Object.values(languages).reduce(
|
||||||
|
(sum, value) => sum + value,
|
||||||
|
0,
|
||||||
|
);
|
||||||
const topLanguages = Object.entries(languages)
|
const topLanguages = Object.entries(languages)
|
||||||
.sort((a, b) => b[1] - a[1])
|
.sort((a, b) => b[1] - a[1])
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
@@ -190,7 +222,10 @@ export function GitPanel({
|
|||||||
{/* Auswahl, sobald es mehr als eines gibt. Bei einem einzigen waere eine
|
{/* Auswahl, sobald es mehr als eines gibt. Bei einem einzigen waere eine
|
||||||
Leiste mit genau einem Knopf nur Ballast. */}
|
Leiste mit genau einem Knopf nur Ballast. */}
|
||||||
{repositories.length > 1 ? (
|
{repositories.length > 1 ? (
|
||||||
<div className="glass flex flex-wrap items-center gap-1.5 p-2" role="tablist">
|
<div
|
||||||
|
className="glass flex flex-wrap items-center gap-1.5 p-2"
|
||||||
|
role="tablist"
|
||||||
|
>
|
||||||
{repositories.map((entry) => {
|
{repositories.map((entry) => {
|
||||||
const aktiv = entry.id === repository.id;
|
const aktiv = entry.id === repository.id;
|
||||||
return (
|
return (
|
||||||
@@ -256,7 +291,11 @@ export function GitPanel({
|
|||||||
<ExternalLink className="h-3.5 w-3.5" />
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} url={snapshot?.ciUrl} />
|
<CiBadge
|
||||||
|
state={snapshot?.ciState}
|
||||||
|
name={snapshot?.ciName}
|
||||||
|
url={snapshot?.ciUrl}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-slate-500" suppressHydrationWarning>
|
<p className="mt-1 text-xs text-slate-500" suppressHydrationWarning>
|
||||||
{repository.lastSyncedAt
|
{repository.lastSyncedAt
|
||||||
@@ -368,12 +407,22 @@ export function GitPanel({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<UnvollstaendigerStand snapshot={snapshot} />
|
||||||
|
|
||||||
{snapshot ? (
|
{snapshot ? (
|
||||||
<>
|
<>
|
||||||
{/* Kennzahlen */}
|
{/* Kennzahlen */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
<Metric icon={Star} label="Stars" value={formatNumber(snapshot.stars)} />
|
<Metric
|
||||||
<Metric icon={GitMerge} label="Forks" value={formatNumber(snapshot.forks)} />
|
icon={Star}
|
||||||
|
label="Stars"
|
||||||
|
value={formatNumber(snapshot.stars)}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
icon={GitMerge}
|
||||||
|
label="Forks"
|
||||||
|
value={formatNumber(snapshot.forks)}
|
||||||
|
/>
|
||||||
<Metric
|
<Metric
|
||||||
icon={GitPullRequest}
|
icon={GitPullRequest}
|
||||||
label="Offene PRs/MRs"
|
label="Offene PRs/MRs"
|
||||||
@@ -384,11 +433,19 @@ export function GitPanel({
|
|||||||
label="Offene Issues"
|
label="Offene Issues"
|
||||||
value={formatNumber(snapshot.openIssues)}
|
value={formatNumber(snapshot.openIssues)}
|
||||||
/>
|
/>
|
||||||
<Metric icon={GitBranch} label="Branches" value={formatNumber(snapshot.branchCount)} />
|
<Metric
|
||||||
|
icon={GitBranch}
|
||||||
|
label="Branches"
|
||||||
|
value={formatNumber(snapshot.branchCount)}
|
||||||
|
/>
|
||||||
<Metric
|
<Metric
|
||||||
icon={HardDrive}
|
icon={HardDrive}
|
||||||
label="Groesse"
|
label="Groesse"
|
||||||
value={snapshot.sizeKb > 1024 ? `${Math.round(snapshot.sizeKb / 1024)} MB` : `${snapshot.sizeKb} KB`}
|
value={
|
||||||
|
snapshot.sizeKb > 1024
|
||||||
|
? `${Math.round(snapshot.sizeKb / 1024)} MB`
|
||||||
|
: `${snapshot.sizeKb} KB`
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -408,11 +465,15 @@ export function GitPanel({
|
|||||||
{snapshot.lastCommitSha.slice(0, 7)}
|
{snapshot.lastCommitSha.slice(0, 7)}
|
||||||
</span>
|
</span>
|
||||||
<span>{snapshot.lastCommitAuthor ?? "unbekannt"}</span>
|
<span>{snapshot.lastCommitAuthor ?? "unbekannt"}</span>
|
||||||
<span suppressHydrationWarning>{formatRelative(snapshot.lastCommitAt)}</span>
|
<span suppressHydrationWarning>
|
||||||
|
{formatRelative(snapshot.lastCommitAt)}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-2 text-sm text-slate-500">Keine Commit-Daten.</p>
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
Keine Commit-Daten.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -424,8 +485,11 @@ export function GitPanel({
|
|||||||
<span className="font-mono text-2xl text-white">
|
<span className="font-mono text-2xl text-white">
|
||||||
{snapshot.latestTag ?? snapshot.latestRelease ?? "–"}
|
{snapshot.latestTag ?? snapshot.latestRelease ?? "–"}
|
||||||
</span>
|
</span>
|
||||||
{snapshot.latestRelease && snapshot.latestRelease !== snapshot.latestTag ? (
|
{snapshot.latestRelease &&
|
||||||
<span className="text-sm text-slate-400">{snapshot.latestRelease}</span>
|
snapshot.latestRelease !== snapshot.latestTag ? (
|
||||||
|
<span className="text-sm text-slate-400">
|
||||||
|
{snapshot.latestRelease}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
@@ -470,7 +534,8 @@ export function GitPanel({
|
|||||||
title={`${name} – ${Math.round((bytes / languageTotal) * 100)}%`}
|
title={`${name} – ${Math.round((bytes / languageTotal) * 100)}%`}
|
||||||
style={{
|
style={{
|
||||||
width: `${(bytes / languageTotal) * 100}%`,
|
width: `${(bytes / languageTotal) * 100}%`,
|
||||||
backgroundColor: LANGUAGE_COLORS[index % LANGUAGE_COLORS.length],
|
backgroundColor:
|
||||||
|
LANGUAGE_COLORS[index % LANGUAGE_COLORS.length],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -480,7 +545,10 @@ export function GitPanel({
|
|||||||
<span key={name} className="inline-flex items-center gap-1.5">
|
<span key={name} className="inline-flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
className="h-2 w-2 rounded-full"
|
className="h-2 w-2 rounded-full"
|
||||||
style={{ backgroundColor: LANGUAGE_COLORS[index % LANGUAGE_COLORS.length] }}
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
LANGUAGE_COLORS[index % LANGUAGE_COLORS.length],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{name}
|
{name}
|
||||||
<span className="text-slate-600">
|
<span className="text-slate-600">
|
||||||
@@ -537,8 +605,8 @@ export function GitPanel({
|
|||||||
{issues.length > 0 ? (
|
{issues.length > 0 ? (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-white/6 bg-white/2 px-4 py-2">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-white/6 bg-white/2 px-4 py-2">
|
||||||
<p className="text-[11px] text-slate-500">
|
<p className="text-[11px] text-slate-500">
|
||||||
Aus offenen Issues Aufgaben machen. Bereits uebernommene werden
|
Aus offenen Issues Aufgaben machen. Bereits uebernommene
|
||||||
uebersprungen.
|
werden uebersprungen.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -555,7 +623,11 @@ export function GitPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<TicketList tickets={issues} empty="Keine offenen Issues." icon={CircleDot} />
|
<TicketList
|
||||||
|
tickets={issues}
|
||||||
|
empty="Keine offenen Issues."
|
||||||
|
icon={CircleDot}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{tab === "branches" ? <BranchList branches={branches} /> : null}
|
{tab === "branches" ? <BranchList branches={branches} /> : null}
|
||||||
@@ -567,7 +639,8 @@ export function GitPanel({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="glass px-5 py-10 text-center text-sm text-slate-500">
|
<div className="glass px-5 py-10 text-center text-sm text-slate-500">
|
||||||
Noch keine Daten geladen. Mit „Jetzt synchronisieren“ den ersten Snapshot holen.
|
Noch keine Daten geladen. Mit „Jetzt synchronisieren“ den ersten
|
||||||
|
Snapshot holen.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -591,7 +664,9 @@ function Metric({
|
|||||||
<Icon className="h-3 w-3" />
|
<Icon className="h-3 w-3" />
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-lg font-semibold tabular-nums text-slate-100">{value}</p>
|
<p className="mt-1 text-lg font-semibold tabular-nums text-slate-100">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -639,10 +714,15 @@ function TicketList({
|
|||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-slate-500">
|
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-slate-500">
|
||||||
<span className="font-mono text-slate-600">#{ticket.number}</span>
|
<span className="font-mono text-slate-600">
|
||||||
|
#{ticket.number}
|
||||||
|
</span>
|
||||||
<span>{ticket.author ?? "unbekannt"}</span>
|
<span>{ticket.author ?? "unbekannt"}</span>
|
||||||
{ticket.labels.slice(0, 3).map((label) => (
|
{ticket.labels.slice(0, 3).map((label) => (
|
||||||
<span key={label} className="rounded bg-white/6 px-1 py-0.5 text-[10px]">
|
<span
|
||||||
|
key={label}
|
||||||
|
className="rounded bg-white/6 px-1 py-0.5 text-[10px]"
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -650,7 +730,9 @@ function TicketList({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
|
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
|
||||||
<span suppressHydrationWarning>{formatRelative(ticket.createdAt)}</span>
|
<span suppressHydrationWarning>
|
||||||
|
{formatRelative(ticket.createdAt)}
|
||||||
|
</span>
|
||||||
{ticket.url ? (
|
{ticket.url ? (
|
||||||
<a
|
<a
|
||||||
href={ticket.url}
|
href={ticket.url}
|
||||||
@@ -677,7 +759,9 @@ function BranchList({ branches }: { branches: BranchInfo[] }) {
|
|||||||
<Row key={branch.name}>
|
<Row key={branch.name}>
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<GitBranch className="h-3.5 w-3.5 shrink-0 text-slate-600" />
|
<GitBranch className="h-3.5 w-3.5 shrink-0 text-slate-600" />
|
||||||
<span className="truncate font-mono text-xs text-slate-200">{branch.name}</span>
|
<span className="truncate font-mono text-xs text-slate-200">
|
||||||
|
{branch.name}
|
||||||
|
</span>
|
||||||
{branch.isDefault ? (
|
{branch.isDefault ? (
|
||||||
<span className="shrink-0 rounded bg-violet-500/15 px-1.5 py-0.5 text-[10px] text-violet-200">
|
<span className="shrink-0 rounded bg-violet-500/15 px-1.5 py-0.5 text-[10px] text-violet-200">
|
||||||
default
|
default
|
||||||
@@ -689,10 +773,14 @@ function BranchList({ branches }: { branches: BranchInfo[] }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500">
|
<div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500">
|
||||||
{branch.lastCommitAt ? (
|
{branch.lastCommitAt ? (
|
||||||
<span suppressHydrationWarning>{formatRelative(branch.lastCommitAt)}</span>
|
<span suppressHydrationWarning>
|
||||||
|
{formatRelative(branch.lastCommitAt)}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{branch.sha ? (
|
{branch.sha ? (
|
||||||
<span className="font-mono text-slate-600">{branch.sha.slice(0, 7)}</span>
|
<span className="font-mono text-slate-600">
|
||||||
|
{branch.sha.slice(0, 7)}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -709,15 +797,23 @@ function TagList({ tags }: { tags: TagInfo[] }) {
|
|||||||
<Row key={tag.name}>
|
<Row key={tag.name}>
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<Tag className="h-3.5 w-3.5 shrink-0 text-slate-600" />
|
<Tag className="h-3.5 w-3.5 shrink-0 text-slate-600" />
|
||||||
<span className="truncate font-mono text-xs text-slate-200">{tag.name}</span>
|
<span className="truncate font-mono text-xs text-slate-200">
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
{tag.message ? (
|
{tag.message ? (
|
||||||
<span className="truncate text-[11px] text-slate-500">{tag.message}</span>
|
<span className="truncate text-[11px] text-slate-500">
|
||||||
|
{tag.message}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500">
|
<div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500">
|
||||||
{tag.date ? <span suppressHydrationWarning>{formatRelative(tag.date)}</span> : null}
|
{tag.date ? (
|
||||||
|
<span suppressHydrationWarning>{formatRelative(tag.date)}</span>
|
||||||
|
) : null}
|
||||||
{tag.sha ? (
|
{tag.sha ? (
|
||||||
<span className="font-mono text-slate-600">{tag.sha.slice(0, 7)}</span>
|
<span className="font-mono text-slate-600">
|
||||||
|
{tag.sha.slice(0, 7)}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -735,7 +831,9 @@ function CommitList({ commits }: { commits: CommitInfo[] }) {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-xs text-slate-200">{commit.message}</p>
|
<p className="truncate text-xs text-slate-200">{commit.message}</p>
|
||||||
<p className="mt-0.5 flex items-center gap-2 text-[11px] text-slate-500">
|
<p className="mt-0.5 flex items-center gap-2 text-[11px] text-slate-500">
|
||||||
<span className="font-mono text-violet-300/80">{commit.shortSha}</span>
|
<span className="font-mono text-violet-300/80">
|
||||||
|
{commit.shortSha}
|
||||||
|
</span>
|
||||||
<span>{commit.author ?? "unbekannt"}</span>
|
<span>{commit.author ?? "unbekannt"}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -760,14 +858,17 @@ function CommitList({ commits }: { commits: CommitInfo[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ReleaseList({ releases }: { releases: ReleaseInfo[] }) {
|
function ReleaseList({ releases }: { releases: ReleaseInfo[] }) {
|
||||||
if (releases.length === 0) return <Empty text="Keine Releases veroeffentlicht." />;
|
if (releases.length === 0)
|
||||||
|
return <Empty text="Keine Releases veroeffentlicht." />;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{releases.map((release) => (
|
{releases.map((release) => (
|
||||||
<Row key={`${release.tag}-${release.name}`}>
|
<Row key={`${release.tag}-${release.name}`}>
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<Package className="h-3.5 w-3.5 shrink-0 text-slate-600" />
|
<Package className="h-3.5 w-3.5 shrink-0 text-slate-600" />
|
||||||
<span className="truncate text-xs text-slate-200">{release.name}</span>
|
<span className="truncate text-xs text-slate-200">
|
||||||
|
{release.name}
|
||||||
|
</span>
|
||||||
<span className="shrink-0 rounded bg-white/6 px-1.5 py-0.5 font-mono text-[10px] text-slate-400">
|
<span className="shrink-0 rounded bg-white/6 px-1.5 py-0.5 font-mono text-[10px] text-slate-400">
|
||||||
{release.tag}
|
{release.tag}
|
||||||
</span>
|
</span>
|
||||||
@@ -783,7 +884,9 @@ function ReleaseList({ releases }: { releases: ReleaseInfo[] }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
|
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
|
||||||
<span suppressHydrationWarning>{formatRelative(release.publishedAt)}</span>
|
<span suppressHydrationWarning>
|
||||||
|
{formatRelative(release.publishedAt)}
|
||||||
|
</span>
|
||||||
{release.url ? (
|
{release.url ? (
|
||||||
<a
|
<a
|
||||||
href={release.url}
|
href={release.url}
|
||||||
@@ -801,3 +904,58 @@ function ReleaseList({ releases }: { releases: ReleaseInfo[] }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teile, die beim letzten Abgleich nicht ankamen.
|
||||||
|
*
|
||||||
|
* Bernstein statt Rot: der Abgleich ist nicht gescheitert, es fehlt nur
|
||||||
|
* etwas – und was danebensteht, ist der letzte gute Stand, keine Null.
|
||||||
|
*
|
||||||
|
* Bis v1.10.1 sah eine nicht geholte Liste genauso aus wie eine leere. Ein
|
||||||
|
* Repository mit 65 Commits meldete "Snapshot aktualisiert: 0 Branches,
|
||||||
|
* 0 Tags", und aus der Oberflaeche war nicht zu erkennen, dass ueberhaupt
|
||||||
|
* etwas schiefgegangen war.
|
||||||
|
*/
|
||||||
|
function UnvollstaendigerStand({
|
||||||
|
snapshot,
|
||||||
|
}: {
|
||||||
|
snapshot: RepoSnapshot | null;
|
||||||
|
}) {
|
||||||
|
if (!snapshot) return null;
|
||||||
|
|
||||||
|
const fehlend = asTextRecord(snapshot.missing);
|
||||||
|
// Sortiert: die Reihenfolge im Objekt haengt daran, welcher Aufruf zuerst
|
||||||
|
// gescheitert ist, und das waere bei jedem Abgleich eine andere.
|
||||||
|
const felder = Object.keys(fehlend).sort();
|
||||||
|
if (felder.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-sm text-amber-100">
|
||||||
|
<p className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Beim letzten Abgleich nicht geholt:{" "}
|
||||||
|
<strong>{felder.join(", ")}</strong>. Dafuer steht hier weiterhin der
|
||||||
|
vorherige Stand
|
||||||
|
{snapshot.completeAt ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
von{" "}
|
||||||
|
<span suppressHydrationWarning>
|
||||||
|
{formatDateTime(snapshot.completeAt)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 space-y-0.5 pl-6 text-[11px] text-amber-200/90">
|
||||||
|
{felder.map((feld) => (
|
||||||
|
<li key={feld}>
|
||||||
|
<span className="font-mono">{feld}</span> — {fehlend[feld]}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import {
|
import {
|
||||||
Ban,
|
Ban,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Columns3,
|
||||||
Database,
|
Database,
|
||||||
Download,
|
Download,
|
||||||
Fingerprint,
|
Fingerprint,
|
||||||
@@ -74,6 +75,12 @@ export interface AdminData {
|
|||||||
/** Wie viele Konten ueberhaupt auf Stillstand hoeren. */
|
/** Wie viele Konten ueberhaupt auf Stillstand hoeren. */
|
||||||
listeners: number;
|
listeners: number;
|
||||||
};
|
};
|
||||||
|
tasks: {
|
||||||
|
/** Karten je Spalte im Aufgabenbrett; 0 = ohne Begrenzung. */
|
||||||
|
columnLimit: number;
|
||||||
|
/** Laengste Spalte ueber alle Projekte – ohne die ist die Zahl blind. */
|
||||||
|
laengsteSpalte: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type Notice = { ok: boolean; text: string } | null;
|
type Notice = { ok: boolean; text: string } | null;
|
||||||
@@ -195,6 +202,7 @@ export function AdminManager({ data }: { data: AdminData }) {
|
|||||||
|
|
||||||
<AutoSyncSection data={data.autoSync} />
|
<AutoSyncSection data={data.autoSync} />
|
||||||
<StaleSection data={data.stale} />
|
<StaleSection data={data.stale} />
|
||||||
|
<TaskLimitSection data={data.tasks} />
|
||||||
|
|
||||||
{/* ── Instanz ─────────────────────────────────────── */}
|
{/* ── Instanz ─────────────────────────────────────── */}
|
||||||
<section className="glass p-5">
|
<section className="glass p-5">
|
||||||
@@ -1099,3 +1107,128 @@ function UserDialog({
|
|||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SPALTEN_OPTIONEN = [0, 5, 10, 15, 20, 30, 50];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie viele Karten eine Spalte des Aufgabenbretts hoechstens zeigt.
|
||||||
|
*
|
||||||
|
* Begrenzt wird nur die Anzeige: der Rest steht hinter einem Knopf, und die
|
||||||
|
* Reihenfolge wird weiterhin ueber die vollstaendige Spalte gespeichert.
|
||||||
|
* Eine Aufgabe verschwindet hier also nirgends – sie ist nur einen Klick
|
||||||
|
* weiter weg.
|
||||||
|
*/
|
||||||
|
function TaskLimitSection({ data }: { data: AdminData["tasks"] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [limit, setLimit] = useState(data.columnLimit);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [notice, setNotice] = useState<Notice>(null);
|
||||||
|
|
||||||
|
async function save(value: number) {
|
||||||
|
setBusy(true);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/admin/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ taskColumnLimit: value }),
|
||||||
|
});
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(body.error ?? "Speichern fehlgeschlagen.");
|
||||||
|
setNotice({ ok: true, text: "Gespeichert." });
|
||||||
|
router.refresh();
|
||||||
|
} catch (caught) {
|
||||||
|
setLimit(data.columnLimit);
|
||||||
|
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const greift = limit > 0 && data.laengsteSpalte > limit;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="glass p-5">
|
||||||
|
<div className="mb-4 flex items-start gap-3">
|
||||||
|
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-violet-500/12 text-violet-300">
|
||||||
|
<Columns3 className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
||||||
|
Aufgabenbrett
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 max-w-xl text-sm text-slate-400">
|
||||||
|
Wie viele Karten eine Spalte hoechstens zeigt. Was darueber hinausgeht, kommt
|
||||||
|
hinter einen Knopf – verborgen wird nichts, und die Reihenfolge bleibt
|
||||||
|
vollstaendig gespeichert. Gilt fuer alle Projekte.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="task-column-limit">
|
||||||
|
Karten je Spalte
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="task-column-limit"
|
||||||
|
className="field"
|
||||||
|
value={limit}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = Number(event.target.value);
|
||||||
|
setLimit(value);
|
||||||
|
void save(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{SPALTEN_OPTIONEN.map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{value === 0 ? "ohne Begrenzung" : `hoechstens ${value}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="glass-soft px-3 py-2.5">
|
||||||
|
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
|
||||||
|
Laengste Spalte
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-sm tabular-nums text-slate-200">
|
||||||
|
{data.laengsteSpalte}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="glass-soft px-3 py-2.5">
|
||||||
|
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
|
||||||
|
Wirkung
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-sm text-slate-200">
|
||||||
|
{limit === 0 ? "aus" : greift ? "greift" : "noch nicht"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-3 text-[11px] text-slate-600">
|
||||||
|
{limit === 0
|
||||||
|
? "Jede Spalte zeigt alles. Bei langen Listen wird das Brett unuebersichtlich."
|
||||||
|
: greift
|
||||||
|
? "Mindestens eine Spalte ist laenger als die Grenze – dort erscheint der Knopf."
|
||||||
|
: "Derzeit reicht keine Spalte an die Grenze heran; sichtbar wird die Einstellung erst danach."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{notice ? (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-3 rounded-lg border px-3 py-2 text-sm",
|
||||||
|
notice.ok
|
||||||
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
||||||
|
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{notice.text}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ import {
|
|||||||
import { restrictToWindowEdges } from "@dnd-kit/modifiers";
|
import { restrictToWindowEdges } from "@dnd-kit/modifiers";
|
||||||
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { CheckSquare, Loader2, Plus } from "lucide-react";
|
import { CheckSquare, ChevronDown, ChevronUp, Loader2, Plus } from "lucide-react";
|
||||||
import type { Task, TaskStatus } from "@prisma/client";
|
import type { Task, TaskStatus } from "@prisma/client";
|
||||||
import { TaskCard } from "@/components/tasks/TaskCard";
|
import { TaskCard } from "@/components/tasks/TaskCard";
|
||||||
import { TaskDialog } from "@/components/tasks/TaskDialog";
|
import { TaskDialog } from "@/components/tasks/TaskDialog";
|
||||||
import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks";
|
import { TASK_STATUS_META, TASK_STATUS_ORDER, spaltenAnzeige } from "@/lib/tasks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type Columns = Record<TaskStatus, Task[]>;
|
type Columns = Record<TaskStatus, Task[]>;
|
||||||
@@ -48,10 +48,13 @@ export function TaskBoard({
|
|||||||
projectId,
|
projectId,
|
||||||
tasks,
|
tasks,
|
||||||
progressFromTasks,
|
progressFromTasks,
|
||||||
|
columnLimit = 0,
|
||||||
}: {
|
}: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
progressFromTasks: boolean;
|
progressFromTasks: boolean;
|
||||||
|
/** Karten je Spalte; 0 = ohne Begrenzung. Kommt aus den Admin-Einstellungen. */
|
||||||
|
columnLimit?: number;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [columns, setColumns] = useState<Columns>(() => group(tasks));
|
const [columns, setColumns] = useState<Columns>(() => group(tasks));
|
||||||
@@ -224,6 +227,7 @@ export function TaskBoard({
|
|||||||
key={status}
|
key={status}
|
||||||
status={status}
|
status={status}
|
||||||
tasks={columns[status]}
|
tasks={columns[status]}
|
||||||
|
limit={columnLimit}
|
||||||
onAdd={() => setCreatingIn(status)}
|
onAdd={() => setCreatingIn(status)}
|
||||||
onEdit={setEditing}
|
onEdit={setEditing}
|
||||||
/>
|
/>
|
||||||
@@ -252,16 +256,24 @@ export function TaskBoard({
|
|||||||
function TaskColumn({
|
function TaskColumn({
|
||||||
status,
|
status,
|
||||||
tasks,
|
tasks,
|
||||||
|
limit,
|
||||||
onAdd,
|
onAdd,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
|
limit: number;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onEdit: (task: Task) => void;
|
onEdit: (task: Task) => void;
|
||||||
}) {
|
}) {
|
||||||
const meta = TASK_STATUS_META[status];
|
const meta = TASK_STATUS_META[status];
|
||||||
const { setNodeRef, isOver } = useDroppable({ id: status });
|
const { setNodeRef, isOver } = useDroppable({ id: status });
|
||||||
|
const [entfaltet, setEntfaltet] = useState(false);
|
||||||
|
|
||||||
|
// Nur die Anzeige wird gekuerzt – `tasks` bleibt oben vollstaendig, sonst
|
||||||
|
// wuerde das Umsortieren die verborgenen Aufgaben aus dem Brett loeschen.
|
||||||
|
const { sichtbar, verborgen } = spaltenAnzeige(tasks.length, limit, entfaltet);
|
||||||
|
const gezeigt = tasks.slice(0, sichtbar);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -284,12 +296,14 @@ function TaskColumn({
|
|||||||
<span className="ml-auto text-[10px] tabular-nums text-slate-600">{tasks.length}</span>
|
<span className="ml-auto text-[10px] tabular-nums text-slate-600">{tasks.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Nur die gezeigten Karten stehen im SortableContext – was nicht im
|
||||||
|
DOM ist, kann dnd-kit ohnehin nicht treffen. */}
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={tasks.map((task) => task.id)}
|
items={gezeigt.map((task) => task.id)}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div className="flex flex-1 flex-col gap-1.5">
|
<div className="flex flex-1 flex-col gap-1.5">
|
||||||
{tasks.map((task) => (
|
{gezeigt.map((task) => (
|
||||||
<SortableTask key={task.id} task={task} onEdit={onEdit} />
|
<SortableTask key={task.id} task={task} onEdit={onEdit} />
|
||||||
))}
|
))}
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
@@ -300,6 +314,27 @@ function TaskColumn({
|
|||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
|
|
||||||
|
{verborgen > 0 || entfaltet ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEntfaltet((vorher) => !vorher)}
|
||||||
|
aria-expanded={entfaltet}
|
||||||
|
className="mt-1.5 flex items-center justify-center gap-1 rounded-lg border border-dashed border-white/10 px-2 py-1.5 text-[11px] text-slate-500 transition-colors hover:border-white/20 hover:text-slate-300"
|
||||||
|
>
|
||||||
|
{entfaltet ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="h-3 w-3" />
|
||||||
|
weniger anzeigen
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
{verborgen} weitere anzeigen
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onAdd}
|
onClick={onAdd}
|
||||||
|
|||||||
@@ -41,6 +41,64 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: Release[] = [
|
export const CHANGELOG: Release[] = [
|
||||||
|
{
|
||||||
|
version: "1.11.0",
|
||||||
|
date: "2026-09-10",
|
||||||
|
headline: "Der Abgleich sagt, was er nicht holen konnte — und verliert dabei nichts mehr.",
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
kind: "behoben",
|
||||||
|
text:
|
||||||
|
"Ein fehlgeschlagener Teil-Abruf sah aus wie ein leeres Ergebnis. Ein Repository " +
|
||||||
|
"mit 65 Commits meldete „Snapshot aktualisiert: 0 Branches, 0 Tags“ und überschrieb " +
|
||||||
|
"dabei den letzten guten Stand. Jetzt steht am Repository, welcher Teil nicht kam " +
|
||||||
|
"und woran es lag — und die vorhandenen Daten bleiben stehen.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "behoben",
|
||||||
|
text:
|
||||||
|
"Die Commit-Heatmap kannte bei Gitea nur die jüngsten 50 Commits und gab deren " +
|
||||||
|
"ältesten als Anfang des Zeitraums aus. Grund war eine Seitengröße, die angefragt " +
|
||||||
|
"statt gemessen wurde: Gitea deckelt jede Antwort auf 50, egal was dasteht. Bei " +
|
||||||
|
"einem Repository mit 76 Commits fehlte damit ein Drittel.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "besser",
|
||||||
|
text:
|
||||||
|
"Ein Abruf, der in eine Zeitüberschreitung läuft, wird einmal nachgeschlagen — " +
|
||||||
|
"einzeln statt zusammen mit den sieben anderen. Bei fehlenden Rechten oder einem " +
|
||||||
|
"Rate-Limit passiert das bewusst nicht; das wird durch Wiederholen nicht besser.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "neu",
|
||||||
|
text:
|
||||||
|
"Das Aufgabenbrett zeigt je Spalte höchstens so viele Karten, wie unter " +
|
||||||
|
"Admin-Einstellungen eingestellt ist. Der Rest steht hinter „n weitere anzeigen“; " +
|
||||||
|
"die Zahl an der Spalte nennt weiterhin alle.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "neu",
|
||||||
|
text:
|
||||||
|
"Eine Aufgabenkarte trägt die Nummer ihres verknüpften Git-Issues. Bisher stand " +
|
||||||
|
"die Verknüpfung nur im Bearbeiten-Dialog.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "besser",
|
||||||
|
text:
|
||||||
|
"Die Rechte, die eine Git-Verbindung braucht, stehen Aufruf für Aufruf in der " +
|
||||||
|
"README — getrennt nach reinem Abgleich und zusätzlicher Issue-Anbindung. Der " +
|
||||||
|
"Hinweis unter dem Token-Feld nannte die Rechte für Actions nicht, ohne die bei " +
|
||||||
|
"GitHub kein CI-Zustand ankommt.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "behoben",
|
||||||
|
text:
|
||||||
|
"Ein unbrauchbarer Wert in GIT_API_TIMEOUT_MS legte den ganzen Abgleich still: " +
|
||||||
|
"eine leere oder unlesbare Angabe wurde zu 0 ms, und damit brach jeder Aufruf ab, " +
|
||||||
|
"bevor er begann. Jetzt gilt die Vorgabe, mit Warnung im Log.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "1.10.1",
|
version: "1.10.1",
|
||||||
date: "2026-08-18",
|
date: "2026-08-18",
|
||||||
|
|||||||
@@ -18,6 +18,19 @@ import {
|
|||||||
const JETZT = new Date("2026-07-26T12:00:00.000Z");
|
const JETZT = new Date("2026-07-26T12:00:00.000Z");
|
||||||
const TAG = 86_400_000;
|
const TAG = 86_400_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Quelle, die es nur eine Seite lang gibt.
|
||||||
|
*
|
||||||
|
* Diese Tests pruefen das Zaehlen und Filtern, nicht das Blaettern. Ihr
|
||||||
|
* Fixture lieferte urspruenglich fuer *jede* Seite dieselben Daten und hielt
|
||||||
|
* nur deshalb an, weil die alte Regel eine kurze Seite fuer die letzte hielt
|
||||||
|
* – genau der Fehler, um den es hier geht. Kein Server verhaelt sich so;
|
||||||
|
* nach der letzten Seite kommt nichts mehr.
|
||||||
|
*/
|
||||||
|
function eineSeite(daten: Array<string | null>) {
|
||||||
|
return async (seite: number) => (seite === 1 ? daten : []);
|
||||||
|
}
|
||||||
|
|
||||||
/** Erzeugt n Commit-Zeitpunkte, beginnend vor `abTagen` Tagen, einer je Tag. */
|
/** Erzeugt n Commit-Zeitpunkte, beginnend vor `abTagen` Tagen, einer je Tag. */
|
||||||
function commits(anzahl: number, abTagen: number): string[] {
|
function commits(anzahl: number, abTagen: number): string[] {
|
||||||
return Array.from({ length: anzahl }, (_, index) =>
|
return Array.from({ length: anzahl }, (_, index) =>
|
||||||
@@ -45,14 +58,14 @@ describe("calendarByPaging", () => {
|
|||||||
|
|
||||||
it("zaehlt mehrere Commits desselben Tages zusammen", async () => {
|
it("zaehlt mehrere Commits desselben Tages zusammen", async () => {
|
||||||
const heute = JETZT.toISOString();
|
const heute = JETZT.toISOString();
|
||||||
const kalender = await calendarByPaging(async () => [heute, heute, heute], JETZT);
|
const kalender = await calendarByPaging(eineSeite([heute, heute, heute]), JETZT);
|
||||||
expect(kalender?.days["2026-07-26"]).toBe(3);
|
expect(kalender?.days["2026-07-26"]).toBe(3);
|
||||||
expect(calendarTotal(kalender!)).toBe(3);
|
expect(calendarTotal(kalender!)).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("beginnt beim aeltesten bekannten Commit, nicht vor einem Jahr", async () => {
|
it("beginnt beim aeltesten bekannten Commit, nicht vor einem Jahr", async () => {
|
||||||
// Nur eine halbe Seite, zehn Tage zurueck: alles davor ist unbekannt.
|
// Nur eine halbe Seite, zehn Tage zurueck: alles davor ist unbekannt.
|
||||||
const kalender = await calendarByPaging(async () => commits(10, 0), JETZT);
|
const kalender = await calendarByPaging(eineSeite(commits(10, 0)), JETZT);
|
||||||
expect(kalender?.from).toBe("2026-07-17");
|
expect(kalender?.from).toBe("2026-07-17");
|
||||||
expect(kalender?.to).toBe("2026-07-26");
|
expect(kalender?.to).toBe("2026-07-26");
|
||||||
expect(kalender?.truncated).toBe(false);
|
expect(kalender?.truncated).toBe(false);
|
||||||
@@ -93,10 +106,10 @@ describe("calendarByPaging", () => {
|
|||||||
it("uebergeht Commits mit Datum in der Zukunft", async () => {
|
it("uebergeht Commits mit Datum in der Zukunft", async () => {
|
||||||
// Falsch gestellte Uhren gibt es; sie duerfen den Zeitraum nicht verlaengern.
|
// Falsch gestellte Uhren gibt es; sie duerfen den Zeitraum nicht verlaengern.
|
||||||
const kalender = await calendarByPaging(
|
const kalender = await calendarByPaging(
|
||||||
async () => [
|
eineSeite([
|
||||||
new Date(JETZT.getTime() + 5 * TAG).toISOString(),
|
new Date(JETZT.getTime() + 5 * TAG).toISOString(),
|
||||||
JETZT.toISOString(),
|
JETZT.toISOString(),
|
||||||
],
|
]),
|
||||||
JETZT,
|
JETZT,
|
||||||
);
|
);
|
||||||
expect(kalender?.to).toBe("2026-07-26");
|
expect(kalender?.to).toBe("2026-07-26");
|
||||||
@@ -105,7 +118,7 @@ describe("calendarByPaging", () => {
|
|||||||
|
|
||||||
it("uebergeht Unbrauchbares, statt daran zu scheitern", async () => {
|
it("uebergeht Unbrauchbares, statt daran zu scheitern", async () => {
|
||||||
const kalender = await calendarByPaging(
|
const kalender = await calendarByPaging(
|
||||||
async () => [null, "gestern", "", JETZT.toISOString()],
|
eineSeite([null, "gestern", "", JETZT.toISOString()]),
|
||||||
JETZT,
|
JETZT,
|
||||||
);
|
);
|
||||||
expect(calendarTotal(kalender!)).toBe(1);
|
expect(calendarTotal(kalender!)).toBe(1);
|
||||||
@@ -114,9 +127,66 @@ describe("calendarByPaging", () => {
|
|||||||
it("laesst Tage ohne Commits weg", async () => {
|
it("laesst Tage ohne Commits weg", async () => {
|
||||||
// Ein Jahr als Liste waeren 365 Eintraege, die meisten davon Nullen.
|
// Ein Jahr als Liste waeren 365 Eintraege, die meisten davon Nullen.
|
||||||
const kalender = await calendarByPaging(
|
const kalender = await calendarByPaging(
|
||||||
async () => [JETZT.toISOString(), new Date(JETZT.getTime() - 30 * TAG).toISOString()],
|
eineSeite([JETZT.toISOString(), new Date(JETZT.getTime() - 30 * TAG).toISOString()]),
|
||||||
JETZT,
|
JETZT,
|
||||||
);
|
);
|
||||||
expect(Object.keys(kalender!.days)).toHaveLength(2);
|
expect(Object.keys(kalender!.days)).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gitea deckelt jede Antwort auf MAX_RESPONSE_ITEMS (Voreinstellung 50),
|
||||||
|
* egal was angefragt wurde. Wer gegen die angefragten 100 vergleicht, haelt
|
||||||
|
* dort jede erste Seite fuer die letzte: die Heatmap kannte nur die
|
||||||
|
* juengsten 50 Commits und gab deren aeltesten als Anfang des Zeitraums aus.
|
||||||
|
*/
|
||||||
|
describe("calendarByPaging gegen eine deckelnde Gegenseite", () => {
|
||||||
|
/** Antwortet nie mit mehr als `deckel` Eintraegen – wie Gitea. */
|
||||||
|
function gedeckelteQuelle(gesamt: number, deckel: number) {
|
||||||
|
const alle = commits(gesamt, 0);
|
||||||
|
return vi.fn(async (seite: number) =>
|
||||||
|
alle.slice((seite - 1) * deckel, seite * deckel),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("blaettert weiter, obwohl die Seite kleiner ist als angefragt", async () => {
|
||||||
|
// 76 Commits bei einem Deckel von 50 – der Fall aus florian.wilde/flimmer.
|
||||||
|
const ladeSeite = gedeckelteQuelle(76, 50);
|
||||||
|
const kalender = await calendarByPaging(ladeSeite, JETZT);
|
||||||
|
|
||||||
|
// Vorher: eine Seite, 50 Commits, "seit" beim 50. – und truncated false.
|
||||||
|
expect(ladeSeite).toHaveBeenCalledTimes(2);
|
||||||
|
expect(calendarTotal(kalender!)).toBe(76);
|
||||||
|
expect(kalender?.truncated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt die erste Antwort als Maßstab, nicht die angefragte Groesse", async () => {
|
||||||
|
// Selbst ein sehr kleiner Deckel darf nicht dazu fuehren, dass nach der
|
||||||
|
// ersten Seite Schluss ist.
|
||||||
|
const ladeSeite = gedeckelteQuelle(25, 10);
|
||||||
|
const kalender = await calendarByPaging(ladeSeite, JETZT);
|
||||||
|
|
||||||
|
expect(ladeSeite).toHaveBeenCalledTimes(3);
|
||||||
|
expect(calendarTotal(kalender!)).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("haelt den Deckel weiterhin ein", async () => {
|
||||||
|
// Mehr Commits als CALENDAR_MAX_PAGES Seiten fassen: abbrechen, aber
|
||||||
|
// ehrlich sein.
|
||||||
|
const ladeSeite = gedeckelteQuelle(50 * CALENDAR_MAX_PAGES + 20, 50);
|
||||||
|
const kalender = await calendarByPaging(ladeSeite, JETZT);
|
||||||
|
|
||||||
|
expect(ladeSeite).toHaveBeenCalledTimes(CALENDAR_MAX_PAGES);
|
||||||
|
expect(kalender?.truncated).toBe(true);
|
||||||
|
expect(calendarTotal(kalender!)).toBe(50 * CALENDAR_MAX_PAGES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet keine Kuerzung, wenn die Daten einfach zu Ende sind", async () => {
|
||||||
|
// Genau eine volle Seite und dann nichts mehr: das ist kein Deckel.
|
||||||
|
const ladeSeite = gedeckelteQuelle(50, 50);
|
||||||
|
const kalender = await calendarByPaging(ladeSeite, JETZT);
|
||||||
|
|
||||||
|
expect(ladeSeite).toHaveBeenCalledTimes(2);
|
||||||
|
expect(kalender?.truncated).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+31
-4
@@ -16,11 +16,20 @@
|
|||||||
export const CALENDAR_DAYS = 365;
|
export const CALENDAR_DAYS = 365;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hoechstzahl Seiten, wo seitenweise geholt werden muss. Fuenf Seiten sind
|
* Hoechstzahl Seiten, wo seitenweise geholt werden muss. Genug fuer die
|
||||||
* 500 Commits – genug fuer die allermeisten Repositories und wenig genug,
|
* allermeisten Repositories und wenig genug, um kein fremdes Kontingent zu
|
||||||
* um kein fremdes Kontingent zu sprengen.
|
* sprengen.
|
||||||
*/
|
*/
|
||||||
export const CALENDAR_MAX_PAGES = 5;
|
export const CALENDAR_MAX_PAGES = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Angefragte Seitengroesse – eine Bitte, keine Zusage.
|
||||||
|
*
|
||||||
|
* Gitea antwortet hoechstens mit MAX_RESPONSE_ITEMS Eintraegen
|
||||||
|
* (Voreinstellung 50), egal was hier steht; GitHub und GitLab erlauben 100.
|
||||||
|
* Wie viele wirklich kommen, entscheidet deshalb erst die erste Antwort –
|
||||||
|
* siehe calendarByPaging.
|
||||||
|
*/
|
||||||
export const CALENDAR_PAGE_SIZE = 100;
|
export const CALENDAR_PAGE_SIZE = 100;
|
||||||
|
|
||||||
export interface CommitCalendar {
|
export interface CommitCalendar {
|
||||||
@@ -55,6 +64,15 @@ export function calendarSince(now: Date = new Date()): Date {
|
|||||||
* Abgebrochen wird, sobald eine Seite nicht mehr voll ist (dann gibt es
|
* Abgebrochen wird, sobald eine Seite nicht mehr voll ist (dann gibt es
|
||||||
* nichts weiter), sobald die Seite vollstaendig aus der Zeit vor dem
|
* nichts weiter), sobald die Seite vollstaendig aus der Zeit vor dem
|
||||||
* Zeitraum stammt, oder wenn der Deckel greift.
|
* Zeitraum stammt, oder wenn der Deckel greift.
|
||||||
|
*
|
||||||
|
* "Voll" bemisst sich an der **ersten geliefertern Seite**, nicht an dem, was
|
||||||
|
* angefragt wurde. Gitea deckelt jede Antwort auf MAX_RESPONSE_ITEMS
|
||||||
|
* (Voreinstellung 50), unabhaengig vom angefragten limit. Wer gegen die
|
||||||
|
* angefragten 100 vergleicht, haelt bei Gitea jede erste Seite fuer die
|
||||||
|
* letzte – die Heatmap kannte deshalb nur die juengsten 50 Commits und gab
|
||||||
|
* deren aeltesten als Anfang des Zeitraums aus. Bei einem Repository mit 76
|
||||||
|
* Commits stand dort "50 an 2 Tagen seit dem 01.09.", und das las sich wie
|
||||||
|
* eine Aussage ueber das Repository statt ueber die Seitengroesse.
|
||||||
*/
|
*/
|
||||||
export async function calendarByPaging(
|
export async function calendarByPaging(
|
||||||
ladeSeite: (seite: number) => Promise<Array<string | null>>,
|
ladeSeite: (seite: number) => Promise<Array<string | null>>,
|
||||||
@@ -64,11 +82,18 @@ export async function calendarByPaging(
|
|||||||
const days: Record<string, number> = {};
|
const days: Record<string, number> = {};
|
||||||
let aeltester: number | null = null;
|
let aeltester: number | null = null;
|
||||||
let gedeckelt = false;
|
let gedeckelt = false;
|
||||||
|
/** Was die Gegenseite tatsaechlich als volle Seite ansieht. */
|
||||||
|
let seitengroesse: number | null = null;
|
||||||
|
|
||||||
for (let seite = 1; seite <= CALENDAR_MAX_PAGES; seite += 1) {
|
for (let seite = 1; seite <= CALENDAR_MAX_PAGES; seite += 1) {
|
||||||
const daten = await ladeSeite(seite);
|
const daten = await ladeSeite(seite);
|
||||||
if (daten.length === 0) break;
|
if (daten.length === 0) break;
|
||||||
|
|
||||||
|
// Die erste Antwort setzt den Maßstab. Sie kann kleiner ausfallen als
|
||||||
|
// angefragt; entscheidend ist nur, dass die folgenden Seiten daran
|
||||||
|
// gemessen werden.
|
||||||
|
if (seitengroesse === null) seitengroesse = daten.length;
|
||||||
|
|
||||||
let innerhalb = 0;
|
let innerhalb = 0;
|
||||||
for (const value of daten) {
|
for (const value of daten) {
|
||||||
if (!value) continue;
|
if (!value) continue;
|
||||||
@@ -85,10 +110,12 @@ export async function calendarByPaging(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Nicht mehr voll: das war die letzte Seite.
|
// Nicht mehr voll: das war die letzte Seite.
|
||||||
if (daten.length < CALENDAR_PAGE_SIZE) break;
|
if (daten.length < seitengroesse) break;
|
||||||
// Die ganze Seite lag schon vor dem Zeitraum – weiter zurueck lohnt nicht.
|
// Die ganze Seite lag schon vor dem Zeitraum – weiter zurueck lohnt nicht.
|
||||||
if (innerhalb === 0) break;
|
if (innerhalb === 0) break;
|
||||||
|
|
||||||
|
// Letzte erlaubte Seite war voll und lag im Zeitraum: es gibt Aelteres,
|
||||||
|
// das wir nicht geholt haben. Das gehoert an die Anzeige.
|
||||||
if (seite === CALENDAR_MAX_PAGES) gedeckelt = true;
|
if (seite === CALENDAR_MAX_PAGES) gedeckelt = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+110
-35
@@ -1,4 +1,12 @@
|
|||||||
import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http";
|
import {
|
||||||
|
Teilabrufe,
|
||||||
|
getJson,
|
||||||
|
getJsonSafe,
|
||||||
|
normalizeBaseUrl,
|
||||||
|
sendJson,
|
||||||
|
shortSha,
|
||||||
|
toIso,
|
||||||
|
} from "./http";
|
||||||
import {
|
import {
|
||||||
CALENDAR_PAGE_SIZE,
|
CALENDAR_PAGE_SIZE,
|
||||||
calendarByPaging,
|
calendarByPaging,
|
||||||
@@ -62,7 +70,11 @@ export class GiteaClient implements GitClient {
|
|||||||
return { number: angelegt.number, url: angelegt.html_url ?? null };
|
return { number: angelegt.number, url: angelegt.html_url ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> {
|
async setIssueState(
|
||||||
|
ref: RepoRef,
|
||||||
|
number: number,
|
||||||
|
open: boolean,
|
||||||
|
): Promise<void> {
|
||||||
await sendJson(
|
await sendJson(
|
||||||
`${this.repoBase(ref)}/issues/${number}`,
|
`${this.repoBase(ref)}/issues/${number}`,
|
||||||
"PATCH",
|
"PATCH",
|
||||||
@@ -71,7 +83,10 @@ export class GiteaClient implements GitClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
|
async getIssueState(
|
||||||
|
ref: RepoRef,
|
||||||
|
number: number,
|
||||||
|
): Promise<"open" | "closed" | null> {
|
||||||
const issue = await getJson<{ state?: string } | null>(
|
const issue = await getJson<{ state?: string } | null>(
|
||||||
`${this.repoBase(ref)}/issues/${number}`,
|
`${this.repoBase(ref)}/issues/${number}`,
|
||||||
{ headers: this.headers(), allow404: true },
|
{ headers: this.headers(), allow404: true },
|
||||||
@@ -97,9 +112,12 @@ export class GiteaClient implements GitClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getJson<{ login: string; full_name?: string }>(`${this.apiBase}/user`, {
|
const user = await getJson<{ login: string; full_name?: string }>(
|
||||||
headers: this.headers(),
|
`${this.apiBase}/user`,
|
||||||
});
|
{
|
||||||
|
headers: this.headers(),
|
||||||
|
},
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
message: `Verbunden als ${user.login}${version.version ? ` (Gitea ${version.version})` : ""}`,
|
message: `Verbunden als ${user.login}${version.version ? ` (Gitea ${version.version})` : ""}`,
|
||||||
@@ -107,7 +125,10 @@ export class GiteaClient implements GitClient {
|
|||||||
version: version.version ?? null,
|
version: version.version ?? null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +144,9 @@ export class GiteaClient implements GitClient {
|
|||||||
const needle = query?.toLowerCase().trim();
|
const needle = query?.toLowerCase().trim();
|
||||||
|
|
||||||
return repos
|
return repos
|
||||||
.filter((repo) => !needle || repo.full_name.toLowerCase().includes(needle))
|
.filter(
|
||||||
|
(repo) => !needle || repo.full_name.toLowerCase().includes(needle),
|
||||||
|
)
|
||||||
.map((repo) => ({
|
.map((repo) => ({
|
||||||
fullPath: repo.full_name,
|
fullPath: repo.full_name,
|
||||||
owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "",
|
owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "",
|
||||||
@@ -144,31 +167,76 @@ export class GiteaClient implements GitClient {
|
|||||||
|
|
||||||
const defaultBranch = repo.default_branch ?? "";
|
const defaultBranch = repo.default_branch ?? "";
|
||||||
|
|
||||||
const [branches, tags, commits, releases, languages, pulls, issues, status] =
|
// Sammelt, welche Liste nicht geholt werden konnte und warum. Frueher
|
||||||
await Promise.all([
|
// stand hier getJsonSafe, das jeden Fehler in eine leere Liste verwandelt
|
||||||
getJsonSafe<GiteaBranch[]>(`${base}/branches?limit=50`, [], { headers }),
|
// hat – siehe Teilabrufe.
|
||||||
getJsonSafe<GiteaTag[]>(`${base}/tags?limit=25`, [], { headers }),
|
const teil = new Teilabrufe();
|
||||||
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
|
||||||
getJsonSafe<GiteaCommit[]>(`${base}/commits?limit=100&stat=false`, [], { headers }),
|
const [
|
||||||
getJsonSafe<GiteaRelease[]>(`${base}/releases?limit=10`, [], { headers }),
|
branches,
|
||||||
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
tags,
|
||||||
getJsonSafe<GiteaPull[]>(`${base}/pulls?state=open&limit=50`, [], { headers }),
|
commits,
|
||||||
// type=issues, sonst kommen bei Gitea die Pull Requests mit.
|
releases,
|
||||||
getJsonSafe<GiteaIssue[]>(`${base}/issues?state=open&type=issues&limit=50`, [], {
|
languages,
|
||||||
|
pulls,
|
||||||
|
issues,
|
||||||
|
status,
|
||||||
|
] = await Promise.all([
|
||||||
|
teil.hol<GiteaBranch[]>("branches", `${base}/branches?limit=50`, [], {
|
||||||
|
headers,
|
||||||
|
}),
|
||||||
|
teil.hol<GiteaTag[]>("tags", `${base}/tags?limit=25`, [], { headers }),
|
||||||
|
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||||||
|
// Gitea liefert davon hoechstens MAX_RESPONSE_ITEMS (Voreinstellung 50).
|
||||||
|
// Das ist hier hinnehmbar - die Kurve kuerzt sich ehrlich auf den
|
||||||
|
// tatsaechlich belegten Zeitraum (siehe bucketCommitActivity), und
|
||||||
|
// blaettern wuerde jeden Abgleich teurer machen. Beim Kalender, der
|
||||||
|
// hoechstens einmal am Tag laeuft, wird dagegen geblaettert.
|
||||||
|
teil.hol<GiteaCommit[]>(
|
||||||
|
"commits",
|
||||||
|
`${base}/commits?limit=100&stat=false`,
|
||||||
|
[],
|
||||||
|
{
|
||||||
headers,
|
headers,
|
||||||
}),
|
},
|
||||||
// Gitea meldet CI-Ergebnisse als Commit-Status. Das deckt Gitea
|
),
|
||||||
// Actions und externe Systeme wie Woodpecker gleichermassen ab –
|
teil.hol<GiteaRelease[]>("releases", `${base}/releases?limit=10`, [], {
|
||||||
// anders als ein reiner Actions-Endpunkt, den aeltere Instanzen
|
headers,
|
||||||
// ohnehin nicht kennen.
|
}),
|
||||||
defaultBranch
|
teil.hol<Record<string, number>>(
|
||||||
? getJsonSafe<GiteaCombinedStatus>(
|
"languages",
|
||||||
`${base}/commits/${encodeURIComponent(defaultBranch)}/status`,
|
`${base}/languages`,
|
||||||
{} as GiteaCombinedStatus,
|
{},
|
||||||
{ headers },
|
{ headers },
|
||||||
)
|
),
|
||||||
: Promise.resolve({} as GiteaCombinedStatus),
|
teil.hol<GiteaPull[]>(
|
||||||
]);
|
"pullRequests",
|
||||||
|
`${base}/pulls?state=open&limit=50`,
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// type=issues, sonst kommen bei Gitea die Pull Requests mit.
|
||||||
|
teil.hol<GiteaIssue[]>(
|
||||||
|
"issues",
|
||||||
|
`${base}/issues?state=open&type=issues&limit=50`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
// Gitea meldet CI-Ergebnisse als Commit-Status. Das deckt Gitea
|
||||||
|
// Actions und externe Systeme wie Woodpecker gleichermassen ab –
|
||||||
|
// anders als ein reiner Actions-Endpunkt, den aeltere Instanzen
|
||||||
|
// ohnehin nicht kennen.
|
||||||
|
defaultBranch
|
||||||
|
? teil.hol<GiteaCombinedStatus>(
|
||||||
|
"ci",
|
||||||
|
`${base}/commits/${encodeURIComponent(defaultBranch)}/status`,
|
||||||
|
{} as GiteaCombinedStatus,
|
||||||
|
{ headers },
|
||||||
|
)
|
||||||
|
: Promise.resolve({} as GiteaCombinedStatus),
|
||||||
|
]);
|
||||||
|
|
||||||
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
||||||
name: branch.name,
|
name: branch.name,
|
||||||
@@ -186,7 +254,9 @@ export class GiteaClient implements GitClient {
|
|||||||
sha: tag.commit?.sha ?? tag.id ?? null,
|
sha: tag.commit?.sha ?? tag.id ?? null,
|
||||||
message: tag.message ?? null,
|
message: tag.message ?? null,
|
||||||
date: toIso(tag.commit?.created),
|
date: toIso(tag.commit?.created),
|
||||||
url: tag.zipball_url ? `${repo.html_url}/releases/tag/${encodeURIComponent(tag.name)}` : null,
|
url: tag.zipball_url
|
||||||
|
? `${repo.html_url}/releases/tag/${encodeURIComponent(tag.name)}`
|
||||||
|
: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mappedCommits: CommitInfo[] = commits.map((commit) => ({
|
const mappedCommits: CommitInfo[] = commits.map((commit) => ({
|
||||||
@@ -259,7 +329,10 @@ export class GiteaClient implements GitClient {
|
|||||||
pullRequests: mappedPulls,
|
pullRequests: mappedPulls,
|
||||||
issues: mappedIssues,
|
issues: mappedIssues,
|
||||||
ci,
|
ci,
|
||||||
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
|
commitActivity: bucketCommitActivity(
|
||||||
|
mappedCommits.map((commit) => commit.date),
|
||||||
|
),
|
||||||
|
missing: teil.fehler,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +371,9 @@ export class GiteaClient implements GitClient {
|
|||||||
{ headers },
|
{ headers },
|
||||||
);
|
);
|
||||||
const name = pickReadme(
|
const name = pickReadme(
|
||||||
eintraege.filter((eintrag) => eintrag.type === "file").map((eintrag) => eintrag.name),
|
eintraege
|
||||||
|
.filter((eintrag) => eintrag.type === "file")
|
||||||
|
.map((eintrag) => eintrag.name),
|
||||||
);
|
);
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
|
|
||||||
|
|||||||
+129
-38
@@ -1,4 +1,12 @@
|
|||||||
import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http";
|
import {
|
||||||
|
Teilabrufe,
|
||||||
|
getJson,
|
||||||
|
getJsonSafe,
|
||||||
|
normalizeBaseUrl,
|
||||||
|
sendJson,
|
||||||
|
shortSha,
|
||||||
|
toIso,
|
||||||
|
} from "./http";
|
||||||
import type {
|
import type {
|
||||||
BranchInfo,
|
BranchInfo,
|
||||||
CiInfo,
|
CiInfo,
|
||||||
@@ -31,7 +39,9 @@ export class GitHubClient implements GitClient {
|
|||||||
|
|
||||||
constructor(config: ProviderConfig) {
|
constructor(config: ProviderConfig) {
|
||||||
const base = normalizeBaseUrl(config.baseUrl || "https://github.com");
|
const base = normalizeBaseUrl(config.baseUrl || "https://github.com");
|
||||||
this.apiBase = /github\.com$/i.test(base) ? "https://api.github.com" : `${base}/api/v3`;
|
this.apiBase = /github\.com$/i.test(base)
|
||||||
|
? "https://api.github.com"
|
||||||
|
: `${base}/api/v3`;
|
||||||
this.token = config.token;
|
this.token = config.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +71,11 @@ export class GitHubClient implements GitClient {
|
|||||||
return { number: angelegt.number, url: angelegt.html_url ?? null };
|
return { number: angelegt.number, url: angelegt.html_url ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> {
|
async setIssueState(
|
||||||
|
ref: RepoRef,
|
||||||
|
number: number,
|
||||||
|
open: boolean,
|
||||||
|
): Promise<void> {
|
||||||
await sendJson(
|
await sendJson(
|
||||||
`${this.repoBase(ref)}/issues/${number}`,
|
`${this.repoBase(ref)}/issues/${number}`,
|
||||||
"PATCH",
|
"PATCH",
|
||||||
@@ -70,7 +84,10 @@ export class GitHubClient implements GitClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
|
async getIssueState(
|
||||||
|
ref: RepoRef,
|
||||||
|
number: number,
|
||||||
|
): Promise<"open" | "closed" | null> {
|
||||||
const issue = await getJson<{ state?: string } | null>(
|
const issue = await getJson<{ state?: string } | null>(
|
||||||
`${this.repoBase(ref)}/issues/${number}`,
|
`${this.repoBase(ref)}/issues/${number}`,
|
||||||
{ headers: this.headers(), allow404: true },
|
{ headers: this.headers(), allow404: true },
|
||||||
@@ -82,25 +99,42 @@ export class GitHubClient implements GitClient {
|
|||||||
async check(): Promise<ConnectionCheck> {
|
async check(): Promise<ConnectionCheck> {
|
||||||
try {
|
try {
|
||||||
if (!this.token) {
|
if (!this.token) {
|
||||||
await getJson(`${this.apiBase}/rate_limit`, { headers: this.headers() });
|
await getJson(`${this.apiBase}/rate_limit`, {
|
||||||
|
headers: this.headers(),
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
message: "Erreichbar (ohne Token – nur oeffentliche Repos, striktes Rate-Limit).",
|
message:
|
||||||
|
"Erreichbar (ohne Token – nur oeffentliche Repos, striktes Rate-Limit).",
|
||||||
user: null,
|
user: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const user = await getJson<{ login: string; name?: string }>(`${this.apiBase}/user`, {
|
const user = await getJson<{ login: string; name?: string }>(
|
||||||
headers: this.headers(),
|
`${this.apiBase}/user`,
|
||||||
});
|
{
|
||||||
return { ok: true, message: `Verbunden als ${user.login}`, user: user.login };
|
headers: this.headers(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: `Verbunden als ${user.login}`,
|
||||||
|
user: user.login,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async listRepos(query?: string): Promise<RepoListItem[]> {
|
async listRepos(query?: string): Promise<RepoListItem[]> {
|
||||||
if (!this.token) {
|
if (!this.token) {
|
||||||
throw new GitApiError("Fuer das Auflisten von Repos wird ein Token benoetigt.", 401, this.apiBase);
|
throw new GitApiError(
|
||||||
|
"Fuer das Auflisten von Repos wird ein Token benoetigt.",
|
||||||
|
401,
|
||||||
|
this.apiBase,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const repos = await getJson<GitHubRepo[]>(
|
const repos = await getJson<GitHubRepo[]>(
|
||||||
`${this.apiBase}/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member`,
|
`${this.apiBase}/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member`,
|
||||||
@@ -108,7 +142,9 @@ export class GitHubClient implements GitClient {
|
|||||||
);
|
);
|
||||||
const needle = query?.toLowerCase().trim();
|
const needle = query?.toLowerCase().trim();
|
||||||
return (repos ?? [])
|
return (repos ?? [])
|
||||||
.filter((repo) => !needle || repo.full_name.toLowerCase().includes(needle))
|
.filter(
|
||||||
|
(repo) => !needle || repo.full_name.toLowerCase().includes(needle),
|
||||||
|
)
|
||||||
.map((repo) => ({
|
.map((repo) => ({
|
||||||
fullPath: repo.full_name,
|
fullPath: repo.full_name,
|
||||||
owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "",
|
owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "",
|
||||||
@@ -129,23 +165,63 @@ export class GitHubClient implements GitClient {
|
|||||||
|
|
||||||
const defaultBranch = repo.default_branch;
|
const defaultBranch = repo.default_branch;
|
||||||
|
|
||||||
const [branches, tags, commits, releases, languages, pulls, issues, ciRuns] =
|
// Sammelt, welche Liste nicht geholt werden konnte und warum.
|
||||||
await Promise.all([
|
const teil = new Teilabrufe();
|
||||||
getJsonSafe<GitHubBranch[]>(`${base}/branches?per_page=50`, [], { headers }),
|
|
||||||
getJsonSafe<GitHubTag[]>(`${base}/tags?per_page=25`, [], { headers }),
|
const [
|
||||||
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
branches,
|
||||||
getJsonSafe<GitHubCommit[]>(`${base}/commits?per_page=100`, [], { headers }),
|
tags,
|
||||||
getJsonSafe<GitHubRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
|
commits,
|
||||||
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
releases,
|
||||||
getJsonSafe<GitHubPull[]>(`${base}/pulls?state=open&per_page=50`, [], { headers }),
|
languages,
|
||||||
// /issues liefert bei GitHub auch Pull Requests – die filtern wir raus.
|
pulls,
|
||||||
getJsonSafe<GitHubIssue[]>(`${base}/issues?state=open&per_page=50`, [], { headers }),
|
issues,
|
||||||
getJsonSafe<{ workflow_runs?: GitHubRun[] }>(
|
ciRuns,
|
||||||
`${base}/actions/runs?per_page=1&branch=${encodeURIComponent(defaultBranch)}`,
|
] = await Promise.all([
|
||||||
{},
|
teil.hol<GitHubBranch[]>("branches", `${base}/branches?per_page=50`, [], {
|
||||||
{ headers },
|
headers,
|
||||||
),
|
}),
|
||||||
]);
|
teil.hol<GitHubTag[]>("tags", `${base}/tags?per_page=25`, [], {
|
||||||
|
headers,
|
||||||
|
}),
|
||||||
|
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||||||
|
// Kommen weniger an, kuerzt sich die Kurve ehrlich auf den tatsaechlich
|
||||||
|
// belegten Zeitraum (siehe bucketCommitActivity).
|
||||||
|
teil.hol<GitHubCommit[]>("commits", `${base}/commits?per_page=100`, [], {
|
||||||
|
headers,
|
||||||
|
}),
|
||||||
|
teil.hol<GitHubRelease[]>(
|
||||||
|
"releases",
|
||||||
|
`${base}/releases?per_page=10`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
teil.hol<Record<string, number>>(
|
||||||
|
"languages",
|
||||||
|
`${base}/languages`,
|
||||||
|
{},
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
teil.hol<GitHubPull[]>(
|
||||||
|
"pullRequests",
|
||||||
|
`${base}/pulls?state=open&per_page=50`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
// /issues liefert bei GitHub auch Pull Requests – die filtern wir raus.
|
||||||
|
teil.hol<GitHubIssue[]>(
|
||||||
|
"issues",
|
||||||
|
`${base}/issues?state=open&per_page=50`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
teil.hol<{ workflow_runs?: GitHubRun[] }>(
|
||||||
|
"ci",
|
||||||
|
`${base}/actions/runs?per_page=1&branch=${encodeURIComponent(defaultBranch)}`,
|
||||||
|
{},
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
||||||
name: branch.name,
|
name: branch.name,
|
||||||
@@ -208,7 +284,10 @@ export class GitHubClient implements GitClient {
|
|||||||
|
|
||||||
// open_issues_count zaehlt bei GitHub PRs mit – hier wieder herausrechnen.
|
// open_issues_count zaehlt bei GitHub PRs mit – hier wieder herausrechnen.
|
||||||
const openPullRequests = pulls.length;
|
const openPullRequests = pulls.length;
|
||||||
const openIssues = Math.max(0, (repo.open_issues_count ?? 0) - openPullRequests);
|
const openIssues = Math.max(
|
||||||
|
0,
|
||||||
|
(repo.open_issues_count ?? 0) - openPullRequests,
|
||||||
|
);
|
||||||
|
|
||||||
const run = ciRuns?.workflow_runs?.[0];
|
const run = ciRuns?.workflow_runs?.[0];
|
||||||
const ci: CiInfo | null = run
|
const ci: CiInfo | null = run
|
||||||
@@ -242,7 +321,10 @@ export class GitHubClient implements GitClient {
|
|||||||
pullRequests: mappedPulls,
|
pullRequests: mappedPulls,
|
||||||
issues: mappedIssues,
|
issues: mappedIssues,
|
||||||
ci,
|
ci,
|
||||||
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
|
commitActivity: bucketCommitActivity(
|
||||||
|
mappedCommits.map((commit) => commit.date),
|
||||||
|
),
|
||||||
|
missing: teil.fehler,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,9 +342,12 @@ export class GitHubClient implements GitClient {
|
|||||||
*/
|
*/
|
||||||
async fetchCommitCalendar(ref: RepoRef): Promise<CommitCalendar | null> {
|
async fetchCommitCalendar(ref: RepoRef): Promise<CommitCalendar | null> {
|
||||||
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
|
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
|
||||||
const wochen = await getJson<GitHubWeek[]>(`${base}/stats/commit_activity`, {
|
const wochen = await getJson<GitHubWeek[]>(
|
||||||
headers: this.headers(),
|
`${base}/stats/commit_activity`,
|
||||||
});
|
{
|
||||||
|
headers: this.headers(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!Array.isArray(wochen) || wochen.length === 0) {
|
if (!Array.isArray(wochen) || wochen.length === 0) {
|
||||||
// Entweder rechnet GitHub noch, oder das Repository hat keine Commits.
|
// Entweder rechnet GitHub noch, oder das Repository hat keine Commits.
|
||||||
@@ -300,7 +385,9 @@ export class GitHubClient implements GitClient {
|
|||||||
|
|
||||||
let datei: GitHubContent;
|
let datei: GitHubContent;
|
||||||
try {
|
try {
|
||||||
datei = await getJson<GitHubContent>(`${base}/readme`, { headers: this.headers() });
|
datei = await getJson<GitHubContent>(`${base}/readme`, {
|
||||||
|
headers: this.headers(),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof GitApiError && error.status === 404) return null;
|
if (error instanceof GitApiError && error.status === 404) return null;
|
||||||
throw error;
|
throw error;
|
||||||
@@ -337,8 +424,12 @@ interface GitHubWeek {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** GitHub trennt Lauf-Status und Ergebnis; hier auf einen Wert zusammenfuehren. */
|
/** GitHub trennt Lauf-Status und Ergebnis; hier auf einen Wert zusammenfuehren. */
|
||||||
function mapRunState(status: string | null, conclusion: string | null): CiState {
|
function mapRunState(
|
||||||
if (status === "queued" || status === "waiting" || status === "pending") return "pending";
|
status: string | null,
|
||||||
|
conclusion: string | null,
|
||||||
|
): CiState {
|
||||||
|
if (status === "queued" || status === "waiting" || status === "pending")
|
||||||
|
return "pending";
|
||||||
if (status === "in_progress") return "running";
|
if (status === "in_progress") return "running";
|
||||||
switch (conclusion) {
|
switch (conclusion) {
|
||||||
case "success":
|
case "success":
|
||||||
|
|||||||
+135
-38
@@ -1,4 +1,12 @@
|
|||||||
import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http";
|
import {
|
||||||
|
Teilabrufe,
|
||||||
|
getJson,
|
||||||
|
getJsonSafe,
|
||||||
|
normalizeBaseUrl,
|
||||||
|
sendJson,
|
||||||
|
shortSha,
|
||||||
|
toIso,
|
||||||
|
} from "./http";
|
||||||
import {
|
import {
|
||||||
CALENDAR_PAGE_SIZE,
|
CALENDAR_PAGE_SIZE,
|
||||||
calendarByPaging,
|
calendarByPaging,
|
||||||
@@ -61,7 +69,11 @@ export class GitLabClient implements GitClient {
|
|||||||
return { number: angelegt.iid, url: angelegt.web_url ?? null };
|
return { number: angelegt.iid, url: angelegt.web_url ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> {
|
async setIssueState(
|
||||||
|
ref: RepoRef,
|
||||||
|
number: number,
|
||||||
|
open: boolean,
|
||||||
|
): Promise<void> {
|
||||||
await sendJson(
|
await sendJson(
|
||||||
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
|
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
|
||||||
"PUT",
|
"PUT",
|
||||||
@@ -70,7 +82,10 @@ export class GitLabClient implements GitClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
|
async getIssueState(
|
||||||
|
ref: RepoRef,
|
||||||
|
number: number,
|
||||||
|
): Promise<"open" | "closed" | null> {
|
||||||
const issue = await getJson<{ state?: string } | null>(
|
const issue = await getJson<{ state?: string } | null>(
|
||||||
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
|
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
|
||||||
{ headers: this.headers(), allow404: true },
|
{ headers: this.headers(), allow404: true },
|
||||||
@@ -83,15 +98,31 @@ export class GitLabClient implements GitClient {
|
|||||||
async check(): Promise<ConnectionCheck> {
|
async check(): Promise<ConnectionCheck> {
|
||||||
try {
|
try {
|
||||||
if (!this.token) {
|
if (!this.token) {
|
||||||
await getJson(`${this.apiBase}/projects?per_page=1`, { headers: this.headers() });
|
await getJson(`${this.apiBase}/projects?per_page=1`, {
|
||||||
return { ok: true, message: "Erreichbar (ohne Token – nur oeffentliche Projekte).", user: null };
|
headers: this.headers(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: "Erreichbar (ohne Token – nur oeffentliche Projekte).",
|
||||||
|
user: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const user = await getJson<{ username: string; name?: string }>(`${this.apiBase}/user`, {
|
const user = await getJson<{ username: string; name?: string }>(
|
||||||
headers: this.headers(),
|
`${this.apiBase}/user`,
|
||||||
});
|
{
|
||||||
return { ok: true, message: `Verbunden als ${user.username}`, user: user.username };
|
headers: this.headers(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: `Verbunden als ${user.username}`,
|
||||||
|
user: user.username,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +132,9 @@ export class GitLabClient implements GitClient {
|
|||||||
? `${this.apiBase}/projects?membership=true&per_page=100&order_by=last_activity_at${search}`
|
? `${this.apiBase}/projects?membership=true&per_page=100&order_by=last_activity_at${search}`
|
||||||
: `${this.apiBase}/projects?per_page=50&order_by=last_activity_at${search}`;
|
: `${this.apiBase}/projects?per_page=50&order_by=last_activity_at${search}`;
|
||||||
|
|
||||||
const projects = await getJson<GitLabProject[]>(url, { headers: this.headers() });
|
const projects = await getJson<GitLabProject[]>(url, {
|
||||||
|
headers: this.headers(),
|
||||||
|
});
|
||||||
return (projects ?? []).map((project) => {
|
return (projects ?? []).map((project) => {
|
||||||
const path = project.path_with_namespace;
|
const path = project.path_with_namespace;
|
||||||
const segments = path.split("/");
|
const segments = path.split("/");
|
||||||
@@ -127,35 +160,85 @@ export class GitLabClient implements GitClient {
|
|||||||
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
|
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
|
||||||
const headers = this.headers();
|
const headers = this.headers();
|
||||||
|
|
||||||
const project = await getJson<GitLabProject>(`${base}?statistics=true`, { headers });
|
const project = await getJson<GitLabProject>(`${base}?statistics=true`, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
const defaultBranch = project.default_branch ?? "";
|
const defaultBranch = project.default_branch ?? "";
|
||||||
|
|
||||||
const [branches, tags, commits, releases, languages, mergeRequests, issues, pipelines] =
|
// Sammelt, welche Liste nicht geholt werden konnte und warum.
|
||||||
await Promise.all([
|
const teil = new Teilabrufe();
|
||||||
getJsonSafe<GitLabBranch[]>(`${base}/repository/branches?per_page=50`, [], { headers }),
|
|
||||||
getJsonSafe<GitLabTag[]>(`${base}/repository/tags?per_page=25`, [], { headers }),
|
const [
|
||||||
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
branches,
|
||||||
getJsonSafe<GitLabCommit[]>(`${base}/repository/commits?per_page=100`, [], { headers }),
|
tags,
|
||||||
getJsonSafe<GitLabRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
|
commits,
|
||||||
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
releases,
|
||||||
getJsonSafe<GitLabMergeRequest[]>(`${base}/merge_requests?state=opened&per_page=50`, [], {
|
languages,
|
||||||
|
mergeRequests,
|
||||||
|
issues,
|
||||||
|
pipelines,
|
||||||
|
] = await Promise.all([
|
||||||
|
teil.hol<GitLabBranch[]>(
|
||||||
|
"branches",
|
||||||
|
`${base}/repository/branches?per_page=50`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
teil.hol<GitLabTag[]>("tags", `${base}/repository/tags?per_page=25`, [], {
|
||||||
|
headers,
|
||||||
|
}),
|
||||||
|
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||||||
|
// Kommen weniger an, kuerzt sich die Kurve ehrlich auf den tatsaechlich
|
||||||
|
// belegten Zeitraum (siehe bucketCommitActivity).
|
||||||
|
teil.hol<GitLabCommit[]>(
|
||||||
|
"commits",
|
||||||
|
`${base}/repository/commits?per_page=100`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
teil.hol<GitLabRelease[]>(
|
||||||
|
"releases",
|
||||||
|
`${base}/releases?per_page=10`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
teil.hol<Record<string, number>>(
|
||||||
|
"languages",
|
||||||
|
`${base}/languages`,
|
||||||
|
{},
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
teil.hol<GitLabMergeRequest[]>(
|
||||||
|
"pullRequests",
|
||||||
|
`${base}/merge_requests?state=opened&per_page=50`,
|
||||||
|
[],
|
||||||
|
{
|
||||||
headers,
|
headers,
|
||||||
}),
|
},
|
||||||
getJsonSafe<GitLabIssue[]>(`${base}/issues?state=opened&per_page=50`, [], { headers }),
|
),
|
||||||
getJsonSafe<GitLabPipeline[]>(
|
teil.hol<GitLabIssue[]>(
|
||||||
`${base}/pipelines?per_page=1${defaultBranch ? `&ref=${encodeURIComponent(defaultBranch)}` : ""}`,
|
"issues",
|
||||||
[],
|
`${base}/issues?state=opened&per_page=50`,
|
||||||
{ headers },
|
[],
|
||||||
),
|
{ headers },
|
||||||
]);
|
),
|
||||||
|
teil.hol<GitLabPipeline[]>(
|
||||||
|
"ci",
|
||||||
|
`${base}/pipelines?per_page=1${defaultBranch ? `&ref=${encodeURIComponent(defaultBranch)}` : ""}`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
||||||
name: branch.name,
|
name: branch.name,
|
||||||
sha: branch.commit?.id ?? null,
|
sha: branch.commit?.id ?? null,
|
||||||
isDefault: Boolean(branch.default),
|
isDefault: Boolean(branch.default),
|
||||||
protected: Boolean(branch.protected),
|
protected: Boolean(branch.protected),
|
||||||
lastCommitAt: toIso(branch.commit?.committed_date ?? branch.commit?.created_at),
|
lastCommitAt: toIso(
|
||||||
|
branch.commit?.committed_date ?? branch.commit?.created_at,
|
||||||
|
),
|
||||||
lastCommitMessage: branch.commit?.title ?? null,
|
lastCommitMessage: branch.commit?.title ?? null,
|
||||||
lastCommitAuthor: branch.commit?.author_name ?? null,
|
lastCommitAuthor: branch.commit?.author_name ?? null,
|
||||||
url: branch.web_url ?? null,
|
url: branch.web_url ?? null,
|
||||||
@@ -240,7 +323,10 @@ export class GitLabClient implements GitClient {
|
|||||||
pullRequests: mappedMergeRequests,
|
pullRequests: mappedMergeRequests,
|
||||||
issues: mappedIssues,
|
issues: mappedIssues,
|
||||||
ci,
|
ci,
|
||||||
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
|
commitActivity: bucketCommitActivity(
|
||||||
|
mappedCommits.map((commit) => commit.date),
|
||||||
|
),
|
||||||
|
missing: teil.fehler,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +346,9 @@ export class GitLabClient implements GitClient {
|
|||||||
`&since=${encodeURIComponent(seit)}`,
|
`&since=${encodeURIComponent(seit)}`,
|
||||||
{ headers: this.headers() },
|
{ headers: this.headers() },
|
||||||
);
|
);
|
||||||
return commits.map((commit) => toIso(commit.committed_date ?? commit.created_at));
|
return commits.map((commit) =>
|
||||||
|
toIso(commit.committed_date ?? commit.created_at),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,18 +366,27 @@ export class GitLabClient implements GitClient {
|
|||||||
{ headers },
|
{ headers },
|
||||||
);
|
);
|
||||||
const name = pickReadme(
|
const name = pickReadme(
|
||||||
eintraege.filter((eintrag) => eintrag.type === "blob").map((eintrag) => eintrag.name),
|
eintraege
|
||||||
|
.filter((eintrag) => eintrag.type === "blob")
|
||||||
|
.map((eintrag) => eintrag.name),
|
||||||
);
|
);
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
|
|
||||||
const datei = await getJson<{ content?: string; encoding?: string; file_path?: string }>(
|
const datei = await getJson<{
|
||||||
`${base}/repository/files/${encodeURIComponent(name)}?ref=HEAD`,
|
content?: string;
|
||||||
{ headers },
|
encoding?: string;
|
||||||
);
|
file_path?: string;
|
||||||
|
}>(`${base}/repository/files/${encodeURIComponent(name)}?ref=HEAD`, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
if (!datei?.content || datei.encoding !== "base64") return null;
|
if (!datei?.content || datei.encoding !== "base64") return null;
|
||||||
|
|
||||||
const { content, truncated } = capReadme(decodeBase64(datei.content));
|
const { content, truncated } = capReadme(decodeBase64(datei.content));
|
||||||
const projekt = await getJsonSafe<GitLabProject>(base, {} as GitLabProject, { headers });
|
const projekt = await getJsonSafe<GitLabProject>(
|
||||||
|
base,
|
||||||
|
{} as GitLabProject,
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content,
|
content,
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Teilabrufe, zeitlimit } from "./http";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diese beiden Stellen haben zusammen einen Ausfall verursacht, den niemand
|
||||||
|
* sehen konnte: Der Abgleich holte die Listen eines Repositories, verlor sie
|
||||||
|
* unterwegs, verwandelte jeden Fehler in eine leere Liste und meldete
|
||||||
|
* "Snapshot aktualisiert: 0 Branches, 0 Tags". Aus der Oberflaeche war nicht
|
||||||
|
* zu erkennen, dass ueberhaupt etwas schiefgegangen war.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("zeitlimit", () => {
|
||||||
|
it("nimmt einen brauchbaren Wert", () => {
|
||||||
|
expect(zeitlimit("30000")).toBe(30000);
|
||||||
|
expect(zeitlimit(" 20000 ")).toBe(20000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("faellt ohne Angabe auf die Vorgabe zurueck", () => {
|
||||||
|
expect(zeitlimit(undefined)).toBe(15000);
|
||||||
|
expect(zeitlimit("")).toBe(15000);
|
||||||
|
expect(zeitlimit(" ")).toBe(15000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("faengt genau die Werte ab, die alles stillgelegt haetten", () => {
|
||||||
|
// Number("") ist 0, Number("abc") ist NaN. setTimeout loest bei beidem
|
||||||
|
// sofort aus – jeder ausgehende Aufruf braeche ab, bevor er beginnt,
|
||||||
|
// und der Abgleich meldete ueberall leere Listen.
|
||||||
|
const warnungen: string[] = [];
|
||||||
|
const merken = (text: string) => warnungen.push(text);
|
||||||
|
|
||||||
|
expect(zeitlimit("0", merken)).toBe(15000);
|
||||||
|
expect(zeitlimit("abc", merken)).toBe(15000);
|
||||||
|
expect(zeitlimit("-5000", merken)).toBe(15000);
|
||||||
|
expect(zeitlimit("500", merken)).toBe(15000);
|
||||||
|
|
||||||
|
// Und zwar nicht stillschweigend.
|
||||||
|
expect(warnungen).toHaveLength(4);
|
||||||
|
for (const warnung of warnungen) {
|
||||||
|
expect(warnung).toContain("GIT_API_TIMEOUT_MS");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Teilabrufe", () => {
|
||||||
|
// Vor jedem Aufruf prueft der SSRF-Schutz das Ziel und loest dafuer den
|
||||||
|
// Hostnamen auf. Eine ausdrueckliche Freigabe ueberspringt das – sonst
|
||||||
|
// haengt der Test an einem DNS-Server und misst nicht, was er messen will.
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubEnv("GIT_ALLOWED_HOSTS", "git.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
// Ohne das zaehlen die Spies ueber Tests hinweg weiter – und genau an
|
||||||
|
// Aufrufzahlen haengen die Pruefungen hier.
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet nichts, solange alles ankommt", async () => {
|
||||||
|
const teil = new Teilabrufe();
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(JSON.stringify([{ name: "main" }]), { status: 200 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const wert = await teil.hol("branches", "https://git.example.com/api/v1/x", []);
|
||||||
|
|
||||||
|
expect(wert).toEqual([{ name: "main" }]);
|
||||||
|
expect(teil.fehler).toEqual({});
|
||||||
|
expect(teil.unvollstaendig).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt den Fallback zurueck, haelt aber den Grund fest", async () => {
|
||||||
|
// Der Kern des Fehlers: vorher war dieser Fall von "das Repository hat
|
||||||
|
// keine Branches" nicht zu unterscheiden.
|
||||||
|
const teil = new Teilabrufe();
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response("no scopes", { status: 403 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const wert = await teil.hol("branches", "https://git.example.com/api/v1/x", []);
|
||||||
|
|
||||||
|
expect(wert).toEqual([]);
|
||||||
|
expect(teil.unvollstaendig).toBe(true);
|
||||||
|
expect(teil.fehler.branches).toContain("403");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nennt den Status, weil daran die Ursache haengt", async () => {
|
||||||
|
// 403 heisst Scopes, 429 Rate-Limit, 408 Zeitueberschreitung. Ohne die
|
||||||
|
// Zahl bliebe nur "hat nicht geklappt".
|
||||||
|
const teil = new Teilabrufe();
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("slow down", { status: 429 }));
|
||||||
|
|
||||||
|
await teil.hol("tags", "https://git.example.com/api/v1/x", []);
|
||||||
|
|
||||||
|
expect(teil.fehler.tags).toMatch(/429/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("haelt mehrere Fehlschlaege getrennt auseinander", async () => {
|
||||||
|
const teil = new Teilabrufe();
|
||||||
|
let aufruf = 0;
|
||||||
|
vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
|
||||||
|
aufruf += 1;
|
||||||
|
// Der zweite geht durch – ein Teilausfall ist der haeufige Fall.
|
||||||
|
return aufruf === 2
|
||||||
|
? new Response(JSON.stringify([1, 2]), { status: 200 })
|
||||||
|
: new Response("nope", { status: 403 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const [branches, tags, commits] = await Promise.all([
|
||||||
|
teil.hol("branches", "https://git.example.com/a", []),
|
||||||
|
teil.hol("tags", "https://git.example.com/b", []),
|
||||||
|
teil.hol("commits", "https://git.example.com/c", []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(branches).toEqual([]);
|
||||||
|
expect(tags).toEqual([1, 2]);
|
||||||
|
expect(commits).toEqual([]);
|
||||||
|
expect(Object.keys(teil.fehler).sort()).toEqual(["branches", "commits"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("faengt auch einen Abbruch ab, nicht nur eine Fehlerantwort", async () => {
|
||||||
|
const teil = new Teilabrufe();
|
||||||
|
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("socket hang up"));
|
||||||
|
|
||||||
|
const wert = await teil.hol("commits", "https://git.example.com/api/v1/x", []);
|
||||||
|
|
||||||
|
expect(wert).toEqual([]);
|
||||||
|
expect(teil.fehler.commits).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der zweite Versuch.
|
||||||
|
*
|
||||||
|
* Beobachtet an einer laufenden Instanz: zwei von acht Teil-Abrufen liefen
|
||||||
|
* in eine Zeitueberschreitung nach 15 s, waehrend dieselben Aufrufe von
|
||||||
|
* aussen einzeln in 80 ms antworteten. Nicht die Endpunkte sind langsam –
|
||||||
|
* die Achterrunde erwuergt sich gelegentlich selbst.
|
||||||
|
*/
|
||||||
|
describe("Teilabrufe: Nachschlag", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubEnv("GIT_ALLOWED_HOSTS", "git.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
// Ohne das zaehlen die Spies ueber Tests hinweg weiter – und genau an
|
||||||
|
// Aufrufzahlen haengen die Pruefungen hier.
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Antwortet die ersten `fehlschlaege` Male mit Abbruch, danach mit Daten. */
|
||||||
|
function zaehlend(fehlschlaege: number, status = 408) {
|
||||||
|
let aufruf = 0;
|
||||||
|
return vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
|
||||||
|
aufruf += 1;
|
||||||
|
if (aufruf <= fehlschlaege) {
|
||||||
|
if (status === 408) {
|
||||||
|
const abbruch = new Error("aborted");
|
||||||
|
abbruch.name = "AbortError";
|
||||||
|
throw abbruch;
|
||||||
|
}
|
||||||
|
return new Response("nope", { status });
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify(["da"]), { status: 200 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("holt eine Zeitueberschreitung im zweiten Versuch nach", async () => {
|
||||||
|
const fetchSpy = zaehlend(1);
|
||||||
|
const teil = new Teilabrufe();
|
||||||
|
|
||||||
|
const wert = await teil.hol("branches", "https://git.example.com/a", []);
|
||||||
|
|
||||||
|
expect(wert).toEqual(["da"]);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
// Geglueckt heisst: kein Eintrag. Sonst stuende am Repository ein
|
||||||
|
// Fehler, den es nicht mehr gibt.
|
||||||
|
expect(teil.fehler).toEqual({});
|
||||||
|
expect(teil.unvollstaendig).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("vermerkt es, wenn auch der zweite Versuch scheitert", async () => {
|
||||||
|
const fetchSpy = zaehlend(99);
|
||||||
|
const teil = new Teilabrufe();
|
||||||
|
|
||||||
|
const wert = await teil.hol("commits", "https://git.example.com/a", []);
|
||||||
|
|
||||||
|
expect(wert).toEqual([]);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(teil.fehler.commits).toContain("408");
|
||||||
|
expect(teil.fehler.commits).toContain("zweiten Versuch");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schlaegt bei 403 gar nicht erst nach", async () => {
|
||||||
|
// Fehlende Scopes werden durch Wiederholen nicht besser.
|
||||||
|
const fetchSpy = zaehlend(99, 403);
|
||||||
|
const teil = new Teilabrufe();
|
||||||
|
|
||||||
|
await teil.hol("tags", "https://git.example.com/a", []);
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(teil.fehler.tags).not.toContain("zweiten Versuch");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schlaegt bei 429 nicht nach", async () => {
|
||||||
|
// Sofortiges Wiederholen macht ein Rate-Limit schlimmer, nicht besser.
|
||||||
|
const fetchSpy = zaehlend(99, 429);
|
||||||
|
const teil = new Teilabrufe();
|
||||||
|
|
||||||
|
await teil.hol("issues", "https://git.example.com/a", []);
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt hoechstens zwei Nachschlaege je Abgleich", async () => {
|
||||||
|
// Acht Zeitueberschreitungen zu je 15 s waeren zwei Minuten. Wer weg
|
||||||
|
// ist, soll schnell als weg erkannt werden.
|
||||||
|
const fetchSpy = zaehlend(99);
|
||||||
|
const teil = new Teilabrufe();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
teil.hol("branches", "https://git.example.com/a", []),
|
||||||
|
teil.hol("tags", "https://git.example.com/b", []),
|
||||||
|
teil.hol("commits", "https://git.example.com/c", []),
|
||||||
|
teil.hol("releases", "https://git.example.com/d", []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Vier erste Versuche, aber nur zwei Nachschlaege.
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(6);
|
||||||
|
expect(Object.keys(teil.fehler).sort()).toEqual([
|
||||||
|
"branches",
|
||||||
|
"commits",
|
||||||
|
"releases",
|
||||||
|
"tags",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reisst die Kette nicht ab, wenn ein Nachschlag scheitert", async () => {
|
||||||
|
// Der erste Nachschlag geht schief, der zweite muss trotzdem laufen.
|
||||||
|
let aufruf = 0;
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
|
||||||
|
aufruf += 1;
|
||||||
|
// 1 und 2 sind die ersten Versuche, 3 der erste Nachschlag (scheitert),
|
||||||
|
// 4 der zweite Nachschlag (glueckt).
|
||||||
|
if (aufruf <= 3) {
|
||||||
|
const abbruch = new Error("aborted");
|
||||||
|
abbruch.name = "AbortError";
|
||||||
|
throw abbruch;
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify(["da"]), { status: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const teil = new Teilabrufe();
|
||||||
|
const [erst, zweit] = await Promise.all([
|
||||||
|
teil.hol("branches", "https://git.example.com/a", []),
|
||||||
|
teil.hol("tags", "https://git.example.com/b", []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
||||||
|
expect(erst).toEqual([]);
|
||||||
|
expect(zweit).toEqual(["da"]);
|
||||||
|
expect(Object.keys(teil.fehler)).toEqual(["branches"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+164
-4
@@ -1,7 +1,37 @@
|
|||||||
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
|
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
|
||||||
import { GitApiError } from "./types";
|
import { GitApiError } from "./types";
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT = Number(process.env.GIT_API_TIMEOUT_MS ?? 15000);
|
const TIMEOUT_VORGABE = 15000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeitlimit je Aufruf.
|
||||||
|
*
|
||||||
|
* Bewusst mit Pruefung: Number("") ist 0 und Number("abc") ist NaN – beides
|
||||||
|
* laesst setTimeout sofort ausloesen, und dann bricht jeder ausgehende
|
||||||
|
* Aufruf ab, bevor er begonnen hat. Eine verrutschte Zeile in der .env
|
||||||
|
* legte damit den ganzen Abgleich still, ohne dass irgendwo etwas stand.
|
||||||
|
*/
|
||||||
|
export function zeitlimit(
|
||||||
|
roh: string | undefined,
|
||||||
|
warnen: (text: string) => void = console.warn,
|
||||||
|
): number {
|
||||||
|
const wert = roh?.trim();
|
||||||
|
if (!wert) return TIMEOUT_VORGABE;
|
||||||
|
|
||||||
|
const zahl = Number(wert);
|
||||||
|
// Untergrenze 1000 ms: darunter ist kein Aufruf ueber das Netz zu
|
||||||
|
// schaffen, und wer das eintraegt, hat sich vertan.
|
||||||
|
if (!Number.isFinite(zahl) || zahl < 1000) {
|
||||||
|
warnen(
|
||||||
|
`[nebula] GIT_API_TIMEOUT_MS="${wert}" ist unbrauchbar (Zahl ab 1000 erwartet). ` +
|
||||||
|
`Es gelten ${TIMEOUT_VORGABE} ms.`,
|
||||||
|
);
|
||||||
|
return TIMEOUT_VORGABE;
|
||||||
|
}
|
||||||
|
return zahl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT = zeitlimit(process.env.GIT_API_TIMEOUT_MS);
|
||||||
|
|
||||||
export interface FetchOptions {
|
export interface FetchOptions {
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
@@ -73,7 +103,11 @@ export async function getJson<T>(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.text().catch(() => "");
|
const body = await response.text().catch(() => "");
|
||||||
throw new GitApiError(friendlyMessage(response.status, url, body), response.status, url);
|
throw new GitApiError(
|
||||||
|
friendlyMessage(response.status, url, body),
|
||||||
|
response.status,
|
||||||
|
url,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
@@ -150,7 +184,11 @@ export async function sendJson<T>(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text().catch(() => "");
|
const text = await response.text().catch(() => "");
|
||||||
throw new GitApiError(friendlyMessage(response.status, url, text), response.status, url);
|
throw new GitApiError(
|
||||||
|
friendlyMessage(response.status, url, text),
|
||||||
|
response.status,
|
||||||
|
url,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
@@ -175,7 +213,14 @@ export async function sendJson<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wie getJson, gibt aber bei jedem Fehler den Fallback zurueck. */
|
/**
|
||||||
|
* Wie getJson, gibt aber bei jedem Fehler den Fallback zurueck.
|
||||||
|
*
|
||||||
|
* Nur fuer Abrufe benutzen, bei denen ein Fehlschlag wirklich nichts
|
||||||
|
* bedeutet. Fuer die Listen eines Snapshots ist das falsch – dort ist
|
||||||
|
* "nicht geholt" etwas anderes als "nichts vorhanden". Dafuer gibt es
|
||||||
|
* Teilabrufe.
|
||||||
|
*/
|
||||||
export async function getJsonSafe<T>(
|
export async function getJsonSafe<T>(
|
||||||
url: string,
|
url: string,
|
||||||
fallback: T,
|
fallback: T,
|
||||||
@@ -189,6 +234,121 @@ export async function getJsonSafe<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie viele zweite Versuche ein Abgleich hoechstens unternimmt.
|
||||||
|
*
|
||||||
|
* Zwei, nicht acht: jede Zeitueberschreitung kostet volle 15 Sekunden, und
|
||||||
|
* acht davon hintereinander waeren zwei Minuten, in denen "Jetzt
|
||||||
|
* synchronisieren" wie ein Absturz aussieht. Wer wirklich weg ist, soll
|
||||||
|
* schnell als weg erkannt werden.
|
||||||
|
*/
|
||||||
|
const NACHSCHLAG_MAX = 2;
|
||||||
|
|
||||||
|
/** Warum ein einzelner Teil-Abruf nicht geklappt hat. */
|
||||||
|
export type Teilfehler = Record<string, string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sammelstelle fuer die Listen eines Snapshots.
|
||||||
|
*
|
||||||
|
* Der Vorgaenger dieser Klasse war ein getJsonSafe je Liste. Das hat jeden
|
||||||
|
* Fehler in eine leere Liste verwandelt, und damit war ein 403 nicht mehr
|
||||||
|
* von einem Repository ohne Branches zu unterscheiden: Der Abgleich meldete
|
||||||
|
* "Snapshot aktualisiert: 0 Branches, 0 Tags", schrieb Nullen ueber den
|
||||||
|
* letzten guten Stand und niemand konnte sehen, woran es lag.
|
||||||
|
*
|
||||||
|
* Der Fallback bleibt – ein einzelner Fehlschlag darf den ganzen Abgleich
|
||||||
|
* nicht abbrechen –, aber der Grund wird festgehalten.
|
||||||
|
*/
|
||||||
|
export class Teilabrufe {
|
||||||
|
readonly fehler: Teilfehler = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nachschlaege haengen aneinander, damit sie nacheinander laufen.
|
||||||
|
*
|
||||||
|
* Die Runde selbst ist gleichzeitig – acht Aufrufe auf einmal. Genau das
|
||||||
|
* scheint sie gelegentlich zu erwuergen: beobachtet wurden zwei von acht
|
||||||
|
* mit Zeitueberschreitung nach 15 s, waehrend dieselben Aufrufe einzeln in
|
||||||
|
* 80 ms antworteten. Der zweite Versuch soll deshalb nicht wieder in
|
||||||
|
* denselben Schwung geraten.
|
||||||
|
*/
|
||||||
|
private kette: Promise<unknown> = Promise.resolve();
|
||||||
|
private nachgeschlagen = 0;
|
||||||
|
|
||||||
|
async hol<T>(
|
||||||
|
feld: string,
|
||||||
|
url: string,
|
||||||
|
fallback: T,
|
||||||
|
options: FetchOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
const result = await getJson<T>(url, options);
|
||||||
|
return (result ?? fallback) as T;
|
||||||
|
} catch (error) {
|
||||||
|
if (this.lohntNachschlag(error)) {
|
||||||
|
const zweiter = await this.nachschlag<T>(url, options);
|
||||||
|
if (zweiter.ok) return (zweiter.wert ?? fallback) as T;
|
||||||
|
this.fehler[feld] = `${beschreibe(zweiter.fehler)} (auch im zweiten Versuch)`;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
this.fehler[feld] = beschreibe(error);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein zweiter Versuch hilft nur bei Fehlern, die nichts ueber die Sache
|
||||||
|
* aussagen: Zeitueberschreitung (408) und eine Verbindung, die gar nicht
|
||||||
|
* zustande kam (0). Ein 403 wird durch Wiederholen nicht besser, ein 429
|
||||||
|
* durch sofortiges Wiederholen schlimmer, und ein 404 bleibt ein 404.
|
||||||
|
*/
|
||||||
|
private lohntNachschlag(error: unknown): boolean {
|
||||||
|
if (this.nachgeschlagen >= NACHSCHLAG_MAX) return false;
|
||||||
|
if (!(error instanceof GitApiError)) return false;
|
||||||
|
return error.status === 408 || error.status === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async nachschlag<T>(
|
||||||
|
url: string,
|
||||||
|
options: FetchOptions,
|
||||||
|
): Promise<{ ok: boolean; wert?: T; fehler?: unknown }> {
|
||||||
|
this.nachgeschlagen += 1;
|
||||||
|
|
||||||
|
const versuch = this.kette.then(async () => getJson<T>(url, options));
|
||||||
|
// Auch ein Fehlschlag darf die Kette nicht abreissen lassen, sonst
|
||||||
|
// scheitern die folgenden Nachschlaege gleich mit.
|
||||||
|
this.kette = versuch.catch(() => undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return { ok: true, wert: await versuch };
|
||||||
|
} catch (fehler) {
|
||||||
|
return { ok: false, fehler };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** true, sobald mindestens ein Teil-Abruf gescheitert ist. */
|
||||||
|
get unvollstaendig(): boolean {
|
||||||
|
return Object.keys(this.fehler).length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kurzfassung eines Fehlers, wie sie am Repository angezeigt wird.
|
||||||
|
*
|
||||||
|
* getJson gibt ausschliesslich GitApiError heraus – ein blockiertes Ziel
|
||||||
|
* kommt als 403, eine Zeitueberschreitung als 408, ein nicht erreichter
|
||||||
|
* Host als 0. Der Status ist damit die eigentliche Auskunft, und die
|
||||||
|
* uebrigen Zweige sind nur fuer den Fall da, dass sich das einmal aendert.
|
||||||
|
*/
|
||||||
|
function beschreibe(error: unknown): string {
|
||||||
|
if (error instanceof GitApiError) {
|
||||||
|
return error.status
|
||||||
|
? `HTTP ${error.status}: ${error.message}`
|
||||||
|
: error.message;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) return `${error.name}: ${error.message}`;
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
export function toIso(value: unknown): string | null {
|
export function toIso(value: unknown): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const date = new Date(String(value));
|
const date = new Date(String(value));
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ export interface RepoSnapshotData extends RepoOverview {
|
|||||||
ci: CiInfo | null;
|
ci: CiInfo | null;
|
||||||
/** Commits je Tag der letzten 30 Tage, aeltester zuerst. */
|
/** Commits je Tag der letzten 30 Tage, aeltester zuerst. */
|
||||||
commitActivity: number[];
|
commitActivity: number[];
|
||||||
|
/**
|
||||||
|
* Teil-Abrufe, die nicht geklappt haben: Feldname -> Grund mit HTTP-Status.
|
||||||
|
* Leer heisst, dass alle Listen wirklich geholt wurden.
|
||||||
|
*
|
||||||
|
* Der Unterschied ist wesentlich: eine leere Liste in `tags` bedeutet mit
|
||||||
|
* einem Eintrag hier "nicht geholt" und ohne ihn "es gibt keine Tags".
|
||||||
|
* Nur im ersten Fall darf der gespeicherte Stand stehen bleiben.
|
||||||
|
*/
|
||||||
|
missing: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tage, ueber die die Commit-Aktivitaet gebildet wird. */
|
/** Tage, ueber die die Commit-Aktivitaet gebildet wird. */
|
||||||
|
|||||||
+116
-31
@@ -12,6 +12,15 @@ export interface SyncResult {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
fetchedAt?: string;
|
fetchedAt?: string;
|
||||||
|
/**
|
||||||
|
* Der Abgleich lief durch, aber einzelne Listen kamen nicht an.
|
||||||
|
*
|
||||||
|
* Getrennt von `ok`, weil beides verschiedene Folgen hat: `ok: false`
|
||||||
|
* setzt syncError, zaehlt Fehlversuche und haelt das Repository beim
|
||||||
|
* naechsten Durchlauf zurueck. Ein unvollstaendiger Abgleich soll nichts
|
||||||
|
* davon ausloesen – der Rest ist ja frisch – aber sichtbar sein.
|
||||||
|
*/
|
||||||
|
unvollstaendig?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,7 +28,9 @@ export interface SyncResult {
|
|||||||
* in die Datenbank. Fehler werden am Repository vermerkt statt geworfen,
|
* in die Datenbank. Fehler werden am Repository vermerkt statt geworfen,
|
||||||
* damit ein kaputtes Repo nicht das ganze Dashboard blockiert.
|
* damit ein kaputtes Repo nicht das ganze Dashboard blockiert.
|
||||||
*/
|
*/
|
||||||
export async function syncRepository(repositoryId: string): Promise<SyncResult> {
|
export async function syncRepository(
|
||||||
|
repositoryId: string,
|
||||||
|
): Promise<SyncResult> {
|
||||||
const repository = await prisma.repository.findUnique({
|
const repository = await prisma.repository.findUnique({
|
||||||
where: { id: repositoryId },
|
where: { id: repositoryId },
|
||||||
include: { connection: true },
|
include: { connection: true },
|
||||||
@@ -33,7 +44,8 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
|
|||||||
if (!ref) {
|
if (!ref) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: "Kein Repository hinterlegt – bitte Repo-URL oder Owner/Name setzen.",
|
message:
|
||||||
|
"Kein Repository hinterlegt – bitte Repo-URL oder Owner/Name setzen.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +58,8 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
|
|||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: "Keine Git-Verbindung ausgewaehlt und die Repo-URL ist nicht auswertbar.",
|
message:
|
||||||
|
"Keine Git-Verbindung ausgewaehlt und die Repo-URL ist nicht auswertbar.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
client = createClient({
|
client = createClient({
|
||||||
@@ -81,10 +94,28 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
|
|||||||
|
|
||||||
// Nach dem Speichern, damit die Meldung den neuen Stand sieht.
|
// Nach dem Speichern, damit die Meldung den neuen Stand sieht.
|
||||||
await notifyAfterSync(repository.id);
|
await notifyAfterSync(repository.id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehlgeschlagene Teile gehoeren in die Meldung.
|
||||||
|
*
|
||||||
|
* Vorher stand hier "Snapshot aktualisiert: 0 Branches, 0 Tags" – und das
|
||||||
|
* war die einzige Spur, wenn saemtliche Listen unterwegs verloren gingen.
|
||||||
|
* Wer das liest, denkt an ein leeres Repository, nicht an einen Fehler.
|
||||||
|
*/
|
||||||
|
// Sortiert, damit die Meldung bei jedem Durchlauf gleich aussieht –
|
||||||
|
// die Reihenfolge im Objekt haengt sonst daran, welcher Aufruf zuerst
|
||||||
|
// gescheitert ist.
|
||||||
|
const fehlend = Object.keys(data.missing).sort();
|
||||||
|
const zahlen = fehlend.length
|
||||||
|
? `Teilweise aktualisiert – nicht geholt: ${fehlend.join(", ")}. ` +
|
||||||
|
`Der vorherige Stand bleibt dafuer stehen.`
|
||||||
|
: `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
unvollstaendig: fehlend.length > 0,
|
||||||
message:
|
message:
|
||||||
`Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.` +
|
zahlen +
|
||||||
(abgehakt > 0
|
(abgehakt > 0
|
||||||
? ` ${abgehakt} Aufgabe(n) abgehakt, weil das Issue geschlossen wurde.`
|
? ` ${abgehakt} Aufgabe(n) abgehakt, weil das Issue geschlossen wurde.`
|
||||||
: ""),
|
: ""),
|
||||||
@@ -127,7 +158,9 @@ export interface ProjectSyncResult extends SyncResult {
|
|||||||
* beim selben Anbieter, und ein Schwung gleichzeitiger Anfragen ist genau
|
* beim selben Anbieter, und ein Schwung gleichzeitiger Anfragen ist genau
|
||||||
* das, worauf Rate-Limits reagieren.
|
* das, worauf Rate-Limits reagieren.
|
||||||
*/
|
*/
|
||||||
export async function syncProject(projectId: string): Promise<ProjectSyncResult> {
|
export async function syncProject(
|
||||||
|
projectId: string,
|
||||||
|
): Promise<ProjectSyncResult> {
|
||||||
const repositories = await prisma.repository.findMany({
|
const repositories = await prisma.repository.findMany({
|
||||||
where: { projectId },
|
where: { projectId },
|
||||||
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||||
@@ -214,7 +247,10 @@ async function maybeRefreshCalendar(
|
|||||||
// Rate-Limit, Zeitueberschreitung, Ausfall: der Kalender ist Beiwerk und
|
// Rate-Limit, Zeitueberschreitung, Ausfall: der Kalender ist Beiwerk und
|
||||||
// soll den Abgleich nicht scheitern lassen. Ohne Zeitstempel versucht es
|
// soll den Abgleich nicht scheitern lassen. Ohne Zeitstempel versucht es
|
||||||
// der naechste Durchgang von selbst wieder.
|
// der naechste Durchgang von selbst wieder.
|
||||||
console.error("[nebula] Commit-Kalender konnte nicht geholt werden:", error);
|
console.error(
|
||||||
|
"[nebula] Commit-Kalender konnte nicht geholt werden:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +330,9 @@ async function noteNewCommits(
|
|||||||
// Zahl als mit einer falschen.
|
// Zahl als mit einer falschen.
|
||||||
const index = data.commits.findIndex((commit) => commit.sha === vorher);
|
const index = data.commits.findIndex((commit) => commit.sha === vorher);
|
||||||
const anzahl = index > 0 ? index : null;
|
const anzahl = index > 0 ? index : null;
|
||||||
const wo = data.defaultBranch ? `${repoTitle(repository)} · ${data.defaultBranch}` : repoTitle(repository);
|
const wo = data.defaultBranch
|
||||||
|
? `${repoTitle(repository)} · ${data.defaultBranch}`
|
||||||
|
: repoTitle(repository);
|
||||||
const betreff = shorten(data.commits[0]?.message ?? "", 60);
|
const betreff = shorten(data.commits[0]?.message ?? "", 60);
|
||||||
|
|
||||||
await recordActivity({
|
await recordActivity({
|
||||||
@@ -304,16 +342,38 @@ async function noteNewCommits(
|
|||||||
anzahl === null
|
anzahl === null
|
||||||
? `Neue Commits in ${wo}: ${betreff}`
|
? `Neue Commits in ${wo}: ${betreff}`
|
||||||
: `${anzahl} neue${anzahl === 1 ? "r" : ""} Commit${anzahl === 1 ? "" : "s"} in ${wo}: ${betreff}`,
|
: `${anzahl} neue${anzahl === 1 ? "r" : ""} Commit${anzahl === 1 ? "" : "s"} in ${wo}: ${betreff}`,
|
||||||
meta: { repositoryId: repository.id, from: vorher, to: neuester, count: anzahl },
|
meta: {
|
||||||
|
repositoryId: repository.id,
|
||||||
|
from: vorher,
|
||||||
|
to: neuester,
|
||||||
|
count: anzahl,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistSnapshot(repository: Repository, data: RepoSnapshotData) {
|
async function persistSnapshot(repository: Repository, data: RepoSnapshotData) {
|
||||||
const latestCommit = data.commits[0] ?? null;
|
const latestCommit = data.commits[0] ?? null;
|
||||||
const latestRelease =
|
const latestRelease =
|
||||||
data.releases.find((release) => !release.draft && !release.prerelease) ?? data.releases[0] ?? null;
|
data.releases.find((release) => !release.draft && !release.prerelease) ??
|
||||||
|
data.releases[0] ??
|
||||||
|
null;
|
||||||
const latestTag = data.tags[0]?.name ?? latestRelease?.tag ?? null;
|
const latestTag = data.tags[0]?.name ?? latestRelease?.tag ?? null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Teil, der nicht geholt werden konnte, bleibt so, wie er war.
|
||||||
|
*
|
||||||
|
* Vorher wurde bedingungslos ueberschrieben. Kam eine Liste leer zurueck,
|
||||||
|
* weil der Abruf gescheitert war, ersetzte sie den letzten guten Stand –
|
||||||
|
* ein Klick auf "Jetzt synchronisieren" genuegte, und die Commits eines
|
||||||
|
* Repositories waren weg. Der Snapshot ist ein Zwischenspeicher: ein
|
||||||
|
* alter Stand ist immer brauchbarer als eine leere Liste, die aussieht
|
||||||
|
* wie eine Aussage.
|
||||||
|
*/
|
||||||
|
const geholt = <T>(feld: string, wert: T): T | undefined =>
|
||||||
|
feld in data.missing ? undefined : wert;
|
||||||
|
|
||||||
|
const vollstaendig = Object.keys(data.missing).length === 0;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
fullName: data.fullName,
|
fullName: data.fullName,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
@@ -325,30 +385,55 @@ async function persistSnapshot(repository: Repository, data: RepoSnapshotData) {
|
|||||||
openIssues: data.openIssues,
|
openIssues: data.openIssues,
|
||||||
openPullRequests: data.openPullRequests,
|
openPullRequests: data.openPullRequests,
|
||||||
sizeKb: data.sizeKb,
|
sizeKb: data.sizeKb,
|
||||||
lastCommitSha: latestCommit?.sha ?? null,
|
// Die Commit-Felder haengen alle an derselben Liste – entweder alle
|
||||||
lastCommitMessage: latestCommit?.message ?? null,
|
// neu oder alle unveraendert. Halb aktualisiert waeren sie in sich
|
||||||
lastCommitAuthor: latestCommit?.author ?? null,
|
// widerspruechlich.
|
||||||
lastCommitAt: latestCommit?.date ? new Date(latestCommit.date) : null,
|
lastCommitSha: geholt("commits", latestCommit?.sha ?? null),
|
||||||
latestTag,
|
lastCommitMessage: geholt("commits", latestCommit?.message ?? null),
|
||||||
latestRelease: latestRelease?.name ?? null,
|
lastCommitAuthor: geholt("commits", latestCommit?.author ?? null),
|
||||||
latestReleaseAt: latestRelease?.publishedAt ? new Date(latestRelease.publishedAt) : null,
|
lastCommitAt: geholt(
|
||||||
branchCount: data.branches.length,
|
"commits",
|
||||||
tagCount: data.tags.length,
|
latestCommit?.date ? new Date(latestCommit.date) : null,
|
||||||
branches: data.branches as unknown as object,
|
),
|
||||||
tags: data.tags as unknown as object,
|
// latestTag faellt auf den Release-Tag zurueck; steht einer der beiden
|
||||||
commits: data.commits as unknown as object,
|
// Abrufe aus, ist die Ableitung nicht mehr belastbar.
|
||||||
releases: data.releases as unknown as object,
|
latestTag:
|
||||||
languages: data.languages as unknown as object,
|
"tags" in data.missing || "releases" in data.missing
|
||||||
pullRequests: data.pullRequests as unknown as object,
|
? undefined
|
||||||
issues: data.issues as unknown as object,
|
: latestTag,
|
||||||
commitActivity: data.commitActivity as unknown as object,
|
latestRelease: geholt("releases", latestRelease?.name ?? null),
|
||||||
ciState: data.ci?.state ?? null,
|
latestReleaseAt: geholt(
|
||||||
ciName: data.ci?.name ?? null,
|
"releases",
|
||||||
ciBranch: data.ci?.branch ?? null,
|
latestRelease?.publishedAt ? new Date(latestRelease.publishedAt) : null,
|
||||||
ciUrl: data.ci?.url ?? null,
|
),
|
||||||
ciFinishedAt: data.ci?.finishedAt ? new Date(data.ci.finishedAt) : null,
|
branchCount: geholt("branches", data.branches.length),
|
||||||
|
tagCount: geholt("tags", data.tags.length),
|
||||||
|
branches: geholt("branches", data.branches as unknown as object),
|
||||||
|
tags: geholt("tags", data.tags as unknown as object),
|
||||||
|
commits: geholt("commits", data.commits as unknown as object),
|
||||||
|
releases: geholt("releases", data.releases as unknown as object),
|
||||||
|
languages: geholt("languages", data.languages as unknown as object),
|
||||||
|
pullRequests: geholt(
|
||||||
|
"pullRequests",
|
||||||
|
data.pullRequests as unknown as object,
|
||||||
|
),
|
||||||
|
issues: geholt("issues", data.issues as unknown as object),
|
||||||
|
// Die Kurve wird aus den Commits gerechnet – ohne sie waere sie leer.
|
||||||
|
commitActivity: geholt("commits", data.commitActivity as unknown as object),
|
||||||
|
ciState: geholt("ci", data.ci?.state ?? null),
|
||||||
|
ciName: geholt("ci", data.ci?.name ?? null),
|
||||||
|
ciBranch: geholt("ci", data.ci?.branch ?? null),
|
||||||
|
ciUrl: geholt("ci", data.ci?.url ?? null),
|
||||||
|
ciFinishedAt: geholt(
|
||||||
|
"ci",
|
||||||
|
data.ci?.finishedAt ? new Date(data.ci.finishedAt) : null,
|
||||||
|
),
|
||||||
pushedAt: data.pushedAt ? new Date(data.pushedAt) : null,
|
pushedAt: data.pushedAt ? new Date(data.pushedAt) : null,
|
||||||
|
missing: data.missing as unknown as object,
|
||||||
fetchedAt: new Date(),
|
fetchedAt: new Date(),
|
||||||
|
// Nur setzen, wenn wirklich alles ankam. Sonst bleibt der Zeitpunkt
|
||||||
|
// stehen, an dem der Stand zuletzt vollstaendig war.
|
||||||
|
...(vollstaendig ? { completeAt: new Date() } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Vor dem Ueberschreiben lesen – danach ist der alte Stand weg.
|
// Vor dem Ueberschreiben lesen – danach ist der alte Stand weg.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
dueBucket,
|
dueBucket,
|
||||||
nextDueDate,
|
nextDueDate,
|
||||||
progressFromTasks,
|
progressFromTasks,
|
||||||
|
spaltenAnzeige,
|
||||||
} from "./tasks";
|
} from "./tasks";
|
||||||
|
|
||||||
describe("progressFromTasks", () => {
|
describe("progressFromTasks", () => {
|
||||||
@@ -136,3 +137,48 @@ describe("dueBucket", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("spaltenAnzeige", () => {
|
||||||
|
it("zeigt alles, solange die Spalte unter der Grenze bleibt", () => {
|
||||||
|
expect(spaltenAnzeige(3, 15, false)).toEqual({ sichtbar: 3, verborgen: 0 });
|
||||||
|
// Genau auf der Grenze ist noch nichts zu verbergen – sonst stuende
|
||||||
|
// "0 weitere anzeigen" an der Spalte.
|
||||||
|
expect(spaltenAnzeige(15, 15, false)).toEqual({ sichtbar: 15, verborgen: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kappt darueber und sagt, wie viele dahinter warten", () => {
|
||||||
|
expect(spaltenAnzeige(40, 15, false)).toEqual({ sichtbar: 15, verborgen: 25 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt 0 als 'ohne Begrenzung'", () => {
|
||||||
|
// Dieselbe Lesart wie SESSION_IDLE_HOURS. Waere 0 woertlich gemeint,
|
||||||
|
// zeigte das Brett gar nichts mehr.
|
||||||
|
expect(spaltenAnzeige(200, 0, false)).toEqual({ sichtbar: 200, verborgen: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("behandelt eine negative Grenze wie keine", () => {
|
||||||
|
// Die API laesst sie nicht durch; ein alter Datenbankstand oder ein
|
||||||
|
// Handgriff in psql koennte sie aber liefern.
|
||||||
|
expect(spaltenAnzeige(9, -5, false)).toEqual({ sichtbar: 9, verborgen: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zeigt entfaltet wieder alles", () => {
|
||||||
|
expect(spaltenAnzeige(40, 15, true)).toEqual({ sichtbar: 40, verborgen: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet nie mehr Sichtbares als vorhanden", () => {
|
||||||
|
// Sonst liefe das slice() in der Spalte ins Leere und die Zahl am Knopf
|
||||||
|
// waere negativ.
|
||||||
|
for (const anzahl of [0, 1, 7, 15, 16, 99]) {
|
||||||
|
for (const limit of [0, 1, 15, 100]) {
|
||||||
|
for (const entfaltet of [true, false]) {
|
||||||
|
const { sichtbar, verborgen } = spaltenAnzeige(anzahl, limit, entfaltet);
|
||||||
|
expect(sichtbar, `${anzahl}/${limit}/${entfaltet}`).toBeLessThanOrEqual(anzahl);
|
||||||
|
expect(verborgen).toBeGreaterThanOrEqual(0);
|
||||||
|
// Zusammen ergeben sie immer die ganze Spalte – nichts faellt weg.
|
||||||
|
expect(sichtbar + verborgen).toBe(anzahl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -168,6 +168,29 @@ export async function spawnRecurrence(taskId: string) {
|
|||||||
* nicht die Spalte, sondern der Termin: projektuebergreifend ist "was ist
|
* nicht die Spalte, sondern der Termin: projektuebergreifend ist "was ist
|
||||||
* ueberfaellig" die Frage, nicht "was steht in welcher Spalte".
|
* ueberfaellig" die Frage, nicht "was steht in welcher Spalte".
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Wie viele Karten eine Spalte des Aufgabenbretts zeigt – und wie viele
|
||||||
|
* dahinter warten.
|
||||||
|
*
|
||||||
|
* Begrenzt wird ausschliesslich die *Anzeige*. Der Zustand im Brett bleibt
|
||||||
|
* vollstaendig, weil das Umsortieren je Spalte die ganze Liste an den Server
|
||||||
|
* schickt: waere sie hier schon gekuerzt, wuerden die verborgenen Aufgaben
|
||||||
|
* beim naechsten Ziehen aus dem Brett fallen.
|
||||||
|
*
|
||||||
|
* `limit` 0 heisst "ohne Begrenzung" – dieselbe Lesart wie bei
|
||||||
|
* SESSION_IDLE_HOURS.
|
||||||
|
*/
|
||||||
|
export function spaltenAnzeige(
|
||||||
|
anzahl: number,
|
||||||
|
limit: number,
|
||||||
|
entfaltet: boolean,
|
||||||
|
): { sichtbar: number; verborgen: number } {
|
||||||
|
if (limit <= 0 || entfaltet || anzahl <= limit) {
|
||||||
|
return { sichtbar: anzahl, verborgen: 0 };
|
||||||
|
}
|
||||||
|
return { sichtbar: limit, verborgen: anzahl - limit };
|
||||||
|
}
|
||||||
|
|
||||||
export const DUE_BUCKET_ORDER = ["overdue", "today", "week", "later", "someday"] as const;
|
export const DUE_BUCKET_ORDER = ["overdue", "today", "week", "later", "someday"] as const;
|
||||||
|
|
||||||
export type DueBucket = (typeof DUE_BUCKET_ORDER)[number];
|
export type DueBucket = (typeof DUE_BUCKET_ORDER)[number];
|
||||||
|
|||||||
@@ -264,6 +264,8 @@ export const adminSettingsSchema = z.object({
|
|||||||
autoSyncIntervalMinutes: z.coerce.number().int().min(5).max(10080).optional(),
|
autoSyncIntervalMinutes: z.coerce.number().int().min(5).max(10080).optional(),
|
||||||
autoSyncActiveOnly: z.boolean().optional(),
|
autoSyncActiveOnly: z.boolean().optional(),
|
||||||
autoSyncBatchSize: z.coerce.number().int().min(1).max(200).optional(),
|
autoSyncBatchSize: z.coerce.number().int().min(1).max(200).optional(),
|
||||||
|
/** Karten je Spalte im Aufgabenbrett; 0 schaltet die Begrenzung ab. */
|
||||||
|
taskColumnLimit: z.coerce.number().int().min(0).max(500).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const adminUserCreateSchema = z.object({
|
export const adminUserCreateSchema = z.object({
|
||||||
|
|||||||
+17
-2
@@ -47,7 +47,10 @@ export interface ConnectionWithCount extends ConnectionSafe {
|
|||||||
* repositorySafeSelect. Ob einer eingerichtet ist, reicht die Projektseite
|
* repositorySafeSelect. Ob einer eingerichtet ist, reicht die Projektseite
|
||||||
* getrennt durch.
|
* getrennt durch.
|
||||||
*/
|
*/
|
||||||
export interface RepositoryWithSnapshot extends Omit<Repository, "webhookSecret"> {
|
export interface RepositoryWithSnapshot extends Omit<
|
||||||
|
Repository,
|
||||||
|
"webhookSecret"
|
||||||
|
> {
|
||||||
connection: ConnectionSafe | null;
|
connection: ConnectionSafe | null;
|
||||||
snapshot: RepoSnapshot | null;
|
snapshot: RepoSnapshot | null;
|
||||||
}
|
}
|
||||||
@@ -159,7 +162,8 @@ export function asArray<T>(value: unknown): T[] {
|
|||||||
export function asCalendar(value: unknown): CommitCalendar | null {
|
export function asCalendar(value: unknown): CommitCalendar | null {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||||
const kandidat = value as Partial<CommitCalendar>;
|
const kandidat = value as Partial<CommitCalendar>;
|
||||||
if (typeof kandidat.from !== "string" || typeof kandidat.to !== "string") return null;
|
if (typeof kandidat.from !== "string" || typeof kandidat.to !== "string")
|
||||||
|
return null;
|
||||||
if (!kandidat.days || typeof kandidat.days !== "object") return null;
|
if (!kandidat.days || typeof kandidat.days !== "object") return null;
|
||||||
return {
|
return {
|
||||||
from: kandidat.from,
|
from: kandidat.from,
|
||||||
@@ -176,3 +180,14 @@ export function asRecord(value: unknown): Record<string, number> {
|
|||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie asRecord, aber fuer Text – etwa `missing` am Snapshot, das je Feld
|
||||||
|
* den Grund des Fehlschlags traegt.
|
||||||
|
*/
|
||||||
|
export function asTextRecord(value: unknown): Record<string, string> {
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
return value as Record<string, string>;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user