14 Commits
Author SHA1 Message Date
florian.wilde aeb6c69a71 Merge: Release v1.11.0
CI / pruefen (push) Canceled after 0s
2026-09-09 22:16:55 +00:00
florian.wilde 8ff526262d chore(release): v1.11.0
CI / pruefen (pull_request) Canceled after 0s
Vier Aenderungen seit 1.10.1, alle bereits auf main. Minor statt Patch,
weil eine neue Funktion dabei ist und zwei Schemaaenderungen mitkommen.

Der Schwerpunkt liegt beim Abgleich, und die drei Punkte dort gehoeren
zusammen: Ein verschluckter Fehler sah aus wie ein leeres Ergebnis (#19),
ueberschrieb dabei den letzten guten Stand (#20), und weil niemand die
Ursache sehen konnte, blieb sie tagelang unentdeckt. Erst als die Fehler
sichtbar waren, liess sich der Rest ueberhaupt benennen: eine Seiten-
groesse, die angefragt statt gemessen wurde (#22), und Zeitueber-
schreitungen, die einen zweiten Versuch verdienen (#23).

Dazu die Karten je Spalte (#16) und das Issue-Kennzeichen (#12) aus den
Fassungen davor.

Verlauf und Versionsnummer wandern zusammen; changelog.test.ts haelt das
fest.

Beim Ausrollen laufen zwei Migrationen mit: taskColumnLimit an
AppSettings und missing/completeAt an RepoSnapshot.

Refs #16
Refs #19
Refs #20
Refs #22
Refs #23
2026-09-10 00:16:26 +02:00
florian.wilde e1b71f5fb6 Merge #23: Haengengebliebene Teil-Abrufe nachschlagen
CI / pruefen (push) Canceled after 0s
2026-09-09 22:12:21 +00:00
florian.wilde 6c788b3e7d Merge #22: Seitengroesse an der Antwort messen
CI / pruefen (push) Canceled after 0s
2026-09-09 22:11:45 +00:00
florian.wilde 76c064bd31 fix(git): haengengebliebene Teil-Abrufe einmal nachschlagen
CI / pruefen (pull_request) Canceled after 0s
Behebt florian.wilde/nebula#23.

Beobachtet an der 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.

Daraus folgt die Maßnahme, und zwar genau diese: was haengen bleibt,
bekommt einen zweiten Versuch, und der laeuft NICHT wieder im Schwung
mit. Die Nachschlaege haengen an einer Kette und gehen nacheinander
hinaus.

Die Aufrufstellen in den drei Clients bleiben unberuehrt - hol() liefert
weiterhin den endgueltigen Wert. Das war der Grund, es in Teilabrufe zu
loesen statt hinter dem Promise.all: dort haette jeder Client acht
Variablen neu zuweisen muessen.

ZWEI GRENZEN, BEIDE ABSICHTLICH

Nachgeschlagen wird nur bei 408 und bei 0 - Zeitueberschreitung und eine
Verbindung, die gar nicht zustande kam. Ein 403 wird durch Wiederholen
nicht besser, ein 429 durch sofortiges Wiederholen schlimmer, ein 404
bleibt ein 404.

Und hoechstens zweimal je Abgleich. Acht Zeitueberschreitungen zu je 15
Sekunden waeren zwei Minuten, in denen "Jetzt synchronisieren" wie ein
Absturz aussieht. Wer weg ist, soll schnell als weg erkannt werden.

Klappt auch der zweite Versuch nicht, steht das am Repository - mit dem
Vermerk "(auch im zweiten Versuch)", damit erkennbar bleibt, dass es kein
Zufall war.

NICHT GEMACHT: die acht Aufrufe entzerren. Das waere eine Vermutung ueber
die Ursache. Bleibt das Problem, sagt der Vermerk genau das, und dann ist
die Entzerrung dran.

AM RANDE BEHOBEN

Den beiden afterEach-Bloecken in http.test.ts fehlte restoreAllMocks.
Die Spies zaehlten ueber Tests hinweg weiter; aufgefallen, weil die neuen
Pruefungen an Aufrufzahlen haengen und statt 2 gleich 10 sahen.

Gegengeprobt: mit abgeschaltetem Nachschlag fallen vier der sechs neuen
Tests - die beiden, die das Ausbleiben pruefen, bleiben gruen.

Refs #23
2026-09-09 22:46:09 +02:00
florian.wilde abd9f73405 fix(git): Seitengroesse an der Antwort messen, nicht an der Anfrage
CI / pruefen (pull_request) Canceled after 0s
Behebt florian.wilde/nebula#22.

Gitea deckelt jede Listenantwort auf MAX_RESPONSE_ITEMS (Voreinstellung
50), egal was limit sagt. calendarByPaging verglich gegen die angefragten
100 und hielt damit bei jedem Gitea-Repository die erste Seite fuer die
letzte. Die Heatmap kannte nur die juengsten 50 Commits - und gab deren
aeltesten als Anfang des Zeitraums aus, ohne truncated zu setzen. Sie
behauptete also Vollstaendigkeit, die sie nicht hatte.

Maßstab ist jetzt die erste tatsaechlich gelieferte Seite. Kuerzer als
die heisst letzte Seite, leer heisst Ende, und der Deckel gilt weiterhin
nach CALENDAR_MAX_PAGES - dann aber ehrlich mit truncated.

Am echten Repository nachgesehen, dem aus dem Issue:

  vorher   Seite 1: 50                       -> 50 Commits
  jetzt    Seite 1: 50 | Seite 2: 26         -> 76 Commits

Gitea selbst weist 76 aus.

WAS DAS KOSTET

Bei Gitea macht der Kalender jetzt bis zu fuenf Aufrufe statt einem, und
ein Repository mit kurzer Historie fragt eine Seite mehr, um zu erfahren,
dass nichts mehr kommt. Das laeuft hoechstens einmal am Tag; der Preis
dafuer, dass die Zahl stimmt, ist ihn wert.

VIER BESTEHENDE TESTS ANGEFASST

Ihr Fixture lieferte fuer jede Seite dieselben Daten und hielt nur
deshalb an, weil die alte Regel eine kurze Seite fuer die letzte hielt -
also wegen genau des Fehlers, der hier behoben wird. Kein Server
verhaelt sich so. Sie holen ihre Daten jetzt ueber eineSeite() und
pruefen unveraendert das, worum es ihnen geht: Zaehlen und Filtern.

Vier neue Tests stellen eine deckelnde Gegenseite nach. Gegengeprobt:
mit der alten Zeile fallen genau diese vier.

Refs #22
2026-09-09 22:39:26 +02:00
florian.wilde 97ba863356 Merge #19 #20: Teil-Abrufe verschlucken ihre Fehler nicht mehr
CI / pruefen (push) Canceled after 0s
2026-09-09 20:05:26 +00:00
florian.wilde b062eb36a1 fix(sync): Teil-Abrufe verschlucken ihre Fehler nicht mehr
CI / pruefen (pull_request) Canceled after 0s
Behebt florian.wilde/nebula#19 und florian.wilde/nebula#20.

getJsonSafe machte aus jedem Fehlschlag eine leere Liste. Damit war ein
403 nicht mehr von "das Repository hat keine Branches" zu unterscheiden:
Der Abgleich lief durch, meldete "Snapshot aktualisiert: 0 Branches,
0 Tags", setzte kein syncError - und ueberschrieb dabei den letzten guten
Stand mit Nullen. Ein Repository mit 65 Commits sah danach aus wie ein
leeres, und die Ursache war nicht zu ermitteln, weil die Auskunft, die
sie benannt haette, gerade weggeworfen worden war.

Teilabrufe sammelt jetzt je Feld, was schiefging - mit HTTP-Status, denn
daran haengt die Ursache: 403 heisst Scopes, 429 Rate-Limit, 408
Zeitueberschreitung, 0 nicht erreicht. Der Fallback bleibt, ein einzelner
Fehlschlag darf den ganzen Abgleich nicht abbrechen.

Daraus folgt der zweite Teil: persistSnapshot laesst stehen, was nicht
geholt werden konnte. Der Snapshot ist ein Zwischenspeicher - ein alter
Stand mit sichtbarem Datum ist immer brauchbarer als eine leere Liste,
die aussieht wie eine Aussage. Die abgeleiteten Felder gehen mit ihrer
Liste: lastCommit* haengt an commits, latestTag an tags UND releases,
sonst waeren sie in sich widerspruechlich.

Bewusst kein Fehlschlag: syncError bleibt leer und der Zaehler fuers
Zurueckhalten steigt nicht. Der Rest des Standes ist frisch, und ein
Repository deswegen stundenlang auszusetzen waere die falsche Antwort.

GIT_API_TIMEOUT_MS wird geprueft. Number("") ist 0 und Number("abc") ist
NaN; setTimeout loest bei beidem sofort aus, und dann bricht jeder Aufruf
ab, bevor er beginnt. Eine verrutschte Zeile in der .env legte damit den
ganzen Abgleich still - lautlos, versteht sich.

NACHGEWIESEN, NICHT BEHAUPTET

Gegen einen nachgebauten Gitea-Server, der genau den beobachteten Fall
erzeugt - Basis-Aufruf antwortet, Teillisten geben 403 - und eine
Wegwerf-Datenbank:

  gesund      branchCount=2 tagCount=3 commits=2 releases=1 missing={}
  kaputt      branchCount=2 tagCount=3 commits=2 releases=1
              missing={8 Felder mit HTTP 403}, completeAt unveraendert
  wieder gut  missing={} , completeAt fortgeschrieben

Nichts verloren, die Luecke steht in der Meldung und am Repository, und
nach dem naechsten gesunden Durchlauf raeumt sie sich von selbst weg.
axe-core 0 Verstoesse auf 1280 und 375 px, kein seitlicher Ueberlauf.

Der eigentliche Grund der Ausfaelle auf der Instanz ist damit noch nicht
gefunden - aber ab jetzt steht er auf der Projektseite.

Refs #19
Refs #20
2026-09-01 18:09:01 +02:00
florian.wilde 4ce26e1e8d Merge: Banner als Titelbild der README
CI / pruefen (push) Canceled after 0s
2026-08-19 06:58:25 +00:00
florian.wilde 9fb86fcc61 docs(readme): Banner als Titelbild
CI / pruefen (pull_request) Canceled after 0s
Der Textkopf sagte dasselbe in duerr: Name, Zeile darunter, Technikliste.
Das Bild traegt beides zugleich und nennt obendrein die vier Punkte, um
die es geht - drei Git-Provider, Aufgaben und Issues, Passkeys, eigene
Datenbank.

Die Zeile "Ideen festhalten - Status pflegen - ..." faellt weg, weil der
Banner sie woertlich enthaelt. Ueberschrift und Technikzeile bleiben: die
uebrigen Dokumente beginnen alle mit einer H1, und wer Bilder abschaltet
oder die Datei im Editor liest, soll trotzdem sehen, worum es geht. Aus
demselben Grund steht der ganze Inhalt des Bildes im Alternativtext.

Volle Breite statt fester Pixelzahl - so skaliert das Bild in Gitea wie
auf dem Telefon mit dem Textkoerper mit.
2026-08-19 08:57:42 +02:00
florian.wilde d11dd9c4ac Merge #16: Karten je Spalte begrenzen
CI / pruefen (push) Canceled after 0s
2026-08-18 21:27:05 +00:00
florian.wilde 3fd6e41e16 feat(tasks): Karten je Spalte begrenzen, Rest hinter einen Knopf
CI / pruefen (pull_request) Canceled after 0s
Ein Projekt mit vierzig offenen Aufgaben macht das Brett unbrauchbar: die
Spalte laeuft ueber die halbe Seite, und die Bloecke darunter - Notizen,
Repository, Verlauf - rutschen aus dem Blick.

Die Grenze steht in den Admin-Einstellungen und gilt fuer alle Projekte,
wie autoSyncBatchSize und staleAfterDays. Kein Feld am Projekt: das haette
eine Migration am Project-Modell gekostet und eine Einstellung, die man
vierzig Mal pflegt. 0 heisst "ohne Begrenzung", dieselbe Lesart wie bei
SESSION_IDLE_HOURS.

Begrenzt wird ausschliesslich die Anzeige. Das ist der Kern: persist()
schickt je Spalte die vollstaendige Liste an /tasks/reorder, und eine dort
schon gekuerzte Liste wuerde beim ersten Ziehen die verborgenen Aufgaben
aus dem Brett loeschen. Der Zustand im Brett bleibt deshalb ganz, gekuerzt
wird erst beim Rendern der Spalte. Gegengeprobt am echten PATCH-Rumpf:
40 IDs bei 15 sichtbaren Karten.

Die Zahl neben der Spaltenueberschrift nennt weiter den vollen Bestand.
Nur die sichtbaren Karten stehen im SortableContext - was nicht im DOM
ist, kann dnd-kit ohnehin nicht treffen.

Die Admin-Sektion zeigt neben der Einstellung die laengste Spalte im
Bestand. Ohne die steht die Zahl im Leeren: eine Grenze von 15 sagt
nichts, wenn die groesste Spalte aus drei Karten besteht.

Refs #16
2026-08-18 23:19:56 +02:00
florian.wilde 2d191a54bc Merge: Roadmap auf 19 Releases
CI / pruefen (push) Canceled after 0s
2026-08-18 12:48:35 +00:00
florian.wilde 27b4dd83b7 docs(roadmap): Zahl der Releases auf 19 ziehen
CI / pruefen (pull_request) Canceled after 0s
Mit v1.10.1 ist eine Fassung dazugekommen. Die Zahl steht in 3.5 als
Gegenstueck zu den beiden Fassungen ohne Release - laeuft sie mit der
Wirklichkeit auseinander, entwertet das die ganze Notiz.
2026-08-18 14:48:17 +02:00
30 changed files with 1677 additions and 223 deletions
+10
View File
@@ -85,6 +85,16 @@ SSRF-Schutz (`lib/security/ssrf.ts`).
`100vh` beziehen sich weiter auf das volle. `components/ui/Modal.tsx` hängt
dafür an `window.visualViewport`; wer ein eigenes Overlay baut, braucht
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
mehrere Zeilen im selben Augenblick; ein Offset über so eine Grenze hinweg
überspringt oder verdoppelt lautlos. Muster in `lib/timeline.ts`:
+49
View File
@@ -259,6 +259,19 @@ Blockiert · Erledigt**.
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),
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
wieder gelöscht.
@@ -540,6 +553,42 @@ deshalb ohne Risiko.
| Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung |
| 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:
| System | Quelle |
+4 -3
View File
@@ -1,11 +1,11 @@
![Nebula — Projekt-Kontrollzentrum, self-hosted. Alle deine halbfertigen Projekte, ein Kontrollzentrum: Ideen, Status, Notizen und Live-Daten aus deinen Git-Repos, gebündelt an einem Ort auf deinem eigenen Server. Drei Git-Provider (GitHub, GitLab, Gitea), Aufgaben und Issues in beide Richtungen, Passkeys und Zwei-Faktor, Daten in deiner eigenen PostgreSQL.](docs/banner.png)
<div align="center">
# ✦ Nebula
**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>
</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_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` |
| `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_BLOCK_PRIVATE` | Zugriff ins private LAN sperren | `false` |
| `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/
├── docs/banner.png Titelbild dieser Datei
├── docker/entrypoint.sh Migrationen + Serverstart im Container
├── docker-compose.yml db (immer) · app (--profile full) · adminer (--profile tools)
├── Dockerfile Multi-Stage-Build auf Basis des Next-Standalone-Outputs
+4 -3
View File
@@ -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).
**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
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.
### 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
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.
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.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.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
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "nebula",
"version": "1.10.1",
"version": "1.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nebula",
"version": "1.10.1",
"version": "1.11.0",
"hasInstallScript": true,
"dependencies": {
"@dnd-kit/core": "^6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nebula",
"version": "1.10.1",
"version": "1.11.0",
"private": true,
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
"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 '{}';
+18
View File
@@ -266,6 +266,11 @@ model AppSettings {
/// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms }
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 ─────────────────────────────────────────────
/// Ab wie vielen Tagen ohne Commit ein Projekt als stillstehend gilt.
staleAfterDays Int @default(60)
@@ -644,6 +649,19 @@ model RepoSnapshot {
ciUrl String?
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?
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
View File
@@ -19,7 +19,7 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function AdminPage() {
const admin = await requireAdminPage();
const [settings, users, eligible, staleListeners] = await Promise.all([
const [settings, users, eligible, staleListeners, laengsteSpalte] = await Promise.all([
getAppSettings(),
prisma.user.findMany({
select: userSafeSelect,
@@ -41,6 +41,18 @@ export default async function AdminPage() {
prisma.notificationTarget.count({
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 = {
@@ -64,6 +76,10 @@ export default async function AdminPage() {
lastCheckAt: settings.lastStaleCheckAt,
listeners: staleListeners,
},
tasks: {
columnLimit: settings.taskColumnLimit,
laengsteSpalte: laengsteSpalte[0]?._count._all ?? 0,
},
};
return (
+2
View File
@@ -38,6 +38,7 @@ export async function PATCH(request: Request) {
autoSyncActiveOnly,
autoSyncBatchSize,
staleAfterDays,
taskColumnLimit,
} = parsed.data;
// Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren –
@@ -63,6 +64,7 @@ export async function PATCH(request: Request) {
...(staleAfterDays !== undefined
? { staleAfterDays, lastStaleCheckAt: null }
: {}),
...(taskColumnLimit !== undefined ? { taskColumnLimit } : {}),
};
const settings = await prisma.appSettings.upsert({
+4 -1
View File
@@ -10,6 +10,7 @@ import { ProjectHeader } from "@/components/ProjectHeader";
import { TaskBoard } from "@/components/tasks/TaskBoard";
import { requireUserPage } from "@/lib/auth/guard";
import { getSessionUser } from "@/lib/auth/session";
import { getAppSettings } from "@/lib/auth/settings";
import { prisma } from "@/lib/db";
import { connectionSafeSelect, projectDetailInclude, webhookRepositoryIds } from "@/lib/projects";
import type { ConnectionSafe, ProjectDetail } from "@/types";
@@ -37,7 +38,7 @@ export default async function ProjectPage({ params }: PageProps) {
const user = await requireUserPage();
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,
// nicht zu einer Fehlermeldung, die seine Existenz bestaetigt.
prisma.project.findFirst({
@@ -49,6 +50,7 @@ export default async function ProjectPage({ params }: PageProps) {
select: connectionSafeSelect,
orderBy: { name: "asc" },
}),
getAppSettings(),
]);
if (!project) notFound();
@@ -83,6 +85,7 @@ export default async function ProjectPage({ params }: PageProps) {
projectId={detail.id}
tasks={detail.tasks}
progressFromTasks={detail.progressFromTasks}
columnLimit={settings.taskColumnLimit}
/>
{/*
+205 -47
View File
@@ -30,13 +30,14 @@ import { RepositoryDialog } from "@/components/RepositoryDialog";
import { WebhookDialog } from "@/components/WebhookDialog";
import { Sparkline } from "@/components/Sparkline";
import { repoTitle } from "@/lib/git/meta";
import { asArray, asCalendar, asRecord } from "@/types";
import { asArray, asCalendar, asRecord, asTextRecord } from "@/types";
import type {
BranchInfo,
CommitInfo,
ConnectionSafe,
ProjectDetail,
ReleaseInfo,
RepoSnapshot,
RepositoryWithSnapshot,
TagInfo,
TicketInfo,
@@ -69,17 +70,26 @@ export function GitPanel({
const [tab, setTab] = useState<TabKey>("pulls");
const [syncing, setSyncing] = 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 [webhookOpen, setWebhookOpen] = useState(false);
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
// zurueckfallen, statt eine leere Ansicht zu zeigen.
const repository = repositories.find((entry) => entry.id === selectedId) ?? repositories[0] ?? null;
const webhookAktiv = repository ? webhookRepoIds.includes(repository.id) : false;
const repository =
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. */
async function importIssues() {
@@ -87,13 +97,17 @@ export function GitPanel({
setImporting(true);
setMessage(null);
try {
const response = await fetch(`/api/projects/${project.id}/tasks/from-issues`, {
const response = await fetch(
`/api/projects/${project.id}/tasks/from-issues`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ repositoryId: repository.id }),
});
},
);
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({
ok: true,
text:
@@ -103,7 +117,10 @@ export function GitPanel({
});
router.refresh();
} 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 {
setImporting(false);
}
@@ -130,7 +147,10 @@ export function GitPanel({
: `/api/projects/${project.id}/sync`;
const response = await fetch(url, { method: "POST" });
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();
} catch (caught) {
setMessage({
@@ -150,7 +170,9 @@ export function GitPanel({
) {
return;
}
await fetch(`/api/repositories/${entry.id}`, { method: "DELETE" }).catch(() => undefined);
await fetch(`/api/repositories/${entry.id}`, { method: "DELETE" }).catch(
() => undefined,
);
setSelectedId(null);
router.refresh();
}
@@ -160,12 +182,19 @@ export function GitPanel({
<>
<div className="glass px-5 py-10 text-center">
<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">
Verknuepfe ein Repository, um Branches, Tags, Commits und Releases hier zu sehen.
Ein Projekt darf auch mehrere haben – etwa Frontend und Backend.
Verknuepfe ein Repository, um Branches, Tags, Commits und Releases
hier zu sehen. Ein Projekt darf auch mehrere haben – etwa Frontend
und Backend.
</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" />
Repository verknuepfen
</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)
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
@@ -190,7 +222,10 @@ export function GitPanel({
{/* Auswahl, sobald es mehr als eines gibt. Bei einem einzigen waere eine
Leiste mit genau einem Knopf nur Ballast. */}
{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) => {
const aktiv = entry.id === repository.id;
return (
@@ -256,7 +291,11 @@ export function GitPanel({
<ExternalLink className="h-3.5 w-3.5" />
</a>
) : null}
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} url={snapshot?.ciUrl} />
<CiBadge
state={snapshot?.ciState}
name={snapshot?.ciName}
url={snapshot?.ciUrl}
/>
</div>
<p className="mt-1 text-xs text-slate-500" suppressHydrationWarning>
{repository.lastSyncedAt
@@ -368,12 +407,22 @@ export function GitPanel({
</p>
) : null}
<UnvollstaendigerStand snapshot={snapshot} />
{snapshot ? (
<>
{/* Kennzahlen */}
<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 icon={GitMerge} label="Forks" value={formatNumber(snapshot.forks)} />
<Metric
icon={Star}
label="Stars"
value={formatNumber(snapshot.stars)}
/>
<Metric
icon={GitMerge}
label="Forks"
value={formatNumber(snapshot.forks)}
/>
<Metric
icon={GitPullRequest}
label="Offene PRs/MRs"
@@ -384,11 +433,19 @@ export function GitPanel({
label="Offene Issues"
value={formatNumber(snapshot.openIssues)}
/>
<Metric icon={GitBranch} label="Branches" value={formatNumber(snapshot.branchCount)} />
<Metric
icon={GitBranch}
label="Branches"
value={formatNumber(snapshot.branchCount)}
/>
<Metric
icon={HardDrive}
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>
@@ -408,11 +465,15 @@ export function GitPanel({
{snapshot.lastCommitSha.slice(0, 7)}
</span>
<span>{snapshot.lastCommitAuthor ?? "unbekannt"}</span>
<span suppressHydrationWarning>{formatRelative(snapshot.lastCommitAt)}</span>
<span suppressHydrationWarning>
{formatRelative(snapshot.lastCommitAt)}
</span>
</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>
@@ -424,8 +485,11 @@ export function GitPanel({
<span className="font-mono text-2xl text-white">
{snapshot.latestTag ?? snapshot.latestRelease ?? "–"}
</span>
{snapshot.latestRelease && snapshot.latestRelease !== snapshot.latestTag ? (
<span className="text-sm text-slate-400">{snapshot.latestRelease}</span>
{snapshot.latestRelease &&
snapshot.latestRelease !== snapshot.latestTag ? (
<span className="text-sm text-slate-400">
{snapshot.latestRelease}
</span>
) : null}
</div>
<p className="mt-1 text-xs text-slate-500">
@@ -470,7 +534,8 @@ export function GitPanel({
title={`${name} – ${Math.round((bytes / languageTotal) * 100)}%`}
style={{
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
className="h-2 w-2 rounded-full"
style={{ backgroundColor: LANGUAGE_COLORS[index % LANGUAGE_COLORS.length] }}
style={{
backgroundColor:
LANGUAGE_COLORS[index % LANGUAGE_COLORS.length],
}}
/>
{name}
<span className="text-slate-600">
@@ -537,8 +605,8 @@ export function GitPanel({
{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">
<p className="text-[11px] text-slate-500">
Aus offenen Issues Aufgaben machen. Bereits uebernommene werden
uebersprungen.
Aus offenen Issues Aufgaben machen. Bereits uebernommene
werden uebersprungen.
</p>
<button
type="button"
@@ -555,7 +623,11 @@ export function GitPanel({
</button>
</div>
) : null}
<TicketList tickets={issues} empty="Keine offenen Issues." icon={CircleDot} />
<TicketList
tickets={issues}
empty="Keine offenen Issues."
icon={CircleDot}
/>
</>
) : 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">
Noch keine Daten geladen. Mit „Jetzt synchronisieren“ den ersten Snapshot holen.
Noch keine Daten geladen. Mit „Jetzt synchronisieren“ den ersten
Snapshot holen.
</div>
)}
</div>
@@ -591,7 +664,9 @@ function Metric({
<Icon className="h-3 w-3" />
{label}
</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>
);
}
@@ -639,10 +714,15 @@ function TicketList({
) : null}
</p>
<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>
{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}
</span>
))}
@@ -650,7 +730,9 @@ function TicketList({
</div>
</div>
<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 ? (
<a
href={ticket.url}
@@ -677,7 +759,9 @@ function BranchList({ branches }: { branches: BranchInfo[] }) {
<Row key={branch.name}>
<div className="flex min-w-0 items-center gap-2">
<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 ? (
<span className="shrink-0 rounded bg-violet-500/15 px-1.5 py-0.5 text-[10px] text-violet-200">
default
@@ -689,10 +773,14 @@ function BranchList({ branches }: { branches: BranchInfo[] }) {
</div>
<div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500">
{branch.lastCommitAt ? (
<span suppressHydrationWarning>{formatRelative(branch.lastCommitAt)}</span>
<span suppressHydrationWarning>
{formatRelative(branch.lastCommitAt)}
</span>
) : null}
{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}
</div>
</Row>
@@ -709,15 +797,23 @@ function TagList({ tags }: { tags: TagInfo[] }) {
<Row key={tag.name}>
<div className="flex min-w-0 items-center gap-2">
<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 ? (
<span className="truncate text-[11px] text-slate-500">{tag.message}</span>
<span className="truncate text-[11px] text-slate-500">
{tag.message}
</span>
) : null}
</div>
<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 ? (
<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}
</div>
</Row>
@@ -735,7 +831,9 @@ function CommitList({ commits }: { commits: CommitInfo[] }) {
<div className="min-w-0">
<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">
<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>
</p>
</div>
@@ -760,14 +858,17 @@ function CommitList({ commits }: { commits: CommitInfo[] }) {
}
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 (
<div>
{releases.map((release) => (
<Row key={`${release.tag}-${release.name}`}>
<div className="flex min-w-0 items-center gap-2">
<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">
{release.tag}
</span>
@@ -783,7 +884,9 @@ function ReleaseList({ releases }: { releases: ReleaseInfo[] }) {
) : null}
</div>
<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 ? (
<a
href={release.url}
@@ -801,3 +904,58 @@ function ReleaseList({ releases }: { releases: ReleaseInfo[] }) {
</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>
);
}
+133
View File
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import {
Ban,
CheckCircle2,
Columns3,
Database,
Download,
Fingerprint,
@@ -74,6 +75,12 @@ export interface AdminData {
/** Wie viele Konten ueberhaupt auf Stillstand hoeren. */
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;
@@ -195,6 +202,7 @@ export function AdminManager({ data }: { data: AdminData }) {
<AutoSyncSection data={data.autoSync} />
<StaleSection data={data.stale} />
<TaskLimitSection data={data.tasks} />
{/* ── Instanz ─────────────────────────────────────── */}
<section className="glass p-5">
@@ -1099,3 +1107,128 @@ function UserDialog({
</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>
);
}
+39 -4
View File
@@ -18,11 +18,11 @@ import {
import { restrictToWindowEdges } from "@dnd-kit/modifiers";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
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 { TaskCard } from "@/components/tasks/TaskCard";
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";
type Columns = Record<TaskStatus, Task[]>;
@@ -48,10 +48,13 @@ export function TaskBoard({
projectId,
tasks,
progressFromTasks,
columnLimit = 0,
}: {
projectId: string;
tasks: Task[];
progressFromTasks: boolean;
/** Karten je Spalte; 0 = ohne Begrenzung. Kommt aus den Admin-Einstellungen. */
columnLimit?: number;
}) {
const router = useRouter();
const [columns, setColumns] = useState<Columns>(() => group(tasks));
@@ -224,6 +227,7 @@ export function TaskBoard({
key={status}
status={status}
tasks={columns[status]}
limit={columnLimit}
onAdd={() => setCreatingIn(status)}
onEdit={setEditing}
/>
@@ -252,16 +256,24 @@ export function TaskBoard({
function TaskColumn({
status,
tasks,
limit,
onAdd,
onEdit,
}: {
status: TaskStatus;
tasks: Task[];
limit: number;
onAdd: () => void;
onEdit: (task: Task) => void;
}) {
const meta = TASK_STATUS_META[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 (
<div
@@ -284,12 +296,14 @@ function TaskColumn({
<span className="ml-auto text-[10px] tabular-nums text-slate-600">{tasks.length}</span>
</div>
{/* Nur die gezeigten Karten stehen im SortableContext – was nicht im
DOM ist, kann dnd-kit ohnehin nicht treffen. */}
<SortableContext
items={tasks.map((task) => task.id)}
items={gezeigt.map((task) => task.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-1 flex-col gap-1.5">
{tasks.map((task) => (
{gezeigt.map((task) => (
<SortableTask key={task.id} task={task} onEdit={onEdit} />
))}
{tasks.length === 0 ? (
@@ -300,6 +314,27 @@ function TaskColumn({
</div>
</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
type="button"
onClick={onAdd}
+58
View File
@@ -41,6 +41,64 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
};
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",
date: "2026-08-18",
+76 -6
View File
@@ -18,6 +18,19 @@ import {
const JETZT = new Date("2026-07-26T12:00:00.000Z");
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. */
function commits(anzahl: number, abTagen: number): string[] {
return Array.from({ length: anzahl }, (_, index) =>
@@ -45,14 +58,14 @@ describe("calendarByPaging", () => {
it("zaehlt mehrere Commits desselben Tages zusammen", async () => {
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(calendarTotal(kalender!)).toBe(3);
});
it("beginnt beim aeltesten bekannten Commit, nicht vor einem Jahr", async () => {
// 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?.to).toBe("2026-07-26");
expect(kalender?.truncated).toBe(false);
@@ -93,10 +106,10 @@ describe("calendarByPaging", () => {
it("uebergeht Commits mit Datum in der Zukunft", async () => {
// Falsch gestellte Uhren gibt es; sie duerfen den Zeitraum nicht verlaengern.
const kalender = await calendarByPaging(
async () => [
eineSeite([
new Date(JETZT.getTime() + 5 * TAG).toISOString(),
JETZT.toISOString(),
],
]),
JETZT,
);
expect(kalender?.to).toBe("2026-07-26");
@@ -105,7 +118,7 @@ describe("calendarByPaging", () => {
it("uebergeht Unbrauchbares, statt daran zu scheitern", async () => {
const kalender = await calendarByPaging(
async () => [null, "gestern", "", JETZT.toISOString()],
eineSeite([null, "gestern", "", JETZT.toISOString()]),
JETZT,
);
expect(calendarTotal(kalender!)).toBe(1);
@@ -114,9 +127,66 @@ describe("calendarByPaging", () => {
it("laesst Tage ohne Commits weg", async () => {
// Ein Jahr als Liste waeren 365 Eintraege, die meisten davon Nullen.
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,
);
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
View File
@@ -16,11 +16,20 @@
export const CALENDAR_DAYS = 365;
/**
* Hoechstzahl Seiten, wo seitenweise geholt werden muss. Fuenf Seiten sind
* 500 Commits – genug fuer die allermeisten Repositories und wenig genug,
* um kein fremdes Kontingent zu sprengen.
* Hoechstzahl Seiten, wo seitenweise geholt werden muss. Genug fuer die
* allermeisten Repositories und wenig genug, um kein fremdes Kontingent zu
* sprengen.
*/
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 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
* nichts weiter), sobald die Seite vollstaendig aus der Zeit vor dem
* 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(
ladeSeite: (seite: number) => Promise<Array<string | null>>,
@@ -64,11 +82,18 @@ export async function calendarByPaging(
const days: Record<string, number> = {};
let aeltester: number | null = null;
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) {
const daten = await ladeSeite(seite);
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;
for (const value of daten) {
if (!value) continue;
@@ -85,10 +110,12 @@ export async function calendarByPaging(
}
// 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.
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;
}
+97 -22
View File
@@ -1,4 +1,12 @@
import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http";
import {
Teilabrufe,
getJson,
getJsonSafe,
normalizeBaseUrl,
sendJson,
shortSha,
toIso,
} from "./http";
import {
CALENDAR_PAGE_SIZE,
calendarByPaging,
@@ -62,7 +70,11 @@ export class GiteaClient implements GitClient {
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(
`${this.repoBase(ref)}/issues/${number}`,
"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>(
`${this.repoBase(ref)}/issues/${number}`,
{ 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 }>(
`${this.apiBase}/user`,
{
headers: this.headers(),
});
},
);
return {
ok: true,
message: `Verbunden als ${user.login}${version.version ? ` (Gitea ${version.version})` : ""}`,
@@ -107,7 +125,10 @@ export class GiteaClient implements GitClient {
version: version.version ?? null,
};
} 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();
return repos
.filter((repo) => !needle || repo.full_name.toLowerCase().includes(needle))
.filter(
(repo) => !needle || repo.full_name.toLowerCase().includes(needle),
)
.map((repo) => ({
fullPath: repo.full_name,
owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "",
@@ -144,25 +167,70 @@ export class GiteaClient implements GitClient {
const defaultBranch = repo.default_branch ?? "";
const [branches, tags, commits, releases, languages, pulls, issues, status] =
await Promise.all([
getJsonSafe<GiteaBranch[]>(`${base}/branches?limit=50`, [], { headers }),
getJsonSafe<GiteaTag[]>(`${base}/tags?limit=25`, [], { headers }),
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
getJsonSafe<GiteaCommit[]>(`${base}/commits?limit=100&stat=false`, [], { headers }),
getJsonSafe<GiteaRelease[]>(`${base}/releases?limit=10`, [], { headers }),
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
getJsonSafe<GiteaPull[]>(`${base}/pulls?state=open&limit=50`, [], { headers }),
// type=issues, sonst kommen bei Gitea die Pull Requests mit.
getJsonSafe<GiteaIssue[]>(`${base}/issues?state=open&type=issues&limit=50`, [], {
// Sammelt, welche Liste nicht geholt werden konnte und warum. Frueher
// stand hier getJsonSafe, das jeden Fehler in eine leere Liste verwandelt
// hat – siehe Teilabrufe.
const teil = new Teilabrufe();
const [
branches,
tags,
commits,
releases,
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,
},
),
teil.hol<GiteaRelease[]>("releases", `${base}/releases?limit=10`, [], {
headers,
}),
teil.hol<Record<string, number>>(
"languages",
`${base}/languages`,
{},
{ headers },
),
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
? getJsonSafe<GiteaCombinedStatus>(
? teil.hol<GiteaCombinedStatus>(
"ci",
`${base}/commits/${encodeURIComponent(defaultBranch)}/status`,
{} as GiteaCombinedStatus,
{ headers },
@@ -186,7 +254,9 @@ export class GiteaClient implements GitClient {
sha: tag.commit?.sha ?? tag.id ?? null,
message: tag.message ?? null,
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) => ({
@@ -259,7 +329,10 @@ export class GiteaClient implements GitClient {
pullRequests: mappedPulls,
issues: mappedIssues,
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 },
);
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;
+120 -29
View File
@@ -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 {
BranchInfo,
CiInfo,
@@ -31,7 +39,9 @@ export class GitHubClient implements GitClient {
constructor(config: ProviderConfig) {
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;
}
@@ -61,7 +71,11 @@ export class GitHubClient implements GitClient {
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(
`${this.repoBase(ref)}/issues/${number}`,
"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>(
`${this.repoBase(ref)}/issues/${number}`,
{ headers: this.headers(), allow404: true },
@@ -82,25 +99,42 @@ export class GitHubClient implements GitClient {
async check(): Promise<ConnectionCheck> {
try {
if (!this.token) {
await getJson(`${this.apiBase}/rate_limit`, { headers: this.headers() });
await getJson(`${this.apiBase}/rate_limit`, {
headers: this.headers(),
});
return {
ok: true,
message: "Erreichbar (ohne Token – nur oeffentliche Repos, striktes Rate-Limit).",
message:
"Erreichbar (ohne Token – nur oeffentliche Repos, striktes Rate-Limit).",
user: null,
};
}
const user = await getJson<{ login: string; name?: string }>(`${this.apiBase}/user`, {
const user = await getJson<{ login: string; name?: string }>(
`${this.apiBase}/user`,
{
headers: this.headers(),
});
return { ok: true, message: `Verbunden als ${user.login}`, user: user.login };
},
);
return {
ok: true,
message: `Verbunden als ${user.login}`,
user: user.login,
};
} 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[]> {
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[]>(
`${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();
return (repos ?? [])
.filter((repo) => !needle || repo.full_name.toLowerCase().includes(needle))
.filter(
(repo) => !needle || repo.full_name.toLowerCase().includes(needle),
)
.map((repo) => ({
fullPath: repo.full_name,
owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "",
@@ -129,18 +165,58 @@ export class GitHubClient implements GitClient {
const defaultBranch = repo.default_branch;
const [branches, tags, commits, releases, languages, pulls, issues, ciRuns] =
await Promise.all([
getJsonSafe<GitHubBranch[]>(`${base}/branches?per_page=50`, [], { headers }),
getJsonSafe<GitHubTag[]>(`${base}/tags?per_page=25`, [], { headers }),
// Sammelt, welche Liste nicht geholt werden konnte und warum.
const teil = new Teilabrufe();
const [
branches,
tags,
commits,
releases,
languages,
pulls,
issues,
ciRuns,
] = await Promise.all([
teil.hol<GitHubBranch[]>("branches", `${base}/branches?per_page=50`, [], {
headers,
}),
teil.hol<GitHubTag[]>("tags", `${base}/tags?per_page=25`, [], {
headers,
}),
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
getJsonSafe<GitHubCommit[]>(`${base}/commits?per_page=100`, [], { headers }),
getJsonSafe<GitHubRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
getJsonSafe<GitHubPull[]>(`${base}/pulls?state=open&per_page=50`, [], { headers }),
// 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.
getJsonSafe<GitHubIssue[]>(`${base}/issues?state=open&per_page=50`, [], { headers }),
getJsonSafe<{ workflow_runs?: GitHubRun[] }>(
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 },
@@ -208,7 +284,10 @@ export class GitHubClient implements GitClient {
// open_issues_count zaehlt bei GitHub PRs mit – hier wieder herausrechnen.
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 ci: CiInfo | null = run
@@ -242,7 +321,10 @@ export class GitHubClient implements GitClient {
pullRequests: mappedPulls,
issues: mappedIssues,
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> {
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[]>(
`${base}/stats/commit_activity`,
{
headers: this.headers(),
});
},
);
if (!Array.isArray(wochen) || wochen.length === 0) {
// Entweder rechnet GitHub noch, oder das Repository hat keine Commits.
@@ -300,7 +385,9 @@ export class GitHubClient implements GitClient {
let datei: GitHubContent;
try {
datei = await getJson<GitHubContent>(`${base}/readme`, { headers: this.headers() });
datei = await getJson<GitHubContent>(`${base}/readme`, {
headers: this.headers(),
});
} catch (error) {
if (error instanceof GitApiError && error.status === 404) return null;
throw error;
@@ -337,8 +424,12 @@ interface GitHubWeek {
}
/** GitHub trennt Lauf-Status und Ergebnis; hier auf einen Wert zusammenfuehren. */
function mapRunState(status: string | null, conclusion: string | null): CiState {
if (status === "queued" || status === "waiting" || status === "pending") return "pending";
function mapRunState(
status: string | null,
conclusion: string | null,
): CiState {
if (status === "queued" || status === "waiting" || status === "pending")
return "pending";
if (status === "in_progress") return "running";
switch (conclusion) {
case "success":
+128 -31
View File
@@ -1,4 +1,12 @@
import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http";
import {
Teilabrufe,
getJson,
getJsonSafe,
normalizeBaseUrl,
sendJson,
shortSha,
toIso,
} from "./http";
import {
CALENDAR_PAGE_SIZE,
calendarByPaging,
@@ -61,7 +69,11 @@ export class GitLabClient implements GitClient {
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(
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
"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>(
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
{ headers: this.headers(), allow404: true },
@@ -83,15 +98,31 @@ export class GitLabClient implements GitClient {
async check(): Promise<ConnectionCheck> {
try {
if (!this.token) {
await getJson(`${this.apiBase}/projects?per_page=1`, { 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`, {
await getJson(`${this.apiBase}/projects?per_page=1`, {
headers: this.headers(),
});
return { ok: true, message: `Verbunden als ${user.username}`, user: user.username };
return {
ok: true,
message: "Erreichbar (ohne Token – nur oeffentliche Projekte).",
user: null,
};
}
const user = await getJson<{ username: string; name?: string }>(
`${this.apiBase}/user`,
{
headers: this.headers(),
},
);
return {
ok: true,
message: `Verbunden als ${user.username}`,
user: user.username,
};
} 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?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) => {
const path = project.path_with_namespace;
const segments = path.split("/");
@@ -127,23 +160,71 @@ export class GitLabClient implements GitClient {
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
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 [branches, tags, commits, releases, languages, mergeRequests, issues, pipelines] =
await Promise.all([
getJsonSafe<GitLabBranch[]>(`${base}/repository/branches?per_page=50`, [], { headers }),
getJsonSafe<GitLabTag[]>(`${base}/repository/tags?per_page=25`, [], { headers }),
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
getJsonSafe<GitLabCommit[]>(`${base}/repository/commits?per_page=100`, [], { headers }),
getJsonSafe<GitLabRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
getJsonSafe<GitLabMergeRequest[]>(`${base}/merge_requests?state=opened&per_page=50`, [], {
// Sammelt, welche Liste nicht geholt werden konnte und warum.
const teil = new Teilabrufe();
const [
branches,
tags,
commits,
releases,
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,
}),
getJsonSafe<GitLabIssue[]>(`${base}/issues?state=opened&per_page=50`, [], { headers }),
getJsonSafe<GitLabPipeline[]>(
// 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,
},
),
teil.hol<GitLabIssue[]>(
"issues",
`${base}/issues?state=opened&per_page=50`,
[],
{ headers },
),
teil.hol<GitLabPipeline[]>(
"ci",
`${base}/pipelines?per_page=1${defaultBranch ? `&ref=${encodeURIComponent(defaultBranch)}` : ""}`,
[],
{ headers },
@@ -155,7 +236,9 @@ export class GitLabClient implements GitClient {
sha: branch.commit?.id ?? null,
isDefault: Boolean(branch.default),
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,
lastCommitAuthor: branch.commit?.author_name ?? null,
url: branch.web_url ?? null,
@@ -240,7 +323,10 @@ export class GitLabClient implements GitClient {
pullRequests: mappedMergeRequests,
issues: mappedIssues,
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)}`,
{ 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 },
);
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;
const datei = await getJson<{ content?: string; encoding?: string; file_path?: string }>(
`${base}/repository/files/${encodeURIComponent(name)}?ref=HEAD`,
{ headers },
);
const datei = await getJson<{
content?: string;
encoding?: string;
file_path?: string;
}>(`${base}/repository/files/${encodeURIComponent(name)}?ref=HEAD`, {
headers,
});
if (!datei?.content || datei.encoding !== "base64") return null;
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 {
content,
+265
View File
@@ -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
View File
@@ -1,7 +1,37 @@
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
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 {
headers?: Record<string, string>;
@@ -73,7 +103,11 @@ export async function getJson<T>(
if (!response.ok) {
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();
@@ -150,7 +184,11 @@ export async function sendJson<T>(
if (!response.ok) {
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();
@@ -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>(
url: string,
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 {
if (!value) return null;
const date = new Date(String(value));
+9
View File
@@ -99,6 +99,15 @@ export interface RepoSnapshotData extends RepoOverview {
ci: CiInfo | null;
/** Commits je Tag der letzten 30 Tage, aeltester zuerst. */
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. */
+116 -31
View File
@@ -12,6 +12,15 @@ export interface SyncResult {
ok: boolean;
message: 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,
* 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({
where: { id: repositoryId },
include: { connection: true },
@@ -33,7 +44,8 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
if (!ref) {
return {
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) {
return {
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({
@@ -81,10 +94,28 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
// Nach dem Speichern, damit die Meldung den neuen Stand sieht.
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 {
ok: true,
unvollstaendig: fehlend.length > 0,
message:
`Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.` +
zahlen +
(abgehakt > 0
? ` ${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
* 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({
where: { projectId },
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
@@ -214,7 +247,10 @@ async function maybeRefreshCalendar(
// Rate-Limit, Zeitueberschreitung, Ausfall: der Kalender ist Beiwerk und
// soll den Abgleich nicht scheitern lassen. Ohne Zeitstempel versucht es
// 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.
const index = data.commits.findIndex((commit) => commit.sha === vorher);
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);
await recordActivity({
@@ -304,16 +342,38 @@ async function noteNewCommits(
anzahl === null
? `Neue Commits 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) {
const latestCommit = data.commits[0] ?? null;
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;
/**
* 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 = {
fullName: data.fullName,
description: data.description,
@@ -325,30 +385,55 @@ async function persistSnapshot(repository: Repository, data: RepoSnapshotData) {
openIssues: data.openIssues,
openPullRequests: data.openPullRequests,
sizeKb: data.sizeKb,
lastCommitSha: latestCommit?.sha ?? null,
lastCommitMessage: latestCommit?.message ?? null,
lastCommitAuthor: latestCommit?.author ?? null,
lastCommitAt: latestCommit?.date ? new Date(latestCommit.date) : null,
latestTag,
latestRelease: latestRelease?.name ?? null,
latestReleaseAt: latestRelease?.publishedAt ? new Date(latestRelease.publishedAt) : null,
branchCount: data.branches.length,
tagCount: data.tags.length,
branches: data.branches as unknown as object,
tags: data.tags as unknown as object,
commits: data.commits as unknown as object,
releases: data.releases as unknown as object,
languages: data.languages as unknown as object,
pullRequests: data.pullRequests as unknown as object,
issues: data.issues as unknown as object,
commitActivity: data.commitActivity as unknown as object,
ciState: data.ci?.state ?? null,
ciName: data.ci?.name ?? null,
ciBranch: data.ci?.branch ?? null,
ciUrl: data.ci?.url ?? null,
ciFinishedAt: data.ci?.finishedAt ? new Date(data.ci.finishedAt) : null,
// Die Commit-Felder haengen alle an derselben Liste – entweder alle
// neu oder alle unveraendert. Halb aktualisiert waeren sie in sich
// widerspruechlich.
lastCommitSha: geholt("commits", latestCommit?.sha ?? null),
lastCommitMessage: geholt("commits", latestCommit?.message ?? null),
lastCommitAuthor: geholt("commits", latestCommit?.author ?? null),
lastCommitAt: geholt(
"commits",
latestCommit?.date ? new Date(latestCommit.date) : null,
),
// latestTag faellt auf den Release-Tag zurueck; steht einer der beiden
// Abrufe aus, ist die Ableitung nicht mehr belastbar.
latestTag:
"tags" in data.missing || "releases" in data.missing
? undefined
: latestTag,
latestRelease: geholt("releases", latestRelease?.name ?? null),
latestReleaseAt: geholt(
"releases",
latestRelease?.publishedAt ? new Date(latestRelease.publishedAt) : 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,
missing: data.missing as unknown as object,
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.
+46
View File
@@ -7,6 +7,7 @@ import {
dueBucket,
nextDueDate,
progressFromTasks,
spaltenAnzeige,
} from "./tasks";
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);
}
}
}
});
});
+23
View File
@@ -168,6 +168,29 @@ export async function spawnRecurrence(taskId: string) {
* nicht die Spalte, sondern der Termin: projektuebergreifend ist "was ist
* 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 type DueBucket = (typeof DUE_BUCKET_ORDER)[number];
+2
View File
@@ -264,6 +264,8 @@ export const adminSettingsSchema = z.object({
autoSyncIntervalMinutes: z.coerce.number().int().min(5).max(10080).optional(),
autoSyncActiveOnly: z.boolean().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({
+17 -2
View File
@@ -47,7 +47,10 @@ export interface ConnectionWithCount extends ConnectionSafe {
* repositorySafeSelect. Ob einer eingerichtet ist, reicht die Projektseite
* getrennt durch.
*/
export interface RepositoryWithSnapshot extends Omit<Repository, "webhookSecret"> {
export interface RepositoryWithSnapshot extends Omit<
Repository,
"webhookSecret"
> {
connection: ConnectionSafe | null;
snapshot: RepoSnapshot | null;
}
@@ -159,7 +162,8 @@ export function asArray<T>(value: unknown): T[] {
export function asCalendar(value: unknown): CommitCalendar | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
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;
return {
from: kandidat.from,
@@ -176,3 +180,14 @@ export function asRecord(value: unknown): Record<string, number> {
}
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 {};
}