25 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
florian.wilde a2547b9022 Merge: Release v1.10.1
CI / pruefen (push) Canceled after 0s
2026-08-18 12:47:12 +00:00
florian.wilde fd633983b8 chore(release): v1.10.1
CI / pruefen (pull_request) Canceled after 0s
Zwei Aenderungen seit 1.10.0, beide bereits auf main: das Kennzeichen fuer
ein verknuepftes Git-Issue auf der Aufgabenkarte (#12) und die Token-Rechte,
Aufruf fuer Aufruf belegt (#10). Dazu die Korrektur am Hinweis unter dem
Token-Feld, der die Rechte fuer Actions nicht nannte - ohne die sieht bei
GitHub niemand einen CI-Zustand.

Verlauf und Versionsnummer wandern zusammen, sonst laufen sie auseinander;
changelog.test.ts haelt das fest. Die package-lock.json stand noch auf 1.2.0
und beschrieb damit ein Paket, das es so nie gab - mitgezogen.

Refs #10
Refs #12
2026-08-18 14:46:35 +02:00
florian.wilde 1b5f3fa1b2 Merge #12: Karte zeigt ein verknuepftes Git-Issue
CI / pruefen (push) Canceled after 0s
2026-08-18 12:39:33 +00:00
florian.wilde 6ee529576d feat(tasks): Karte zeigt, wenn ein Git-Issue daranhaengt
CI / pruefen (pull_request) Canceled after 0s
Die Verknuepfung stand bisher nur im Bearbeiten-Dialog und in der
projektuebergreifenden Uebersicht. Auf dem Brett eines Projekts - dort,
wo man die Aufgaben im Vorbeigehen sieht - war ihr nichts anzusehen.
Wer wissen wollte, welche Aufgabe schon ein Issue hat, musste jede
einzeln aufmachen.

Das Kennzeichen steht vorn in der Fusszeile der Karte, damit sich eine
Spalte danach ueberfliegen laesst, und traegt dasselbe Symbol wie der
Dialog: ein Sachverhalt, ein Zeichen.

Bewusst kein Link. Die Karte steckt in einem <button>, ein <a> darin
waere ungueltiges Markup; der Weg zum Issue fuehrt ueber den Dialog, der
ihn ohnehin anbietet. Die Nummer allein ergibt vorgelesen keinen Sinn,
deshalb steht "Verknuepft mit" als sr-only davor.

Der Fallstrick lag in der Bedingung darueber: die Fusszeile erschien nur
bei Beschreibung, Frist, Wiederholung oder Labels. Gerade eine aus einem
Issue erzeugte Aufgabe hat davon oft nichts - sie waere leer geblieben.

Abzudunkeln, sobald die Aufgabe erledigt ist, war versucht und wieder
verworfen: axe-core rechnete text-violet-300/50 bei 10px als
Kontrastverstoss ab.

Nachgesehen statt angenommen: Brett mit sechs Aufgaben (verknuepft,
unverknuepft, nackt, vollbepackt, erledigt, vierstellige Nummer) auf
1280 und 375 px - kein seitlicher Ueberlauf, axe-core ohne Verstoss.

Refs #12
2026-08-18 14:04:45 +02:00
florian.wilde 77567c06cd Merge #10: Token-Rechte vollstaendig dokumentiert
CI / pruefen (push) Canceled after 0s
2026-08-18 11:51:06 +00:00
florian.wilde e9f01ba8fa docs(git): jeden ausgehenden Aufruf einzeln mit seinem Recht belegen
CI / pruefen (pull_request) Canceled after 0s
Die Faelle A und B beantworteten die Frage auf Ebene der Scopes, nicht
auf Ebene der Aufrufe. Wer nachpruefen wollte, ob ein Recht wirklich
noetig ist, musste die drei Clients selbst lesen.

Jetzt steht in der README je System eine vollstaendige Tabelle: Aufruf,
Zweck, benoetigtes Recht. Die Zuordnung bei GitHub folgt der offiziellen
Berechtigungsuebersicht - dass /tags und /stats/commit_activity unter
Metadata fallen und nicht unter Contents, ist nicht zu erraten. Fuer
GitLab ist mit Quellenangabe belegt, dass es unterhalb von 'api' keinen
Schreibzugriff auf Issues gibt.

Ausserdem war offen, woher die Kommentare an den Issues stammen: Nebula
schreibt keine. Der Schreibzugriff besteht aus genau zwei Aufrufen. Ein
Test klopft dafuer die Clients ab - kaeme ein dritter dazu, wuerde die
Empfehlung 'nimm ein lesendes Token' still falsch, ohne dass jemand
einen Fehler saehe.

Der Hinweis am Eingabefeld nannte Actions nicht, obwohl der CI-Zustand
bei GitHub genau daran haengt.

Refs #10
2026-08-18 12:57:02 +02:00
florian.wilde 7920b6a20f Token-Rechte vollstaendig dokumentiert
CI / pruefen (pull_request) Canceled after 0s
Die Angaben stammten aus der Zeit, als Nebula nur gelesen hat. Seit v1.7.0
legt es Issues an und schliesst sie - wer sich an README oder den Hinweis im
Verbindungsdialog hielt, bekam dabei einen 403.

Erhoben wurde es nicht aus dem Gedaechtnis, sondern aus dem Code: jeder
ausgehende Aufruf in lib/git/gitea.ts, github.ts und gitlab.ts, abgebildet
auf das noetige Recht.

Aufgeschluesselt nach Funktion statt nach System, damit erkennbar ist, was
sich weglassen laesst: der Abgleich allein kommt rein lesend aus, nur die
Issue-Anbindung schreibt.

**GitLab ist der unangenehme Fall.** Es kennt keinen schmalen Schreib-Scope
fuer Issues - dort geht nur "api", also alles, was das Konto darf. Das steht
jetzt ausdruecklich da, samt der Alternative: bei read_api bleiben und auf
die Issue-Anbindung verzichten. Der Abgleich laeuft dann vollstaendig, und
geschlossene Issues werden weiterhin erkannt, nur nicht zurueckgeschrieben.

Ebenfalls festgehalten, was Nebula *nicht* braucht: Schreibrechte auf Code,
Adminrechte, Rechte zum Anlegen von Webhooks. Letzteres ist der Grund, warum
der Haken von Hand eingetragen wird - so bleibt der Token davon frei.

Der Hinweis in lib/git/meta.ts steht direkt am Eingabefeld und ist die
Stelle, die tatsaechlich gelesen wird; er ist deshalb mitgezogen. Dazu ein
Waechter in meta.test.ts: jeder Provider muss in GitClient createIssue und
setIssueState koennen, folglich muss auch jeder Hinweis sagen, was das an
Rechten kostet. Genau diese Kopplung war hier gerissen.

Geprueft: 267 Unit-Tests (6 neue), 36 E2E-Tests, Hinweistext je System gegen
die laufende Anwendung gelesen, axe-core auf 375px und 1280px ohne
Beanstandung.

Refs #10
2026-08-18 12:10:17 +02:00
florian.wilde 6c3f2d5bb6 Merge: Dokumentation auf Stand v1.10.0
CI / pruefen (push) Canceled after 0s
2026-08-18 06:23:04 +00:00
florian.wilde 3617222cc1 docs: alle Markdown-Dokumente auf Stand v1.10.0
CI / pruefen (pull_request) Canceled after 0s
Die Dokumente waren unterschiedlich weit zurueck: FEATURES bei v1.9.0,
README bei v1.8.0, DEPLOYMENT und CLAUDE.md noch bei v1.5.0.

**README.** Die Einleitung nannte weder die projektuebergreifenden Aufgaben
noch Rueckblick, Zeitleiste oder die Issue-Anbindung. In der API-Tabelle
fehlten vier Routen (/api/activity, /api/tasks/:id/issue,
/api/repositories/:id/webhook und der eingehende /api/webhooks/:id); die
Suche war noch als "ueber die eigenen Notizen" beschrieben. Die
Projektstruktur zeigte ein lib/sync.ts, das laengst ein Verzeichnis ist, und
kannte weder tasks/ noch rueckblick/ oder verlauf/.

Unter "Getestet mit" stand Node 26.5.0, tatsaechlich laeuft 24.19.0. Die
Tabelle nennt jetzt den Werkzeugstand mit Datum und trennt ihn von der
Aussage, wann zuletzt gegen echte Git-Systeme geprueft wurde - das war im
Juli und ist seitdem nicht wiederholt worden.

**FEATURES.** Neues Kapitel 2c zu Rueckblick und Zeitleiste, samt der beiden
Entscheidungen, die sonst niemand nachvollziehen kann: Kalendertage in fester
Zeitzone statt "7 x 24 Stunden", und "erledigt" aus dem meta-Feld statt aus
dem Anzeigetext. Das Suchkapitel deckt jetzt Aufgaben mit ab.

**DEPLOYMENT.** Der Abschnitt "Updates" sprang bisher direkt ins Skript,
ohne zu erklaeren, was ein Update ueberhaupt ist - genau daran ist die Frage
"was meinst du mit Deploy?" entstanden. Jetzt steht vorneweg, dass Git und
laufende Instanz zwei Orte sind und woran man sieht, welche Fassung laeuft.
Dazu ein Abschnitt zu Webhooks (setzt Erreichbarkeit voraus, ohne sie geht
aber nichts verloren) und die Sitzungsfristen in der Haertungsliste.

**CLAUDE.md.** Sechs Fallstricke aus den letzten Runden, die sonst beim
naechsten Mal erneut Zeit kosten - allen voran "min-width: auto" bei Flex-
und Grid-Kindern, das hier zweimal zugeschlagen hat. Ausserdem: dass die
feste Zeitzone auch beim Rechnen gilt, dass Dialoge der Bildschirmtastatur
ausweichen muessen, dass ein Ausdrucksindex nur bei Zeichengleichheit
greift (mit EXPLAIN nachsehen), dass ein leeres Migrationsverzeichnis
"migrate diff" abbrechen laesst - und dass eine Ueberlaufmessung gegen
unvollstaendige Testdaten faelschlich gruen meldet.

Geprueft: Querverweise zwischen den Dokumenten aufgeloest, alle in README
und DEPLOYMENT genannten Umgebungsvariablen gegen Code und .env.example
gehalten, API-Tabelle gegen die tatsaechlich vorhandenen Routen.
2026-08-18 08:20:57 +02:00
florian.wilde 43a4c70053 docs(roadmap): Stand auf v1.10.0 gebracht
CI / pruefen (pull_request) Canceled after 0s
Zuletzt angefasst bei v1.7.0. Seitdem sind drei Fassungen und vier Issues
durchgelaufen, ohne dass die Roadmap es mitbekommen haette.

Erledigt und deshalb aus den offenen Abschnitten entfernt:
Wochenrueckblick, globale Zeitleiste und die Suche ueber Aufgaben - alle
drei mit v1.10.0. Webhooks standen noch als Vorschlag drin, obwohl sie seit
v1.7.0 laufen. In der Erledigt-Tabelle fehlten v1.8.0 bis v1.10.0.

Die Nummerierung war ausserdem lueckenhaft: 1.0, dann 1.2 - der Punkt 1.1
war beim Umsetzen herausgeloescht worden. Jetzt wieder fortlaufend, und
1.0 (Rest aus v1.6.0) ist mit 1.3 zusammengefasst, weil beide dasselbe
meinten.

Neu ist ein Abschnitt "Aufgefallen beim Bauen": fuenf Beobachtungen aus der
Arbeit an v1.6.0 bis v1.10.0, die sonst nur in Issue-Kommentaren und im
Chat gestanden haetten - der abgeschnittene Anwendungsname auf schmalen
Anzeigen, die fehlenden Indizes fuer die inzwischen vier
projektuebergreifenden Seiten, der fehlende Projektfilter auf der
Zeitleiste, der offene ASVS-Punkt zu geleakten Passwoertern und die zwei
Fassungen ohne Release-Anker.

Nichts davon ist dringend; festgehalten, damit es nicht verloren geht.
2026-08-18 08:10:32 +02:00
florian.wilde 65a31b7cc9 Merge: v1.10.0
CI / pruefen (push) Canceled after 0s
2026-08-18 05:49:55 +00:00
36 changed files with 2372 additions and 286 deletions
+65 -3
View File
@@ -64,6 +64,41 @@ SSRF-Schutz (`lib/security/ssrf.ts`).
- **Datumsangaben** über `lib/utils` formatieren (feste Zeitzone), sonst - **Datumsangaben** über `lib/utils` formatieren (feste Zeitzone), sonst
weichen Server-Render und Hydration voneinander ab. Relative Zeiten weichen Server-Render und Hydration voneinander ab. Relative Zeiten
bekommen `suppressHydrationWarning`. bekommen `suppressHydrationWarning`.
- **Die feste Zeitzone gilt auch beim *Rechnen*, nicht nur beim Formatieren.**
Im Container läuft die Anwendung nach UTC; „welcher Kalendertag" und
„welche Woche" kippen dort abends um einen Tag. `lib/tasks` (`dueBucket`)
und `lib/review` (`wocheUm`) rechnen deshalb über Berliner Kalendertage.
Und eine Woche ist **nicht** „7 × 24 Stunden" — bei einer Zeitumstellung
hat sie 167 oder 169.
- **`min-width: auto` bei Flex- und Grid-Kindern.** Hat hier zweimal
zugeschlagen: ein Kind weigert sich, unter die Inhaltsbreite seines
breitesten Kindes zu schrumpfen, und drückt stattdessen die Nachbarn aus
dem Bild. Einmal war es ein Sortierfeld mit `min-width`, das „Neues
Projekt" hinausschob; einmal die Heatmap, die die ganze Projektseite
doppelt so breit machte. Heilmittel ist beide Male `min-w-0` am Kind —
ohne das greift auch ein `overflow-x-auto` darin nicht.
- **`body { overflow-x: hidden }` versteckt genau diese Fehler.** Es steht in
`globals.css` als Notnagel; was es abschneidet, ist auf dem Telefon
schlicht nicht mehr erreichbar. Ursachen gehören in die Komponente.
- **Dialoge müssen der Bildschirmtastatur ausweichen.** Sie verkleinert nur
das *sichtbare* Fenster, nicht das Layoutfenster — `position: fixed` und
`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`:
`(createdAt, id)` als Cursor, Sortierung identisch zur Bedingung.
## Migrationen ## Migrationen
@@ -83,9 +118,24 @@ vergleicht mit der *laufenden* Datenbank. Ist eine eben erzeugte Migration
dort noch nicht eingespielt, steht ihr Inhalt in der nächsten gleich noch dort noch nicht eingespielt, steht ihr Inhalt in der nächsten gleich noch
einmal. einmal.
**Die erzeugte SQL vor dem Anwenden lesen.** Der Volltextindex **Das Zielverzeichnis vorher *nicht* anlegen.** Ein leerer Ordner unter
`Note_fulltext_idx` ist ein Ausdrucksindex, den Prisma nicht kennt (er wird `prisma/migrations/` lässt `--from-migrations` mit `P3015` abbrechen
bislang in Ruhe gelassen, aber das ist nicht garantiert). („Could not find the migration file"). Erst die SQL woandershin erzeugen,
dann Ordner anlegen und hineinkopieren.
**Die erzeugte SQL vor dem Anwenden lesen.** Die Volltextindizes
`Note_fulltext_idx` und `Task_fulltext_idx` sind Ausdrucksindizes, die Prisma
nicht kennt (sie werden bislang in Ruhe gelassen, aber das ist nicht
garantiert).
**Ein Ausdrucksindex greift nur bei Zeichengleichheit.** Weicht der Ausdruck
in der Abfrage auch nur in einem `coalesce` vom Index ab, rechnet Postgres
`to_tsvector` je Zeile neu — ohne Fehlermeldung. Nachsehen statt annehmen:
```bash
docker exec nebula-db psql -U nebula -d <db> -c "SET enable_seqscan = off;
EXPLAIN (COSTS OFF) SELECT …" # muss einen Bitmap Index Scan zeigen
```
**Datenmigrationen von Hand schreiben.** `migrate diff` kennt nur den **Datenmigrationen von Hand schreiben.** `migrate diff` kennt nur den
Zielzustand: Spalten, die woandershin wandern, würde es ersatzlos löschen. Zielzustand: Spalten, die woandershin wandern, würde es ersatzlos löschen.
@@ -103,6 +153,18 @@ Bei Änderungen an der Oberfläche **hinsehen**. Chrome ist installiert, ein
Skript mit `playwright-core` und `channel: "chrome"` genügt für Screenshots. Skript mit `playwright-core` und `channel: "chrome"` genügt für Screenshots.
Mehrere Anzeigefehler dieses Projekts sind ausschließlich so aufgefallen. Mehrere Anzeigefehler dieses Projekts sind ausschließlich so aufgefallen.
**Seitlichen Überlauf misst man.** `document.documentElement.scrollWidth`
gegen `clientWidth` auf 375 px — das findet in Sekunden, was beim Ansehen
durchrutscht. Ein Skript, das dabei nur die *obersten* Verursacher meldet
(Eltern, die selbst schon überlaufen, herausfiltern), ist deutlich brauchbarer
als eine Liste aller betroffenen Knoten.
**Die Testdaten müssen die Bausteine enthalten, um die es geht.** Eine
Messung gegen ein Projekt ohne Repository, ohne Snapshot und ohne README
bescheinigte hier einmal „alles in Ordnung", während die echte Projektseite
doppelt so breit war wie der Bildschirm. Erst mit vollständigen Daten
nachgestellt trat der Fehler zutage.
**Barrierefreiheit misst man, statt sie zu behaupten.** `axe-core` liegt als **Barrierefreiheit misst man, statt sie zu behaupten.** `axe-core` liegt als
Entwicklungsabhängigkeit bereit; ein Skript hängt es per `addScriptTag` in Entwicklungsabhängigkeit bereit; ein Skript hängt es per `addScriptTag` in
die laufende Seite und ruft `axe.run` mit den WCAG-Tags auf. Jeder die laufende Seite und ruft `axe.run` mit den WCAG-Tags auf. Jeder
+42 -1
View File
@@ -283,6 +283,23 @@ Zurückspielen:
## Updates ## Updates
Es sind zwei getrennte Orte, und das ist der Punkt, an dem es leicht
durcheinandergeht:
| | Was dort liegt |
| --- | --- |
| **Das Git-Repository** | Der Quellcode. Dorthin wird entwickelt. |
| **Diese Instanz** | Die laufende Anwendung, die du im Browser benutzt. |
Ein Commit im Git ändert am laufenden Nebula **nichts**. Der Server holt sich
von allein nichts ab; er betreibt weiter die Fassung, die dort zuletzt gebaut
wurde. Ein Update ist der Schritt dazwischen: holen, bauen, umschalten.
**Woran du siehst, was gerade läuft:** die Versionsnummer unten in der
Fußzeile. Ein Klick darauf öffnet den Änderungsverlauf. Steht dort eine
ältere Nummer als im Git, fehlen dir die neueren Funktionen — sie sind
geschrieben, aber noch nicht auf diesem Server.
Dafür gibt es `scripts/update.sh`. Es macht alles in der richtigen Dafür gibt es `scripts/update.sh`. Es macht alles in der richtigen
Reihenfolge und bricht ab, sobald ein Schritt scheitert: Reihenfolge und bricht ab, sobald ein Schritt scheitert:
@@ -308,7 +325,7 @@ nennt den Weg zurück.
```bash ```bash
./scripts/update.sh --yes # ohne Rückfrage, für Cron ./scripts/update.sh --yes # ohne Rückfrage, für Cron
./scripts/update.sh --ref v1.4.0 # auf eine bestimmte Fassung ./scripts/update.sh --ref v1.9.0 # auf eine bestimmte Fassung
./scripts/update.sh --no-pull # nur neu bauen, ohne git pull ./scripts/update.sh --no-pull # nur neu bauen, ohne git pull
``` ```
@@ -354,6 +371,23 @@ docker system df
docker image prune -f # alte Abbilder nach mehreren Updates docker image prune -f # alte Abbilder nach mehreren Updates
``` ```
### Webhooks
Der Abgleich holt den Stand in seinem Takt. Wer ihn **sofort** haben will —
ein Push ist durch, ein Issue wurde geschlossen —, trägt im Git-System einen
Webhook ein. Nebula erzeugt URL und Geheimnis am Repository (Funksymbol im
Git-Bereich der Projektseite), eingetragen wird der Haken von Hand.
Voraussetzung ist, dass **dein Git-System diese Instanz erreicht**. Hinter
dem Reverse Proxy ist das gegeben; in einem abgeschotteten Netz nicht. Der
Endpunkt liegt unter `/api/webhooks/<repositoryId>` und ist der einzige ohne
Anmeldung — ausgewiesen wird sich über eine Signatur, die gegen das
hinterlegte Geheimnis geprüft wird.
**Ohne Webhook geht nichts verloren.** Der Abgleich prüft den Zustand
verknüpfter Issues ohnehin mit und hakt erledigte Aufgaben nach. Der Webhook
macht es nur sofort statt beim nächsten Durchlauf.
### Automatischer Abgleich ### Automatischer Abgleich
Standardmäßig **aus**. Einzuschalten unter *Admin-Einstellungen → Standardmäßig **aus**. Einzuschalten unter *Admin-Einstellungen →
@@ -383,6 +417,13 @@ Damit du weißt, worum du dich **nicht** kümmern musst:
Zugriffe; Kontosperre nach acht Fehlversuchen. Zugriffe; Kontosperre nach acht Fehlversuchen.
- **Git-Tokens** liegen AES-256-GCM-verschlüsselt in der Datenbank und - **Git-Tokens** liegen AES-256-GCM-verschlüsselt in der Datenbank und
verlassen den Server nie — auch nicht im Export. verlassen den Server nie — auch nicht im Export.
- **Sitzungen** laufen nach 8 Stunden ohne Nutzung ab und spätestens nach
30 Tagen (`SESSION_IDLE_HOURS`, `SESSION_TTL_DAYS`). In der Datenbank liegt
nur der Hash des Tokens; das Cookie ist `HttpOnly`, `SameSite=Strict` und
bei HTTPS zusätzlich mit dem Präfix `__Host-`.
- **Passwörter** werden mit scrypt nach der Empfehlung des OWASP Password
Storage Cheat Sheet gehasht (N = 2^17). Rechnet mit rund einer halben
Sekunde je Anmeldung — das ist gewollt.
- Der **Bildoptimierer** von Next ist abgeschaltet: Nebula benutzt ihn nicht, - Der **Bildoptimierer** von Next ist abgeschaltet: Nebula benutzt ihn nicht,
und `/_next/image` wäre sonst eine Angriffsfläche ohne Gegenwert. und `/_next/image` wäre sonst eine Angriffsfläche ohne Gegenwert.
+146 -6
View File
@@ -145,12 +145,17 @@ dort wäre er auf schmalen Anzeigen mit der Auswahlleiste kollidiert.
Favoriten bleiben stets oben. Favoriten bleiben stets oben.
- **Vier Ansichten**: freies Raster, **kompakte Liste**, nach Status gruppierte Abschnitte - **Vier Ansichten**: freies Raster, **kompakte Liste**, nach Status gruppierte Abschnitte
oder **Kanban**. oder **Kanban**.
- **Volltextsuche über Notizen.** Die Suche im Board arbeitet auf den geladenen Projekten - **Volltextsuche über Notizen und Aufgaben.** Die Suche im Board arbeitet auf den
(Name, Kurzbeschreibung, Tags, Repo-Pfad). Notizen liegen dort nicht vor — sie geladenen Projekten (Name, Kurzbeschreibung, Tags, Repo-Pfad). Notizen und Aufgaben
mitzuladen würde die Seite aufblähen. Stattdessen sucht Postgres serverseitig mit liegen dort nicht vor — sie mitzuladen würde die Seite aufblähen. Stattdessen sucht
seiner deutschen Textsuche, entprellt ab zwei Zeichen. Treffer erscheinen als eigene Postgres serverseitig mit seiner deutschen Textsuche, entprellt ab zwei Zeichen.
Liste mit hervorgehobener Fundstelle, die betroffenen Projekte werden zusätzlich Treffer erscheinen als zwei eigene Listen mit hervorgehobener Fundstelle, die
eingeblendet. Deutsche Stammformen greifen: „Webhook" findet auch „Webhooks". betroffenen Projekte werden zusätzlich eingeblendet. Deutsche Stammformen greifen:
„Webhook" findet auch „Webhooks", „Rechnung" auch „Rechnungen".
Beide Seiten haben je einen **Ausdrucksindex** (`Note_fulltext_idx`,
`Task_fulltext_idx`). Der Ausdruck darin muss Zeichen für Zeichen dem in der Abfrage
entsprechen, sonst greift er nicht und Postgres rechnet `to_tsvector` je Zeile neu.
- **Archiv** ist standardmäßig ausgeblendet und per Schalter einblendbar. - **Archiv** ist standardmäßig ausgeblendet und per Schalter einblendbar.
- **Leerzustände** mit direktem Einstieg statt einer leeren Fläche. - **Leerzustände** mit direktem Einstieg statt einer leeren Fläche.
@@ -250,6 +255,23 @@ Blockiert · Erledigt**.
Fälligkeitsdatum und Labels. Fälligkeitsdatum und Labels.
- **Fälligkeiten** färben sich mit: überfällig rot, heute gelb, in den nächsten Tagen - **Fälligkeiten** färben sich mit: überfällig rot, heute gelb, in den nächsten Tagen
gedämpft. gedämpft.
- **Hängt ein Git-Issue daran**, trägt die Karte dessen Nummer — vorn in der Fußzeile,
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 - Erledigtes wird durchgestrichen; der Zeitpunkt wird festgehalten und beim Zurückziehen
wieder gelöscht. wieder gelöscht.
@@ -341,6 +363,54 @@ Die Aufgabe kennt keinen Besitzer — die Trennung läuft über das Projekt
--- ---
## 2c. Rückblick und Verlauf
Zwei Sichten auf denselben Aktivitätsverlauf, über alle Projekte hinweg. Beide teilen
sich Zeilendarstellung und Tagesgruppierung (`components/ActivityRow`) — zwei Kopien
wären beim nächsten Feinschliff auseinandergelaufen.
Der Verlauf kennt keinen Besitzer; die Trennung läuft über das Projekt
(`project: { ownerId }` als erste Bedingung).
### Wochenrückblick
`/rueckblick` beantwortet „was ist diese Woche passiert". Oben Kennzahlen — Vorgänge,
berührte Projekte, erledigte und neue Aufgaben —, darunter die Vorgänge nach
Kalendertagen gebündelt, neuester Tag zuerst. Über `?woche=-n` geht es bis zu 25 Wochen
zurück; weiter lohnt nicht, weil der Verlauf ohnehin nur 200 Einträge je Projekt hält.
**Gerechnet wird auf Kalendertage in fester Zeitzone** (Europe/Berlin), nicht auf
„sieben mal 24 Stunden": liegt eine Zeitumstellung in der Woche, hat sie 167 oder 169
Stunden. Und im Container läuft die Anwendung nach UTC — ein Eintrag von Sonntagabend
fiele sonst in die falsche Woche. Das Fach bestimmt der **Server**, damit Server-Render
und Hydration nicht auseinanderlaufen.
**„Aufgaben erledigt" wird am `meta`-Feld abgelesen** (`to: "DONE"`), nicht am
Anzeigetext. Der Text ist zum Lesen da und ändert sich mit der Formulierung; eine
Zählung daran würde beim nächsten umformulierten Einzeiler unbemerkt kippen. Einträge
ohne `meta` zählen nicht mit — lieber untertreiben als raten.
### Globale Zeitleiste
`/verlauf` zeigt denselben Verlauf **fortlaufend**: ohne Wochengrenze, ohne Fazit, mit
„Ältere laden". Die erste Seite kommt serverseitig mit, alles Weitere über
`/api/activity`.
**Geblättert wird per Cursor** über `(createdAt, id)`, nicht per `skip`. Der Verlauf
schreibt mehrere Einträge im selben Augenblick — beim Übernehmen von Issues entstehen
zehn Zeilen mit identischem Zeitstempel. Ein Offset über eine solche Grenze hinweg
überspringt Zeilen oder zeigt sie zweimal, und zwar lautlos. Der zweite Zweig der
Bedingung (gleicher Zeitpunkt, kleinere ID) fängt genau das ab.
Nachgerechnet gegen 144 Einträge mit absichtlichen Blöcken gleicher Zeitstempel: 15
Seiten, 144 Zeilen, keine Dublette, streng absteigend.
Beide Seiten sind über die Schnellsuche erreichbar, der Rückblick zusätzlich über einen
Verweis vom Dashboard. Bewusst **kein** weiterer Punkt in der Kopfleiste — die ist auf
dem Telefon schon eng.
---
## 3. Notizen ## 3. Notizen
- **Beliebig viele Notizen pro Projekt**, mit optionalem Titel und mehrzeiligem Text. - **Beliebig viele Notizen pro Projekt**, mit optionalem Titel und mehrzeiligem Text.
@@ -432,6 +502,40 @@ Firmen-Gitea nebeneinander.
Kurzbeschreibung. Kurzbeschreibung.
- **Löschen** trennt nur die Verknüpfung; die Projekte selbst bleiben erhalten. - **Löschen** trennt nur die Verknüpfung; die Projekte selbst bleiben erhalten.
#### Nötige Token-Rechte
Nebula liest fast alles nur. Geschrieben wird an **genau zwei Stellen**: `POST …/issues`
beim Anlegen und `PATCH`/`PUT …/issues/{nr}` beim Schließen oder Wiederöffnen (4.6). Wer
darauf verzichtet, kommt mit einem rein lesenden Token aus.
| System | Nur Abgleich | Zusätzlich für die Issue-Anbindung |
| --- | --- | --- |
| GitHub | *Metadata*, *Contents*, *Issues*, *Pull requests*, *Actions* — je lesend | *Issues: Read and write* |
| GitLab | `read_api` | `api` |
| Gitea / Forgejo | `read:repository`, `read:issue`, `read:user` | `write:issue` |
Welcher Aufruf welches Recht verlangt, steht Zeile für Zeile in der
[README](README.md#welche-rechte-der-token-braucht) — dort ist jeder ausgehende Aufruf
der drei Clients aufgeführt.
**GitLab kennt keinen schmalen Schreib-Scope.** `api` erlaubt alles, was das Konto darf,
Code eingeschlossen. Wem das zu weit geht, bleibt bei `read_api`: der Abgleich läuft
vollständig, geschlossene Issues werden weiterhin erkannt — nur schreibt Nebula dann
nicht zurück.
**Nicht verlangt** werden Schreibrechte auf Code, Adminrechte und Rechte zum Anlegen von
Webhooks. Der Haken wird von Hand im Git-System eingetragen (4.7); das erspart dem Token
genau die Administrationsrechte, die er sonst bräuchte.
**Kommentare schreibt Nebula nie** — weder beim Anlegen noch beim Schließen. In den
Rumpf des angelegten Issues wandert die Aufgabenbeschreibung plus eine Zeile Rückverweis
auf das Projekt; danach bleibt der Text unangetastet. Was sonst an Kommentaren und
Labels an einem Issue hängt, kommt aus der Weboberfläche oder von einem anderen Werkzeug.
Ein fehlendes Recht endet in einem `403` mit „Token hat nicht die noetigen Scopes",
sichtbar am Repository auf der Projektseite. Klein anfangen und gezielt nachlegen ist
deshalb ohne Risiko.
### 4.3 Was beim Sync geholt wird ### 4.3 Was beim Sync geholt wird
| Bereich | Details | | Bereich | Details |
@@ -449,6 +553,42 @@ Firmen-Gitea nebeneinander.
| Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung | | Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung |
| Sprachen | Verteilung als Balken mit Prozentwerten | | Sprachen | Verteilung als Balken mit Prozentwerten |
#### Wenn ein Teil nicht ankommt
Diese Listen werden gleichzeitig geholt, und einzelne davon können scheitern, während
der Rest durchgeht — ein 403 wegen fehlender Scopes, ein Rate-Limit, eine
Zeitüberschreitung.
**Ein zweiter Versuch, bevor etwas als fehlend gilt.** Zeitüberschreitungen und
Verbindungen, die gar nicht zustande kamen, werden einmal nachgeschlagen — einzeln
statt im Schwung, weil genau das der beobachtete Fall war: zwei von acht Aufrufen
liefen in eine Zeitüberschreitung nach 15 Sekunden, während dieselben Aufrufe einzeln
in 80 Millisekunden antworteten. Nachgeschlagen wird höchstens zweimal je Abgleich —
acht Zeitüberschreitungen hintereinander wären zwei Minuten. Bei `403`, `404` und `429`
gibt es keinen zweiten Versuch: fehlende Rechte werden durch Wiederholen nicht besser,
ein Rate-Limit wird schlimmer.
**Ein gescheiterter Abruf ist keine leere Liste.** Der Unterschied steht am Snapshot: zu
jedem Feld, das nicht geholt werden konnte, wird der Grund samt HTTP-Status festgehalten.
Daraus folgt dreierlei:
- Die vorhandenen Daten **bleiben stehen**. Ein Abgleich, der die Tags nicht holen konnte,
ersetzt die gespeicherten nicht durch eine leere Liste. Angezeigt wird dann der letzte
vollständige Stand, mit Datum.
- Die Meldung heißt „Teilweise aktualisiert – nicht geholt: …" statt „Snapshot
aktualisiert: 0 Branches, 0 Tags".
- Auf der Projektseite steht ein Hinweis am Repository, der jedes fehlende Feld mit
seinem Grund nennt.
Das gilt ausdrücklich **nicht** als Fehlschlag: `syncError` bleibt leer, der Zähler für
das Zurückhalten wird nicht erhöht, und der nächste Durchlauf versucht es normal wieder.
Der Rest des Standes ist ja frisch.
> Vor v1.11.0 wurde jeder Fehlschlag in eine leere Liste verwandelt. Ein Repository mit
> 65 Commits meldete „Snapshot aktualisiert: 0 Branches, 0 Tags", überschrieb dabei den
> letzten guten Stand, und aus der Oberfläche war nicht zu erkennen, dass überhaupt etwas
> schiefgegangen war.
Woher der CI-Status kommt: Woher der CI-Status kommt:
| System | Quelle | | System | Quelle |
+165 -20
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"> <div align="center">
# ✦ Nebula # ✦ Nebula
**Das Kontrollzentrum für deine Vibe-Coding Projekte.** **Das Kontrollzentrum für deine Vibe-Coding Projekte.**
Ideen festhalten · Status pflegen · Notizen sammeln · Git-Repos live im Blick
<sub>Next.js 15 · TypeScript · Tailwind CSS 4 · Prisma · PostgreSQL · Docker</sub> <sub>Next.js 15 · TypeScript · Tailwind CSS 4 · Prisma · PostgreSQL · Docker</sub>
</div> </div>
@@ -22,6 +22,12 @@ Jedes Projekt bekommt einen **Status** (von der rohen Idee bis „Fertig"), beli
Branches, Tags, Commits, Releases und offene Issues/PRs — egal ob **GitHub**, **GitLab** Branches, Tags, Commits, Releases und offene Issues/PRs — egal ob **GitHub**, **GitLab**
oder eine selbst gehostete **Gitea**-Instanz. oder eine selbst gehostete **Gitea**-Instanz.
Neben dem Projektbrett gibt es **Aufgaben über alle Projekte hinweg** (nach Fälligkeit
geordnet), einen **Wochenrückblick** und eine **fortlaufende Zeitleiste** aus dem
Aktivitätsverlauf. Aufgaben lassen sich als **Git-Issue** anlegen; wird das Issue
geschlossen, hakt Nebula die Aufgabe ab — sofort per **Webhook** oder verzögert beim
nächsten Abgleich.
Die Anmeldung läuft über **Benutzername und Passwort oder Passkey** (WebAuthn). Nebula Die Anmeldung läuft über **Benutzername und Passwort oder Passkey** (WebAuthn). Nebula
kennt zwei Betriebsarten: **Einzelbetrieb** für ein Konto oder **Mehrbenutzerbetrieb**, in kennt zwei Betriebsarten: **Einzelbetrieb** für ein Konto oder **Mehrbenutzerbetrieb**, in
dem jede Person ihren eigenen, streng getrennten Bereich hat. Umschalten geht jederzeit in dem jede Person ihren eigenen, streng getrennten Bereich hat. Umschalten geht jederzeit in
@@ -125,7 +131,7 @@ Alle Variablen sind in [`.env.example`](.env.example) dokumentiert.
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` | | `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` |
| `POSTGRES_PORT` | Host-Port der Datenbank — wirkt nur mit `docker-compose.dev.yml`, sonst hat die DB keinen | `127.0.0.1:5432` | | `POSTGRES_PORT` | Host-Port der Datenbank — wirkt nur mit `docker-compose.dev.yml`, sonst hat die DB keinen | `127.0.0.1:5432` |
| `ADMINER_PORT` | Host-Port von Adminer (`--profile tools`) | `127.0.0.1:8080` | | `ADMINER_PORT` | Host-Port von Adminer (`--profile tools`) | `127.0.0.1:8080` |
| `GIT_API_TIMEOUT_MS` | Timeout für Git-API-Aufrufe | `15000` | | `GIT_API_TIMEOUT_MS` | Timeout für Git-API-Aufrufe, in Millisekunden. Alles unter 1000 oder Unlesbares wird verworfen (mit Warnung im Log) — sonst bräche jeder Aufruf sofort ab | `15000` |
| `GIT_ALLOW_LOOPBACK` | Git-Instanz auf demselben Rechner erlauben | `false` | | `GIT_ALLOW_LOOPBACK` | Git-Instanz auf demselben Rechner erlauben | `false` |
| `GIT_BLOCK_PRIVATE` | Zugriff ins private LAN sperren | `false` | | `GIT_BLOCK_PRIVATE` | Zugriff ins private LAN sperren | `false` |
| `GIT_ALLOWED_HOSTS` | Strengster Modus: nur diese Hosts erlauben | leer | | `GIT_ALLOWED_HOSTS` | Strengster Modus: nur diese Hosts erlauben | leer |
@@ -167,14 +173,8 @@ Nebula auffindbare Credentials nutzt.
## Git-Verbindung einrichten ## Git-Verbindung einrichten
1. Im Git-System einen **Personal Access Token** mit Leserechten erzeugen: 1. Im Git-System einen **Personal Access Token** erzeugen — mit den Rechten aus dem
Abschnitt [Welche Rechte der Token braucht](#welche-rechte-der-token-braucht).
| System | Scopes |
| --- | --- |
| GitHub | Fine-grained Token: *Contents: Read*, *Metadata: Read*, *Issues: Read*, *Pull requests: Read* |
| GitLab | `read_api` (alternativ `read_repository`) |
| Gitea / Forgejo | `repository:read`, `issue:read`, `user:read` |
2. In Nebula unter **Git-Verbindungen → Verbindung hinzufügen** eintragen: 2. In Nebula unter **Git-Verbindungen → Verbindung hinzufügen** eintragen:
System wählen, Basis-URL (z. B. `https://git.meine-firma.de`, ohne `/api`) und Token. System wählen, Basis-URL (z. B. `https://git.meine-firma.de`, ohne `/api`) und Token.
3. Mit **Testen** prüfen — Nebula meldet den erkannten Benutzer zurück. 3. Mit **Testen** prüfen — Nebula meldet den erkannten Benutzer zurück.
@@ -186,6 +186,132 @@ Nebula auffindbare Credentials nutzt.
Der Snapshot bleibt in der Datenbank, das Dashboard funktioniert also auch offline. Der Snapshot bleibt in der Datenbank, das Dashboard funktioniert also auch offline.
Ohne Token gehen nur öffentliche Repositories, mit strengem Rate-Limit. Ohne Token gehen nur öffentliche Repositories, mit strengem Rate-Limit.
### Welche Rechte der Token braucht
Nebula liest fast alles nur. Geschrieben wird an **genau zwei Stellen**: wenn du eine
Aufgabe als Issue anlegst, und wenn eine Aufgabe auf `DONE` wandert und Nebula das
verknüpfte Issue schließt. Alles andere sind `GET`-Aufrufe. Deshalb lohnt es, zwei Fälle
zu unterscheiden:
**Fall A — nur Abgleich.** Branches, Tags, Commits, Releases, CI-Zustand, README,
Heatmap, offene Issues und PRs anzeigen. Rein lesend.
**Fall B — zusätzlich die Issue-Anbindung.** Eine Aufgabe als Issue anlegen; wird das
Issue geschlossen, hakt Nebula die Aufgabe ab und schließt sie umgekehrt mit.
| System | Fall A (nur lesen) | Fall B zusätzlich |
| --- | --- | --- |
| **GitHub** | Fine-grained Token: *Metadata: Read*, *Contents: Read*, *Issues: Read*, *Pull requests: Read*, *Actions: Read* | *Issues: **Read and write*** |
| **GitLab** | `read_api` | `api` — **es gibt nichts dazwischen** |
| **Gitea / Forgejo** | `read:repository`, `read:issue`, `read:user` | `write:issue` |
#### Jeder Aufruf einzeln
Die folgenden Tabellen sind vollständig: mehr als das ruft Nebula nicht nach draußen.
Sie sind aus `src/lib/git/github.ts`, `gitlab.ts` und `gitea.ts` erhoben — wer prüfen
will, ob hier etwas fehlt, greppt dort nach `getJson`, `getJsonSafe` und `sendJson`.
**GitHub** — `https://api.github.com`, Header `Authorization: Bearer …`
| Aufruf | Wofür | Benötigtes Recht |
| --- | --- | --- |
| `GET /rate_limit` | Verbindungstest | keins |
| `GET /user` | Kontoname beim Testen zurückmelden | keins |
| `GET /user/repos` | Auswahlliste hinter **Repos laden** | Metadata: Read |
| `GET /repos/{o}/{r}` | Stammdaten, Default-Branch, Sichtbarkeit | Metadata: Read |
| `GET …/languages` | Sprachverteilung | Metadata: Read |
| `GET …/tags` | Tags | Metadata: Read |
| `GET …/stats/commit_activity` | Commit-Heatmap über ein Jahr | Metadata: Read |
| `GET …/branches` | Branch-Liste | Contents: Read |
| `GET …/commits` | die letzten 100 Commits | Contents: Read |
| `GET …/releases` | Releases | Contents: Read |
| `GET …/readme` | README-Vorschau | Contents: Read |
| `GET …/pulls?state=open` | offene Pull Requests | Pull requests: Read |
| `GET …/issues?state=open` | offene Issues | Issues: Read |
| `GET …/issues/{nr}` | Abgleich: ist das verknüpfte Issue noch offen? | Issues: Read |
| `GET …/actions/runs` | CI-Zustand des Default-Branch | Actions: Read |
| `POST …/issues` | **Aufgabe als Issue anlegen** | Issues: Read and write |
| `PATCH …/issues/{nr}` | **Issue schließen oder wieder öffnen** | Issues: Read and write |
Dass `/tags` und `/stats/commit_activity` unter *Metadata* fallen und nicht unter
*Contents*, ist nicht zu erraten — es steht so in
[GitHubs Berechtigungsübersicht](https://docs.github.com/en/rest/authentication/permissions-required-for-fine-grained-personal-access-tokens).
**GitLab** — `{Basis-URL}/api/v4`, Header `PRIVATE-TOKEN: …`
| Aufruf | Wofür | Benötigtes Recht |
| --- | --- | --- |
| `GET /projects?per_page=1` | Verbindungstest | `read_api` |
| `GET /user` | Kontoname beim Testen zurückmelden | `read_user` (in `read_api` enthalten) |
| `GET /projects?membership=true` | Auswahlliste hinter **Repos laden** | `read_api` |
| `GET /projects/{id}?statistics=true` | Stammdaten, Default-Branch, Sichtbarkeit | `read_api` |
| `GET …/repository/branches` · `/tags` · `/commits` | Snapshot und Heatmap | `read_api` |
| `GET …/repository/tree` · `/repository/files/{datei}` | README (zwei Aufrufe, GitLab hat keinen README-Endpunkt) | `read_api` |
| `GET …/releases` · `/languages` | Releases, Sprachverteilung | `read_api` |
| `GET …/merge_requests?state=opened` | offene Merge Requests | `read_api` |
| `GET …/issues?state=opened` | offene Issues | `read_api` |
| `GET …/issues/{nr}` | Abgleich: ist das verknüpfte Issue noch offen? | `read_api` |
| `GET …/pipelines` | CI-Zustand des Default-Branch | `read_api` |
| `POST …/issues` | **Aufgabe als Issue anlegen** | `api` |
| `PUT …/issues/{nr}` (`state_event`) | **Issue schließen oder wieder öffnen** | `api` |
**Gitea / Forgejo** — `{Basis-URL}/api/v1`, Header `Authorization: token …`
| Aufruf | Wofür | Benötigtes Recht |
| --- | --- | --- |
| `GET /version` | Verbindungstest | keins — der Aufruf darf fehlschlagen, Nebula wertet ihn nur ergänzend aus |
| `GET /user` | Kontoname beim Testen zurückmelden | `read:user` |
| `GET /user/repos` bzw. `GET /repos/search` | Auswahlliste hinter **Repos laden** | `read:repository` |
| `GET /repos/{o}/{r}` | Stammdaten, Default-Branch, Sichtbarkeit | `read:repository` |
| `GET …/branches` · `/tags` · `/commits` · `/releases` · `/languages` | Snapshot und Heatmap | `read:repository` |
| `GET …/contents` · `/contents/{datei}` | README (zwei Aufrufe, wie bei GitLab) | `read:repository` |
| `GET …/commits/{branch}/status` | CI-Zustand des Default-Branch | `read:repository` |
| `GET …/pulls?state=open` | offene Pull Requests | `read:repository` — Gitea zählt PRs zum Repository-Scope, nicht zu den Issues |
| `GET …/issues?state=open&type=issues` | offene Issues | `read:issue` |
| `GET …/issues/{nr}` | Abgleich: ist das verknüpfte Issue noch offen? | `read:issue` |
| `POST …/issues` | **Aufgabe als Issue anlegen** | `write:issue` |
| `PATCH …/issues/{nr}` | **Issue schließen oder wieder öffnen** | `write:issue` |
`write:issue` schließt `read:issue` mit ein — Gitea versteht `write:` als „lesen und
schreiben". Beide anzuhaken schadet nicht, nötig ist es nicht.
#### Was Nebula nicht tut
- **Keine Kommentare.** Nebula schreibt nie einen Issue-Kommentar, weder beim Anlegen
noch beim Schließen. Was im Rumpf des angelegten Issues steht, ist die
Aufgabenbeschreibung plus eine Zeile Rückverweis — danach fasst Nebula den Text
nicht mehr an.
- **Kein Code.** Nichts wird committet, gepusht, gemergt oder getaggt. Nebula ist eine
Übersicht, keine Entwicklungsumgebung.
- **Keine Webhooks anlegen.** Den Haken trägst du selbst im Git-System ein — genau
deshalb verlangt Nebula dafür keine Administrationsrechte.
- **Keine Labels, keine Meilensteine, keine Zuweisungen.**
- **Keine Adminrechte**, keine Organisations- oder Benutzerverwaltung.
Kommentare und Labels, die an einem Issue hängen, stammen also entweder aus der
Weboberfläche deines Git-Systems oder von einem anderen Werkzeug mit eigenem Token —
nicht von Nebula.
#### Der GitLab-Sonderfall
GitLab kennt keinen schmalen Schreib-Scope für Issues. Laut
[Access token scopes](https://docs.gitlab.com/security/tokens/access_token_scopes/)
gibt es nur `api` — „complete read and write access to the API" — und darunter direkt
`read_api`. Wer bei GitLab Aufgaben als Issue anlegen will, vergibt damit ein Token, das
auch Code ändern darf.
Wenn dir das zu weit geht: bleib bei `read_api` und verzichte auf die Issue-Anbindung.
Der Abgleich läuft vollständig, und geschlossene Issues werden weiterhin erkannt und
haken ihre Aufgabe ab — nur der Rückweg von Nebula ins Issue fällt weg. Ein eigenes
Dienstkonto, das nur Zugriff auf die betroffenen Projekte hat, ist der andere Ausweg.
#### Im Zweifel klein anfangen
Ein fehlendes Recht führt zu einem `403` mit dem Hinweis „Token hat nicht die noetigen
Scopes"; die Meldung steht am Repository auf der Projektseite. Fang mit Fall A an und
leg gezielt nach, wenn du die Issue-Anbindung wirklich benutzt — statt vorsorglich
alles zu vergeben.
### Automatisch statt auf Knopfdruck ### Automatisch statt auf Knopfdruck
Unter **Admin-Einstellungen → Automatischer Abgleich** lässt sich der Sync in den Unter **Admin-Einstellungen → Automatischer Abgleich** lässt sich der Sync in den
@@ -264,6 +390,7 @@ Container aus — auf dem Rechner selbst muss nichts installiert sein.
``` ```
nebula/ nebula/
├── docs/banner.png Titelbild dieser Datei
├── docker/entrypoint.sh Migrationen + Serverstart im Container ├── docker/entrypoint.sh Migrationen + Serverstart im Container
├── docker-compose.yml db (immer) · app (--profile full) · adminer (--profile tools) ├── docker-compose.yml db (immer) · app (--profile full) · adminer (--profile tools)
├── Dockerfile Multi-Stage-Build auf Basis des Next-Standalone-Outputs ├── Dockerfile Multi-Stage-Build auf Basis des Next-Standalone-Outputs
@@ -275,15 +402,25 @@ nebula/
└── src/ └── src/
├── app/ ├── app/
│ ├── page.tsx Dashboard (Board, Filter, Kennzahlen) │ ├── page.tsx Dashboard (Board, Filter, Kennzahlen)
│ ├── projects/[id]/ Projektdetail: Beschreibung, Notizen, Git │ ├── projects/[id]/ Projektdetail: Beschreibung, Notizen, Aufgaben, Git
│ ├── tasks/ Aufgaben über alle Projekte, nach Fälligkeit
│ ├── rueckblick/ Wochenrückblick
│ ├── verlauf/ Fortlaufende Zeitleiste
│ ├── connections/ Verwaltung der Git-Verbindungen │ ├── connections/ Verwaltung der Git-Verbindungen
│ └── api/ REST-Endpunkte (Projekte, Notizen, Verbindungen, Sync) │ ├── account/ admin/ Eigenes Konto · Adminbereich
│ └── api/ REST-Endpunkte (siehe Abschnitt „API")
├── components/ UI-Komponenten (Board, Karten, Panels, Dialoge) ├── components/ UI-Komponenten (Board, Karten, Panels, Dialoge)
├── lib/ ├── lib/
│ ├── auth/ Sitzungen, Passwörter, TOTP, WebAuthn, Zugriffsschutz
│ ├── crypto.ts AES-256-GCM für Access Tokens │ ├── crypto.ts AES-256-GCM für Access Tokens
│ ├── db.ts Prisma-Client │ ├── db.ts Prisma-Client
│ ├── git/ Provider-Adapter: github.ts · gitlab.ts · gitea.ts │ ├── git/ Provider-Adapter: github.ts · gitlab.ts · gitea.ts
│ ├── sync.ts Repo-Snapshot holen und speichern │ ├── sync/ Repo-Snapshot holen, speichern, Zeitgeber
│ ├── tasks/ Aufgabenlogik, Brücke zu Git-Issues
│ ├── webhooks/ Signaturprüfung eingehender Webhooks
│ ├── review.ts Wochengrenzen und Kennzahlen
│ ├── timeline.ts Cursor-Blättern für den Verlauf
│ ├── security/ SSRF-Schutz, Ratenbegrenzung, Body-Prüfung
│ └── validation.ts Zod-Schemas für alle Eingaben │ └── validation.ts Zod-Schemas für alle Eingaben
└── types/ gemeinsame Typen für Server und Client └── types/ gemeinsame Typen für Server und Client
``` ```
@@ -347,7 +484,8 @@ ausschließlich Daten des angemeldeten Kontos.
| `PATCH` `DELETE` | `/api/templates/:id` | Vorlage ändern / löschen | | `PATCH` `DELETE` | `/api/templates/:id` | Vorlage ändern / löschen |
| `POST` | `/api/templates/from-project` | Aus einem Projekt eine Vorlage machen | | `POST` | `/api/templates/from-project` | Aus einem Projekt eine Vorlage machen |
| `GET` | `/api/admin/backup` | Vollständige Sicherung als JSON (Admin) | | `GET` | `/api/admin/backup` | Vollständige Sicherung als JSON (Admin) |
| `GET` | `/api/search?q=` | Volltextsuche über die eigenen Notizen | | `GET` | `/api/search?q=` | Volltextsuche über eigene Notizen **und Aufgaben** |
| `GET` | `/api/activity` | Verlauf über alle Projekte, per Cursor (`?vor=&id=&limit=`) |
| `GET` `POST` | `/api/notifications` | Benachrichtigungsziele | | `GET` `POST` | `/api/notifications` | Benachrichtigungsziele |
| `PATCH` `DELETE` | `/api/notifications/:id` | Ziel ändern / entfernen | | `PATCH` `DELETE` | `/api/notifications/:id` | Ziel ändern / entfernen |
| `POST` | `/api/notifications/:id/test` | Testnachricht senden | | `POST` | `/api/notifications/:id/test` | Testnachricht senden |
@@ -359,10 +497,13 @@ ausschließlich Daten des angemeldeten Kontos.
| `GET` `POST` | `/api/projects/:id/repositories` | Repositories des Projekts | | `GET` `POST` | `/api/projects/:id/repositories` | Repositories des Projekts |
| `PATCH` `DELETE` | `/api/repositories/:id` | Repository ändern / vom Projekt lösen | | `PATCH` `DELETE` | `/api/repositories/:id` | Repository ändern / vom Projekt lösen |
| `POST` | `/api/repositories/:id/sync` | Ein einzelnes Repository abgleichen | | `POST` | `/api/repositories/:id/sync` | Ein einzelnes Repository abgleichen |
| `POST` `DELETE` | `/api/repositories/:id/webhook` | Webhook einrichten / zurücknehmen |
| `POST` | `/api/webhooks/:repositoryId` | **Eingehend** von GitHub/GitLab/Gitea, per Signatur geprüft |
| `GET` `POST` | `/api/projects/:id/tasks` | Aufgaben des Projekts | | `GET` `POST` | `/api/projects/:id/tasks` | Aufgaben des Projekts |
| `PATCH` | `/api/projects/:id/tasks/reorder` | Neue Reihenfolge nach dem Ziehen | | `PATCH` | `/api/projects/:id/tasks/reorder` | Neue Reihenfolge nach dem Ziehen |
| `POST` | `/api/projects/:id/tasks/from-issues` | Offene Issues als Aufgaben übernehmen | | `POST` | `/api/projects/:id/tasks/from-issues` | Offene Issues als Aufgaben übernehmen |
| `PATCH` `DELETE` | `/api/tasks/:id` | Aufgabe ändern / löschen | | `PATCH` `DELETE` | `/api/tasks/:id` | Aufgabe ändern / löschen |
| `POST` | `/api/tasks/:id/issue` | Aufgabe als Issue im verknüpften Repository anlegen |
| `GET` `POST` | `/api/projects/:id/notes` | Notizen des Projekts | | `GET` `POST` | `/api/projects/:id/notes` | Notizen des Projekts |
| `PATCH` `DELETE` | `/api/notes/:id` | Notiz ändern / löschen | | `PATCH` `DELETE` | `/api/notes/:id` | Notiz ändern / löschen |
| `GET` `POST` | `/api/connections` | Git-Verbindungen | | `GET` `POST` | `/api/connections` | Git-Verbindungen |
@@ -434,17 +575,21 @@ Grenzen, die du kennen solltest:
## Getestet mit ## Getestet mit
Stand 25.07.2026 gegen echte Systeme verifiziert — beide Betriebsarten, alle drei Am 25.07.2026 gegen echte Systeme verifiziert — beide Betriebsarten, alle drei
Git-Adapter (GitHub, GitLab und Gitea jeweils gegen die öffentliche Instanz). Git-Adapter (GitHub, GitLab und Gitea jeweils gegen die öffentliche Instanz). Seitdem
laufen die Ende-zu-Ende-Tests bei jeder Änderung mit; live nach draußen geht davon nur
ein Test gegen die GitHub-API.
Werkzeugstand vom 18.08.2026:
| | Version | | | Version |
| --- | --- | | --- | --- |
| Node.js | 26.5.0 | | Node.js | 24.19.0 |
| npm | 11.17.0 | | npm | 11.17.0 |
| Next.js | 15.5.21 | | Next.js | 15.5.21 |
| Prisma | 6.19.3 | | Prisma | 6.19.3 |
| PostgreSQL | 17 (Alpine) | | PostgreSQL | 17.11 (Alpine) |
| Docker / Compose | 29.6.2 / v5.3.1 | | Docker | 29.7.2 |
--- ---
+76 -34
View File
@@ -7,25 +7,21 @@ Stück für Stück abarbeiten. Reihenfolge ist ein Vorschlag, keine Festlegung.
Was bereits steht, listet [FEATURES.md](FEATURES.md). Was bereits steht, listet [FEATURES.md](FEATURES.md).
**Stand:** v1.11.0. Alle Issues aus dem Dashboard sind abgearbeitet.
--- ---
## 1 · Als Nächstes empfohlen ## 1 · Als Nächstes empfohlen
### 1.0 Rest aus v1.6.0: persönliche API-Token `M` ### 1.1 Persönliche API-Token `M`
Schnellerfassung und die projektübergreifende Aufgabenseite sind mit v1.6.0 Der letzte offene Teil aus v1.6.0 („unterwegs erfassen"). Derzeit geht die
ausgeliefert. Offen bleibt der dritte Teil — siehe 1.3. Er gehört sachlich API nur mit Sitzungscookie. Ein Token je Konto würde `curl`-Aufrufe und
dazu: erfassen am Telefon, überblicken an einer Stelle, ansprechen per Handy-Shortcuts erlauben — eine Idee aus dem Terminal anlegen, ohne den
Shortcut. Browser zu öffnen. Nur der Hash in der Datenbank, wie bei den Sitzungen.
Bewusst nicht mitgeliefert wurde ein **Index für die übergreifende Bewusst noch nicht angefasst, weil sicherheitsrelevant: Geltungsbereich,
Aufgabenabfrage**. `Task` hat kein `ownerId`, die Trennung läuft über Widerruf und Ablauf wollen entschieden sein, nicht nebenbei gebaut.
`project: { ownerId }`, und `[projectId, status, position]` greift dafür
nicht. Bei einigen hundert Aufgaben fällt das nicht auf; wird die Seite
träge, ist ein Index auf `dueDate` der erste Griff — und dann eine
Migration wert.
---
### 1.2 Läuft es noch? `M` ### 1.2 Läuft es noch? `M`
@@ -34,24 +30,17 @@ Nebula wüsste dann, ob ein Projekt nicht nur existiert, sondern auch läuft.
Nutzt Zeitgeber und Benachrichtigungen, die es schon gibt. Der SSRF-Schutz Nutzt Zeitgeber und Benachrichtigungen, die es schon gibt. Der SSRF-Schutz
greift dabei wie bei den Git-Aufrufen. greift dabei wie bei den Git-Aufrufen.
### 1.3 Persönliche API-Token `M` ### 1.3 Aufgaben ↔ Issues: der Rest `S`
Derzeit geht die API nur mit Sitzungscookie. Ein Token je Konto würde Beide Richtungen stehen — „als Issue anlegen" seit v1.7.0, der Rückweg über
`curl`-Aufrufe und Handy-Shortcuts erlauben — Idee aus dem Terminal anlegen, Webhook (v1.7.0) und zusätzlich über den Abgleich (v1.9.0). Offen ist
ohne den Browser zu öffnen. Nur der Hash in der Datenbank, wie bei den Kleinkram:
Sitzungen.
---
### 1.4 Aufgaben ↔ Issues: was noch fehlt `S`
Mit v1.7.0 stehen beide Richtungen: „als Issue anlegen“ und der Rückweg über
Webhooks. Offen geblieben ist Kleinkram:
- **Mehrere Repositories.** Der Knopf nimmt immer das führende. Bei einem - **Mehrere Repositories.** Der Knopf nimmt immer das führende. Bei einem
Projekt aus Frontend und Backend fehlt die Wahl. Projekt aus Frontend und Backend fehlt die Wahl.
- **Beim Übernehmen aus Issues** wird der Rückweg nicht automatisch - **Webhook beim Verknüpfen anbieten.** Er ist getrennt einzurichten, und
eingerichtet — der Webhook ist getrennt zu setzen. wer das nicht tut, merkt nur an der Verzögerung, dass etwas fehlt. Ein
Hinweis an der richtigen Stelle würde reichen.
Was **nicht** kommt, bleibt wie besprochen: eine echte beidseitige Was **nicht** kommt, bleibt wie besprochen: eine echte beidseitige
Synchronisation mit Konfliktauflösung. Die Zustände passen ohnehin nicht Synchronisation mit Konfliktauflösung. Die Zustände passen ohnehin nicht
@@ -75,15 +64,60 @@ Voraussetzung, falls du das Projekt veröffentlichen willst.
--- ---
## 3 · Kleinigkeiten ## 3 · Aufgefallen beim Bauen
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`
Zwischen 320 und 375 px wird aus „Nebula" ein „Neb…". Gemessen über fünf
Breiten und zwei Seiten: betrifft alle Seiten gleichermaßen. Seit die
Aufgaben als dritter Punkt danebenstehen, ist es enger geworden.
### 3.2 Indizes für die projektübergreifenden Seiten `S`
Weder `Task` noch `Activity` haben einen Besitzer — die Trennung läuft über
`project: { ownerId }`. Die vorhandenen Indizes
(`[projectId, status, position]` bzw. `[projectId, createdAt]`) greifen für
Abfragen über alle Projekte deshalb nicht.
Betroffen sind inzwischen vier Seiten: Aufgabenübersicht, Wochenrückblick,
Zeitleiste und die Volltextsuche. Bei einigen hundert Datensätzen fällt das
nicht auf. Wird eine der Seiten träge, sind Indizes auf `Task.dueDate` und
`Activity.createdAt` der erste Griff — und dann eine Migration wert.
### 3.3 Projektfilter auf der Zeitleiste `S`
Über alle Projekte wird die Liste schnell lang. Der Wochenrückblick hat
Wochengrenzen als natürliche Bremse, die Zeitleiste nicht. Bewusst
weggelassen, weil das Issue nur „derselbe Verlauf, projektübergreifend"
verlangte.
### 3.4 Abgleich gegen geleakte Passwörter `M`
ASVS 2.1.7 verlangt es; umgesetzt ist bisher nur eine lokale Sperrliste plus
Mustererkennung (Wiederholungen, Zeichenfolgen, Benutzername im Passwort).
Have-I-Been-Pwned scheidet aus — Nebula ruft grundsätzlich nichts von außen,
und ein Passwort-Präfix nach draußen zu geben widerspräche dem erst recht.
Eine lokale Top-10000-Liste als Datei wäre der Kompromiss.
### 3.5 v0.2.0 und v0.3.0 ohne Release `S`
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 20 Fassungen haben seit dem 18.08.2026 ein Release in Gitea.
---
## 4 · Kleinigkeiten
| Idee | Aufwand | Nutzen | | Idee | Aufwand | Nutzen |
| --- | --- | --- | | --- | --- | --- |
| Wochenrückblick | `S` | „Was ist diese Woche passiert" über alle Projekte, aus dem Aktivitätsverlauf |
| Globale Zeitleiste | `S` | Derselbe Verlauf, aber projektübergreifend als eigene Seite |
| Suche über Aufgaben | `S` | Die Volltextsuche deckt bisher nur Notizen ab |
| Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste | | Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste |
| Aufgabe ↔ Commit verknüpfen | `M` | Enthält ein Commit `#5`, wird die übernommene Aufgabe verlinkt — fällt weitgehend mit 1.4 ab | | Aufgabe ↔ Commit verknüpfen | `M` | Enthält ein Commit `#5`, wird die übernommene Aufgabe verlinkt — fällt weitgehend mit 1.3 ab |
| Tastaturkürzel-Übersicht mit `?` | `S` | Es gibt inzwischen genug davon | | Tastaturkürzel-Übersicht mit `?` | `S` | Es gibt inzwischen genug davon |
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel | | Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` | | Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
@@ -91,7 +125,7 @@ Voraussetzung, falls du das Projekt veröffentlichen willst.
--- ---
## 4 · Bewusst nicht ## 5 · Bewusst nicht
Damit klar ist, was **nicht** kommt und warum: Damit klar ist, was **nicht** kommt und warum:
@@ -104,7 +138,10 @@ Damit klar ist, was **nicht** kommt und warum:
- **Anhänge an Notizen.** Bräuchte Dateispeicher und damit eine zweite Sorge - **Anhänge an Notizen.** Bräuchte Dateispeicher und damit eine zweite Sorge
bei jeder Sicherung. Ein Link ins Git tut es auch. bei jeder Sicherung. Ein Link ins Git tut es auch.
- **Eigene Git-Operationen** (klonen, committen, pushen). Nebula ist eine - **Eigene Git-Operationen** (klonen, committen, pushen). Nebula ist eine
Übersicht, keine Entwicklungsumgebung. Übersicht, keine Entwicklungsumgebung. Issues über die HTTP-API der
Provider sind davon nicht berührt — die gibt es seit v1.7.0.
- **Beidseitige Synchronisation von Aufgaben und Issues** mit
Konfliktauflösung — siehe 1.3.
- **Zeiterfassung, Rechnungen, Kundenverwaltung.** Andere Baustelle. - **Zeiterfassung, Rechnungen, Kundenverwaltung.** Andere Baustelle.
- **Cloud-Synchronisation.** Die Daten bleiben in deiner Datenbank; das ist - **Cloud-Synchronisation.** Die Daten bleiben in deiner Datenbank; das ist
der Punkt. der Punkt.
@@ -138,6 +175,11 @@ Damit klar ist, was **nicht** kommt und warum:
| **v1.5.0** Sicherheitsdurchsicht, Server-Anleitung und Update-Skript | ✓ | | **v1.5.0** Sicherheitsdurchsicht, Server-Anleitung und Update-Skript | ✓ |
| **v1.6.0** Mobile Ansicht, Listenansicht, Schnellerfassung, Aufgaben über alle Projekte | ✓ | | **v1.6.0** Mobile Ansicht, Listenansicht, Schnellerfassung, Aufgaben über alle Projekte | ✓ |
| **v1.7.0** Aufgabe als Git-Issue anlegen, Webhooks für Push und Issues | ✓ | | **v1.7.0** Aufgabe als Git-Issue anlegen, Webhooks für Push und Issues | ✓ |
| **v1.8.0** Anmeldung und Sitzungen nach OWASP: scrypt-Aufwand, Aufzählbarkeit, Leerlauffrist, Cookie-Härtung | ✓ |
| **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 Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "nebula", "name": "nebula",
"version": "1.2.0", "version": "1.11.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nebula", "name": "nebula",
"version": "1.2.0", "version": "1.11.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nebula", "name": "nebula",
"version": "1.10.0", "version": "1.11.0",
"private": true, "private": true,
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)", "description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
"scripts": { "scripts": {
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "AppSettings" ADD COLUMN "taskColumnLimit" INTEGER NOT NULL DEFAULT 15;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "RepoSnapshot" ADD COLUMN "completeAt" TIMESTAMP(3),
ADD COLUMN "missing" JSONB NOT NULL DEFAULT '{}';
+18
View File
@@ -266,6 +266,11 @@ model AppSettings {
/// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms } /// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms }
lastAutoSyncReport Json? lastAutoSyncReport Json?
// ── Aufgabenbrett ──────────────────────────────────────────
/// Hoechstzahl Karten je Spalte. Was darueber hinausgeht, kommt hinter
/// einen Knopf – verborgen wird nichts. 0 schaltet die Begrenzung ab.
taskColumnLimit Int @default(15)
// ── Stillstand ───────────────────────────────────────────── // ── Stillstand ─────────────────────────────────────────────
/// Ab wie vielen Tagen ohne Commit ein Projekt als stillstehend gilt. /// Ab wie vielen Tagen ohne Commit ein Projekt als stillstehend gilt.
staleAfterDays Int @default(60) staleAfterDays Int @default(60)
@@ -644,6 +649,19 @@ model RepoSnapshot {
ciUrl String? ciUrl String?
ciFinishedAt DateTime? ciFinishedAt DateTime?
/// Teil-Abrufe, die beim letzten Abgleich nicht geklappt haben:
/// { "branches": "HTTP 403: Zugriff verweigert …" }. Leeres Objekt heisst,
/// dass alle Listen wirklich geholt wurden.
///
/// Ohne diese Angabe ist eine leere Liste zweideutig – "nicht geholt" sieht
/// aus wie "nichts vorhanden". Genau daran ist eine Fehlersuche einmal
/// haengen geblieben.
missing Json @default("{}")
pushedAt DateTime? pushedAt DateTime?
fetchedAt DateTime @default(now()) fetchedAt DateTime @default(now())
/// Wann zuletzt ein Abgleich lief, bei dem alle Teile ankamen. Steht der
/// weiter zurueck als fetchedAt, ist ein Teil der Anzeige aelter als sie
/// vorgibt.
completeAt DateTime?
} }
+17 -1
View File
@@ -19,7 +19,7 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function AdminPage() { export default async function AdminPage() {
const admin = await requireAdminPage(); const admin = await requireAdminPage();
const [settings, users, eligible, staleListeners] = await Promise.all([ const [settings, users, eligible, staleListeners, laengsteSpalte] = await Promise.all([
getAppSettings(), getAppSettings(),
prisma.user.findMany({ prisma.user.findMany({
select: userSafeSelect, select: userSafeSelect,
@@ -41,6 +41,18 @@ export default async function AdminPage() {
prisma.notificationTarget.count({ prisma.notificationTarget.count({
where: { isActive: true, events: { has: "PROJECT_STALE" } }, where: { isActive: true, events: { has: "PROJECT_STALE" } },
}), }),
// Laengste Spalte ueber alle Projekte. Ohne diese Zahl steht die
// Einstellung im Leeren: eine Grenze von 15 sagt nichts, wenn die
// groesste Spalte im Bestand aus drei Karten besteht.
//
// Wie beim Abgleich bewusst ueber alle Konten – gezaehlt wird nur, es
// wird nichts gelesen und nichts zugeordnet.
prisma.task.groupBy({
by: ["projectId", "status"],
_count: { _all: true },
orderBy: { _count: { projectId: "desc" } },
take: 1,
}),
]); ]);
const data: AdminData = { const data: AdminData = {
@@ -64,6 +76,10 @@ export default async function AdminPage() {
lastCheckAt: settings.lastStaleCheckAt, lastCheckAt: settings.lastStaleCheckAt,
listeners: staleListeners, listeners: staleListeners,
}, },
tasks: {
columnLimit: settings.taskColumnLimit,
laengsteSpalte: laengsteSpalte[0]?._count._all ?? 0,
},
}; };
return ( return (
+2
View File
@@ -38,6 +38,7 @@ export async function PATCH(request: Request) {
autoSyncActiveOnly, autoSyncActiveOnly,
autoSyncBatchSize, autoSyncBatchSize,
staleAfterDays, staleAfterDays,
taskColumnLimit,
} = parsed.data; } = parsed.data;
// Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren – // Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren –
@@ -63,6 +64,7 @@ export async function PATCH(request: Request) {
...(staleAfterDays !== undefined ...(staleAfterDays !== undefined
? { staleAfterDays, lastStaleCheckAt: null } ? { staleAfterDays, lastStaleCheckAt: null }
: {}), : {}),
...(taskColumnLimit !== undefined ? { taskColumnLimit } : {}),
}; };
const settings = await prisma.appSettings.upsert({ const settings = await prisma.appSettings.upsert({
+4 -1
View File
@@ -10,6 +10,7 @@ import { ProjectHeader } from "@/components/ProjectHeader";
import { TaskBoard } from "@/components/tasks/TaskBoard"; import { TaskBoard } from "@/components/tasks/TaskBoard";
import { requireUserPage } from "@/lib/auth/guard"; import { requireUserPage } from "@/lib/auth/guard";
import { getSessionUser } from "@/lib/auth/session"; import { getSessionUser } from "@/lib/auth/session";
import { getAppSettings } from "@/lib/auth/settings";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { connectionSafeSelect, projectDetailInclude, webhookRepositoryIds } from "@/lib/projects"; import { connectionSafeSelect, projectDetailInclude, webhookRepositoryIds } from "@/lib/projects";
import type { ConnectionSafe, ProjectDetail } from "@/types"; import type { ConnectionSafe, ProjectDetail } from "@/types";
@@ -37,7 +38,7 @@ export default async function ProjectPage({ params }: PageProps) {
const user = await requireUserPage(); const user = await requireUserPage();
const { id } = await params; const { id } = await params;
const [project, connections] = await Promise.all([ const [project, connections, settings] = await Promise.all([
// findFirst mit Besitzerbedingung: ein fremdes Projekt fuehrt zu 404, // findFirst mit Besitzerbedingung: ein fremdes Projekt fuehrt zu 404,
// nicht zu einer Fehlermeldung, die seine Existenz bestaetigt. // nicht zu einer Fehlermeldung, die seine Existenz bestaetigt.
prisma.project.findFirst({ prisma.project.findFirst({
@@ -49,6 +50,7 @@ export default async function ProjectPage({ params }: PageProps) {
select: connectionSafeSelect, select: connectionSafeSelect,
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}), }),
getAppSettings(),
]); ]);
if (!project) notFound(); if (!project) notFound();
@@ -83,6 +85,7 @@ export default async function ProjectPage({ params }: PageProps) {
projectId={detail.id} projectId={detail.id}
tasks={detail.tasks} tasks={detail.tasks}
progressFromTasks={detail.progressFromTasks} progressFromTasks={detail.progressFromTasks}
columnLimit={settings.taskColumnLimit}
/> />
{/* {/*
+208 -50
View File
@@ -30,13 +30,14 @@ import { RepositoryDialog } from "@/components/RepositoryDialog";
import { WebhookDialog } from "@/components/WebhookDialog"; import { WebhookDialog } from "@/components/WebhookDialog";
import { Sparkline } from "@/components/Sparkline"; import { Sparkline } from "@/components/Sparkline";
import { repoTitle } from "@/lib/git/meta"; import { repoTitle } from "@/lib/git/meta";
import { asArray, asCalendar, asRecord } from "@/types"; import { asArray, asCalendar, asRecord, asTextRecord } from "@/types";
import type { import type {
BranchInfo, BranchInfo,
CommitInfo, CommitInfo,
ConnectionSafe, ConnectionSafe,
ProjectDetail, ProjectDetail,
ReleaseInfo, ReleaseInfo,
RepoSnapshot,
RepositoryWithSnapshot, RepositoryWithSnapshot,
TagInfo, TagInfo,
TicketInfo, TicketInfo,
@@ -69,17 +70,26 @@ export function GitPanel({
const [tab, setTab] = useState<TabKey>("pulls"); const [tab, setTab] = useState<TabKey>("pulls");
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(
null,
);
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const [webhookOpen, setWebhookOpen] = useState(false); const [webhookOpen, setWebhookOpen] = useState(false);
const repositories = project.repositories; const repositories = project.repositories;
const [selectedId, setSelectedId] = useState<string | null>(repositories[0]?.id ?? null); const [selectedId, setSelectedId] = useState<string | null>(
repositories[0]?.id ?? null,
);
// Nach dem Entfernen zeigt die Auswahl ins Leere – dann auf das fuehrende // Nach dem Entfernen zeigt die Auswahl ins Leere – dann auf das fuehrende
// zurueckfallen, statt eine leere Ansicht zu zeigen. // zurueckfallen, statt eine leere Ansicht zu zeigen.
const repository = repositories.find((entry) => entry.id === selectedId) ?? repositories[0] ?? null; const repository =
const webhookAktiv = repository ? webhookRepoIds.includes(repository.id) : false; repositories.find((entry) => entry.id === selectedId) ??
repositories[0] ??
null;
const webhookAktiv = repository
? webhookRepoIds.includes(repository.id)
: false;
/** Offene Issues als Aufgaben anlegen – ohne erneuten Abruf beim Provider. */ /** Offene Issues als Aufgaben anlegen – ohne erneuten Abruf beim Provider. */
async function importIssues() { async function importIssues() {
@@ -87,13 +97,17 @@ export function GitPanel({
setImporting(true); setImporting(true);
setMessage(null); setMessage(null);
try { try {
const response = await fetch(`/api/projects/${project.id}/tasks/from-issues`, { const response = await fetch(
method: "POST", `/api/projects/${project.id}/tasks/from-issues`,
headers: { "Content-Type": "application/json" }, {
body: JSON.stringify({ repositoryId: repository.id }), method: "POST",
}); headers: { "Content-Type": "application/json" },
body: JSON.stringify({ repositoryId: repository.id }),
},
);
const body = await response.json().catch(() => ({})); const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error ?? "Uebernahme fehlgeschlagen."); if (!response.ok)
throw new Error(body.error ?? "Uebernahme fehlgeschlagen.");
setMessage({ setMessage({
ok: true, ok: true,
text: text:
@@ -103,7 +117,10 @@ export function GitPanel({
}); });
router.refresh(); router.refresh();
} catch (caught) { } catch (caught) {
setMessage({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); setMessage({
ok: false,
text: caught instanceof Error ? caught.message : String(caught),
});
} finally { } finally {
setImporting(false); setImporting(false);
} }
@@ -130,7 +147,10 @@ export function GitPanel({
: `/api/projects/${project.id}/sync`; : `/api/projects/${project.id}/sync`;
const response = await fetch(url, { method: "POST" }); const response = await fetch(url, { method: "POST" });
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
setMessage({ ok: response.ok, text: payload.message ?? "Unbekannte Antwort." }); setMessage({
ok: response.ok,
text: payload.message ?? "Unbekannte Antwort.",
});
router.refresh(); router.refresh();
} catch (caught) { } catch (caught) {
setMessage({ setMessage({
@@ -150,7 +170,9 @@ export function GitPanel({
) { ) {
return; return;
} }
await fetch(`/api/repositories/${entry.id}`, { method: "DELETE" }).catch(() => undefined); await fetch(`/api/repositories/${entry.id}`, { method: "DELETE" }).catch(
() => undefined,
);
setSelectedId(null); setSelectedId(null);
router.refresh(); router.refresh();
} }
@@ -160,12 +182,19 @@ export function GitPanel({
<> <>
<div className="glass px-5 py-10 text-center"> <div className="glass px-5 py-10 text-center">
<GitBranch className="mx-auto h-8 w-8 text-slate-600" /> <GitBranch className="mx-auto h-8 w-8 text-slate-600" />
<h3 className="mt-3 text-sm font-semibold text-slate-200">Kein Repository verknuepft</h3> <h3 className="mt-3 text-sm font-semibold text-slate-200">
Kein Repository verknuepft
</h3>
<p className="mx-auto mt-1 max-w-sm text-sm text-slate-500"> <p className="mx-auto mt-1 max-w-sm text-sm text-slate-500">
Verknuepfe ein Repository, um Branches, Tags, Commits und Releases hier zu sehen. Verknuepfe ein Repository, um Branches, Tags, Commits und Releases
Ein Projekt darf auch mehrere haben – etwa Frontend und Backend. hier zu sehen. Ein Projekt darf auch mehrere haben – etwa Frontend
und Backend.
</p> </p>
<button type="button" className="btn btn-primary mt-4" onClick={() => setAddOpen(true)}> <button
type="button"
className="btn btn-primary mt-4"
onClick={() => setAddOpen(true)}
>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Repository verknuepfen Repository verknuepfen
</button> </button>
@@ -180,7 +209,10 @@ export function GitPanel({
); );
} }
const languageTotal = Object.values(languages).reduce((sum, value) => sum + value, 0); const languageTotal = Object.values(languages).reduce(
(sum, value) => sum + value,
0,
);
const topLanguages = Object.entries(languages) const topLanguages = Object.entries(languages)
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.slice(0, 5); .slice(0, 5);
@@ -190,7 +222,10 @@ export function GitPanel({
{/* Auswahl, sobald es mehr als eines gibt. Bei einem einzigen waere eine {/* Auswahl, sobald es mehr als eines gibt. Bei einem einzigen waere eine
Leiste mit genau einem Knopf nur Ballast. */} Leiste mit genau einem Knopf nur Ballast. */}
{repositories.length > 1 ? ( {repositories.length > 1 ? (
<div className="glass flex flex-wrap items-center gap-1.5 p-2" role="tablist"> <div
className="glass flex flex-wrap items-center gap-1.5 p-2"
role="tablist"
>
{repositories.map((entry) => { {repositories.map((entry) => {
const aktiv = entry.id === repository.id; const aktiv = entry.id === repository.id;
return ( return (
@@ -256,7 +291,11 @@ export function GitPanel({
<ExternalLink className="h-3.5 w-3.5" /> <ExternalLink className="h-3.5 w-3.5" />
</a> </a>
) : null} ) : null}
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} url={snapshot?.ciUrl} /> <CiBadge
state={snapshot?.ciState}
name={snapshot?.ciName}
url={snapshot?.ciUrl}
/>
</div> </div>
<p className="mt-1 text-xs text-slate-500" suppressHydrationWarning> <p className="mt-1 text-xs text-slate-500" suppressHydrationWarning>
{repository.lastSyncedAt {repository.lastSyncedAt
@@ -368,12 +407,22 @@ export function GitPanel({
</p> </p>
) : null} ) : null}
<UnvollstaendigerStand snapshot={snapshot} />
{snapshot ? ( {snapshot ? (
<> <>
{/* Kennzahlen */} {/* Kennzahlen */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<Metric icon={Star} label="Stars" value={formatNumber(snapshot.stars)} /> <Metric
<Metric icon={GitMerge} label="Forks" value={formatNumber(snapshot.forks)} /> icon={Star}
label="Stars"
value={formatNumber(snapshot.stars)}
/>
<Metric
icon={GitMerge}
label="Forks"
value={formatNumber(snapshot.forks)}
/>
<Metric <Metric
icon={GitPullRequest} icon={GitPullRequest}
label="Offene PRs/MRs" label="Offene PRs/MRs"
@@ -384,11 +433,19 @@ export function GitPanel({
label="Offene Issues" label="Offene Issues"
value={formatNumber(snapshot.openIssues)} value={formatNumber(snapshot.openIssues)}
/> />
<Metric icon={GitBranch} label="Branches" value={formatNumber(snapshot.branchCount)} /> <Metric
icon={GitBranch}
label="Branches"
value={formatNumber(snapshot.branchCount)}
/>
<Metric <Metric
icon={HardDrive} icon={HardDrive}
label="Groesse" label="Groesse"
value={snapshot.sizeKb > 1024 ? `${Math.round(snapshot.sizeKb / 1024)} MB` : `${snapshot.sizeKb} KB`} value={
snapshot.sizeKb > 1024
? `${Math.round(snapshot.sizeKb / 1024)} MB`
: `${snapshot.sizeKb} KB`
}
/> />
</div> </div>
@@ -408,11 +465,15 @@ export function GitPanel({
{snapshot.lastCommitSha.slice(0, 7)} {snapshot.lastCommitSha.slice(0, 7)}
</span> </span>
<span>{snapshot.lastCommitAuthor ?? "unbekannt"}</span> <span>{snapshot.lastCommitAuthor ?? "unbekannt"}</span>
<span suppressHydrationWarning>{formatRelative(snapshot.lastCommitAt)}</span> <span suppressHydrationWarning>
{formatRelative(snapshot.lastCommitAt)}
</span>
</p> </p>
</> </>
) : ( ) : (
<p className="mt-2 text-sm text-slate-500">Keine Commit-Daten.</p> <p className="mt-2 text-sm text-slate-500">
Keine Commit-Daten.
</p>
)} )}
</div> </div>
@@ -424,8 +485,11 @@ export function GitPanel({
<span className="font-mono text-2xl text-white"> <span className="font-mono text-2xl text-white">
{snapshot.latestTag ?? snapshot.latestRelease ?? "–"} {snapshot.latestTag ?? snapshot.latestRelease ?? "–"}
</span> </span>
{snapshot.latestRelease && snapshot.latestRelease !== snapshot.latestTag ? ( {snapshot.latestRelease &&
<span className="text-sm text-slate-400">{snapshot.latestRelease}</span> snapshot.latestRelease !== snapshot.latestTag ? (
<span className="text-sm text-slate-400">
{snapshot.latestRelease}
</span>
) : null} ) : null}
</div> </div>
<p className="mt-1 text-xs text-slate-500"> <p className="mt-1 text-xs text-slate-500">
@@ -470,7 +534,8 @@ export function GitPanel({
title={`${name} – ${Math.round((bytes / languageTotal) * 100)}%`} title={`${name} – ${Math.round((bytes / languageTotal) * 100)}%`}
style={{ style={{
width: `${(bytes / languageTotal) * 100}%`, width: `${(bytes / languageTotal) * 100}%`,
backgroundColor: LANGUAGE_COLORS[index % LANGUAGE_COLORS.length], backgroundColor:
LANGUAGE_COLORS[index % LANGUAGE_COLORS.length],
}} }}
/> />
))} ))}
@@ -480,7 +545,10 @@ export function GitPanel({
<span key={name} className="inline-flex items-center gap-1.5"> <span key={name} className="inline-flex items-center gap-1.5">
<span <span
className="h-2 w-2 rounded-full" className="h-2 w-2 rounded-full"
style={{ backgroundColor: LANGUAGE_COLORS[index % LANGUAGE_COLORS.length] }} style={{
backgroundColor:
LANGUAGE_COLORS[index % LANGUAGE_COLORS.length],
}}
/> />
{name} {name}
<span className="text-slate-600"> <span className="text-slate-600">
@@ -537,8 +605,8 @@ export function GitPanel({
{issues.length > 0 ? ( {issues.length > 0 ? (
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-white/6 bg-white/2 px-4 py-2"> <div className="flex flex-wrap items-center justify-between gap-2 border-b border-white/6 bg-white/2 px-4 py-2">
<p className="text-[11px] text-slate-500"> <p className="text-[11px] text-slate-500">
Aus offenen Issues Aufgaben machen. Bereits uebernommene werden Aus offenen Issues Aufgaben machen. Bereits uebernommene
uebersprungen. werden uebersprungen.
</p> </p>
<button <button
type="button" type="button"
@@ -555,7 +623,11 @@ export function GitPanel({
</button> </button>
</div> </div>
) : null} ) : null}
<TicketList tickets={issues} empty="Keine offenen Issues." icon={CircleDot} /> <TicketList
tickets={issues}
empty="Keine offenen Issues."
icon={CircleDot}
/>
</> </>
) : null} ) : null}
{tab === "branches" ? <BranchList branches={branches} /> : null} {tab === "branches" ? <BranchList branches={branches} /> : null}
@@ -567,7 +639,8 @@ export function GitPanel({
</> </>
) : ( ) : (
<div className="glass px-5 py-10 text-center text-sm text-slate-500"> <div className="glass px-5 py-10 text-center text-sm text-slate-500">
Noch keine Daten geladen. Mit „Jetzt synchronisieren“ den ersten Snapshot holen. Noch keine Daten geladen. Mit „Jetzt synchronisieren“ den ersten
Snapshot holen.
</div> </div>
)} )}
</div> </div>
@@ -591,7 +664,9 @@ function Metric({
<Icon className="h-3 w-3" /> <Icon className="h-3 w-3" />
{label} {label}
</p> </p>
<p className="mt-1 text-lg font-semibold tabular-nums text-slate-100">{value}</p> <p className="mt-1 text-lg font-semibold tabular-nums text-slate-100">
{value}
</p>
</div> </div>
); );
} }
@@ -639,10 +714,15 @@ function TicketList({
) : null} ) : null}
</p> </p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-slate-500"> <p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-slate-500">
<span className="font-mono text-slate-600">#{ticket.number}</span> <span className="font-mono text-slate-600">
#{ticket.number}
</span>
<span>{ticket.author ?? "unbekannt"}</span> <span>{ticket.author ?? "unbekannt"}</span>
{ticket.labels.slice(0, 3).map((label) => ( {ticket.labels.slice(0, 3).map((label) => (
<span key={label} className="rounded bg-white/6 px-1 py-0.5 text-[10px]"> <span
key={label}
className="rounded bg-white/6 px-1 py-0.5 text-[10px]"
>
{label} {label}
</span> </span>
))} ))}
@@ -650,7 +730,9 @@ function TicketList({
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500"> <div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
<span suppressHydrationWarning>{formatRelative(ticket.createdAt)}</span> <span suppressHydrationWarning>
{formatRelative(ticket.createdAt)}
</span>
{ticket.url ? ( {ticket.url ? (
<a <a
href={ticket.url} href={ticket.url}
@@ -677,7 +759,9 @@ function BranchList({ branches }: { branches: BranchInfo[] }) {
<Row key={branch.name}> <Row key={branch.name}>
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<GitBranch className="h-3.5 w-3.5 shrink-0 text-slate-600" /> <GitBranch className="h-3.5 w-3.5 shrink-0 text-slate-600" />
<span className="truncate font-mono text-xs text-slate-200">{branch.name}</span> <span className="truncate font-mono text-xs text-slate-200">
{branch.name}
</span>
{branch.isDefault ? ( {branch.isDefault ? (
<span className="shrink-0 rounded bg-violet-500/15 px-1.5 py-0.5 text-[10px] text-violet-200"> <span className="shrink-0 rounded bg-violet-500/15 px-1.5 py-0.5 text-[10px] text-violet-200">
default default
@@ -689,10 +773,14 @@ function BranchList({ branches }: { branches: BranchInfo[] }) {
</div> </div>
<div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500"> <div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500">
{branch.lastCommitAt ? ( {branch.lastCommitAt ? (
<span suppressHydrationWarning>{formatRelative(branch.lastCommitAt)}</span> <span suppressHydrationWarning>
{formatRelative(branch.lastCommitAt)}
</span>
) : null} ) : null}
{branch.sha ? ( {branch.sha ? (
<span className="font-mono text-slate-600">{branch.sha.slice(0, 7)}</span> <span className="font-mono text-slate-600">
{branch.sha.slice(0, 7)}
</span>
) : null} ) : null}
</div> </div>
</Row> </Row>
@@ -709,15 +797,23 @@ function TagList({ tags }: { tags: TagInfo[] }) {
<Row key={tag.name}> <Row key={tag.name}>
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<Tag className="h-3.5 w-3.5 shrink-0 text-slate-600" /> <Tag className="h-3.5 w-3.5 shrink-0 text-slate-600" />
<span className="truncate font-mono text-xs text-slate-200">{tag.name}</span> <span className="truncate font-mono text-xs text-slate-200">
{tag.name}
</span>
{tag.message ? ( {tag.message ? (
<span className="truncate text-[11px] text-slate-500">{tag.message}</span> <span className="truncate text-[11px] text-slate-500">
{tag.message}
</span>
) : null} ) : null}
</div> </div>
<div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500"> <div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500">
{tag.date ? <span suppressHydrationWarning>{formatRelative(tag.date)}</span> : null} {tag.date ? (
<span suppressHydrationWarning>{formatRelative(tag.date)}</span>
) : null}
{tag.sha ? ( {tag.sha ? (
<span className="font-mono text-slate-600">{tag.sha.slice(0, 7)}</span> <span className="font-mono text-slate-600">
{tag.sha.slice(0, 7)}
</span>
) : null} ) : null}
</div> </div>
</Row> </Row>
@@ -735,7 +831,9 @@ function CommitList({ commits }: { commits: CommitInfo[] }) {
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-xs text-slate-200">{commit.message}</p> <p className="truncate text-xs text-slate-200">{commit.message}</p>
<p className="mt-0.5 flex items-center gap-2 text-[11px] text-slate-500"> <p className="mt-0.5 flex items-center gap-2 text-[11px] text-slate-500">
<span className="font-mono text-violet-300/80">{commit.shortSha}</span> <span className="font-mono text-violet-300/80">
{commit.shortSha}
</span>
<span>{commit.author ?? "unbekannt"}</span> <span>{commit.author ?? "unbekannt"}</span>
</p> </p>
</div> </div>
@@ -760,14 +858,17 @@ function CommitList({ commits }: { commits: CommitInfo[] }) {
} }
function ReleaseList({ releases }: { releases: ReleaseInfo[] }) { function ReleaseList({ releases }: { releases: ReleaseInfo[] }) {
if (releases.length === 0) return <Empty text="Keine Releases veroeffentlicht." />; if (releases.length === 0)
return <Empty text="Keine Releases veroeffentlicht." />;
return ( return (
<div> <div>
{releases.map((release) => ( {releases.map((release) => (
<Row key={`${release.tag}-${release.name}`}> <Row key={`${release.tag}-${release.name}`}>
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<Package className="h-3.5 w-3.5 shrink-0 text-slate-600" /> <Package className="h-3.5 w-3.5 shrink-0 text-slate-600" />
<span className="truncate text-xs text-slate-200">{release.name}</span> <span className="truncate text-xs text-slate-200">
{release.name}
</span>
<span className="shrink-0 rounded bg-white/6 px-1.5 py-0.5 font-mono text-[10px] text-slate-400"> <span className="shrink-0 rounded bg-white/6 px-1.5 py-0.5 font-mono text-[10px] text-slate-400">
{release.tag} {release.tag}
</span> </span>
@@ -783,7 +884,9 @@ function ReleaseList({ releases }: { releases: ReleaseInfo[] }) {
) : null} ) : null}
</div> </div>
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500"> <div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
<span suppressHydrationWarning>{formatRelative(release.publishedAt)}</span> <span suppressHydrationWarning>
{formatRelative(release.publishedAt)}
</span>
{release.url ? ( {release.url ? (
<a <a
href={release.url} href={release.url}
@@ -801,3 +904,58 @@ function ReleaseList({ releases }: { releases: ReleaseInfo[] }) {
</div> </div>
); );
} }
/**
* Teile, die beim letzten Abgleich nicht ankamen.
*
* Bernstein statt Rot: der Abgleich ist nicht gescheitert, es fehlt nur
* etwas – und was danebensteht, ist der letzte gute Stand, keine Null.
*
* Bis v1.10.1 sah eine nicht geholte Liste genauso aus wie eine leere. Ein
* Repository mit 65 Commits meldete "Snapshot aktualisiert: 0 Branches,
* 0 Tags", und aus der Oberflaeche war nicht zu erkennen, dass ueberhaupt
* etwas schiefgegangen war.
*/
function UnvollstaendigerStand({
snapshot,
}: {
snapshot: RepoSnapshot | null;
}) {
if (!snapshot) return null;
const fehlend = asTextRecord(snapshot.missing);
// Sortiert: die Reihenfolge im Objekt haengt daran, welcher Aufruf zuerst
// gescheitert ist, und das waere bei jedem Abgleich eine andere.
const felder = Object.keys(fehlend).sort();
if (felder.length === 0) return null;
return (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-sm text-amber-100">
<p className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
Beim letzten Abgleich nicht geholt:{" "}
<strong>{felder.join(", ")}</strong>. Dafuer steht hier weiterhin der
vorherige Stand
{snapshot.completeAt ? (
<>
{" "}
von{" "}
<span suppressHydrationWarning>
{formatDateTime(snapshot.completeAt)}
</span>
</>
) : null}
.
</span>
</p>
<ul className="mt-2 space-y-0.5 pl-6 text-[11px] text-amber-200/90">
{felder.map((feld) => (
<li key={feld}>
<span className="font-mono">{feld}</span> — {fehlend[feld]}
</li>
))}
</ul>
</div>
);
}
+133
View File
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { import {
Ban, Ban,
CheckCircle2, CheckCircle2,
Columns3,
Database, Database,
Download, Download,
Fingerprint, Fingerprint,
@@ -74,6 +75,12 @@ export interface AdminData {
/** Wie viele Konten ueberhaupt auf Stillstand hoeren. */ /** Wie viele Konten ueberhaupt auf Stillstand hoeren. */
listeners: number; listeners: number;
}; };
tasks: {
/** Karten je Spalte im Aufgabenbrett; 0 = ohne Begrenzung. */
columnLimit: number;
/** Laengste Spalte ueber alle Projekte – ohne die ist die Zahl blind. */
laengsteSpalte: number;
};
} }
type Notice = { ok: boolean; text: string } | null; type Notice = { ok: boolean; text: string } | null;
@@ -195,6 +202,7 @@ export function AdminManager({ data }: { data: AdminData }) {
<AutoSyncSection data={data.autoSync} /> <AutoSyncSection data={data.autoSync} />
<StaleSection data={data.stale} /> <StaleSection data={data.stale} />
<TaskLimitSection data={data.tasks} />
{/* ── Instanz ─────────────────────────────────────── */} {/* ── Instanz ─────────────────────────────────────── */}
<section className="glass p-5"> <section className="glass p-5">
@@ -1099,3 +1107,128 @@ function UserDialog({
</Modal> </Modal>
); );
} }
const SPALTEN_OPTIONEN = [0, 5, 10, 15, 20, 30, 50];
/**
* Wie viele Karten eine Spalte des Aufgabenbretts hoechstens zeigt.
*
* Begrenzt wird nur die Anzeige: der Rest steht hinter einem Knopf, und die
* Reihenfolge wird weiterhin ueber die vollstaendige Spalte gespeichert.
* Eine Aufgabe verschwindet hier also nirgends – sie ist nur einen Klick
* weiter weg.
*/
function TaskLimitSection({ data }: { data: AdminData["tasks"] }) {
const router = useRouter();
const [limit, setLimit] = useState(data.columnLimit);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
async function save(value: number) {
setBusy(true);
setNotice(null);
try {
const response = await fetch("/api/admin/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskColumnLimit: value }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error ?? "Speichern fehlgeschlagen.");
setNotice({ ok: true, text: "Gespeichert." });
router.refresh();
} catch (caught) {
setLimit(data.columnLimit);
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
} finally {
setBusy(false);
}
}
const greift = limit > 0 && data.laengsteSpalte > limit;
return (
<section className="glass p-5">
<div className="mb-4 flex items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-violet-500/12 text-violet-300">
<Columns3 className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
Aufgabenbrett
</h2>
<p className="mt-1 max-w-xl text-sm text-slate-400">
Wie viele Karten eine Spalte hoechstens zeigt. Was darueber hinausgeht, kommt
hinter einen Knopf – verborgen wird nichts, und die Reihenfolge bleibt
vollstaendig gespeichert. Gilt fuer alle Projekte.
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="field-label" htmlFor="task-column-limit">
Karten je Spalte
</label>
<select
id="task-column-limit"
className="field"
value={limit}
disabled={busy}
onChange={(event) => {
const value = Number(event.target.value);
setLimit(value);
void save(value);
}}
>
{SPALTEN_OPTIONEN.map((value) => (
<option key={value} value={value}>
{value === 0 ? "ohne Begrenzung" : `hoechstens ${value}`}
</option>
))}
</select>
</div>
<dl className="grid grid-cols-2 gap-3">
<div className="glass-soft px-3 py-2.5">
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
Laengste Spalte
</dt>
<dd className="mt-1 text-sm tabular-nums text-slate-200">
{data.laengsteSpalte}
</dd>
</div>
<div className="glass-soft px-3 py-2.5">
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
Wirkung
</dt>
<dd className="mt-1 text-sm text-slate-200">
{limit === 0 ? "aus" : greift ? "greift" : "noch nicht"}
</dd>
</div>
</dl>
</div>
<p className="mt-3 text-[11px] text-slate-600">
{limit === 0
? "Jede Spalte zeigt alles. Bei langen Listen wird das Brett unuebersichtlich."
: greift
? "Mindestens eine Spalte ist laenger als die Grenze – dort erscheint der Knopf."
: "Derzeit reicht keine Spalte an die Grenze heran; sichtbar wird die Einstellung erst danach."}
</p>
{notice ? (
<p
className={cn(
"mt-3 rounded-lg border px-3 py-2 text-sm",
notice.ok
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
)}
>
{notice.text}
</p>
) : null}
</section>
);
}
+39 -4
View File
@@ -18,11 +18,11 @@ import {
import { restrictToWindowEdges } from "@dnd-kit/modifiers"; import { restrictToWindowEdges } from "@dnd-kit/modifiers";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { CheckSquare, Loader2, Plus } from "lucide-react"; import { CheckSquare, ChevronDown, ChevronUp, Loader2, Plus } from "lucide-react";
import type { Task, TaskStatus } from "@prisma/client"; import type { Task, TaskStatus } from "@prisma/client";
import { TaskCard } from "@/components/tasks/TaskCard"; import { TaskCard } from "@/components/tasks/TaskCard";
import { TaskDialog } from "@/components/tasks/TaskDialog"; import { TaskDialog } from "@/components/tasks/TaskDialog";
import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks"; import { TASK_STATUS_META, TASK_STATUS_ORDER, spaltenAnzeige } from "@/lib/tasks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type Columns = Record<TaskStatus, Task[]>; type Columns = Record<TaskStatus, Task[]>;
@@ -48,10 +48,13 @@ export function TaskBoard({
projectId, projectId,
tasks, tasks,
progressFromTasks, progressFromTasks,
columnLimit = 0,
}: { }: {
projectId: string; projectId: string;
tasks: Task[]; tasks: Task[];
progressFromTasks: boolean; progressFromTasks: boolean;
/** Karten je Spalte; 0 = ohne Begrenzung. Kommt aus den Admin-Einstellungen. */
columnLimit?: number;
}) { }) {
const router = useRouter(); const router = useRouter();
const [columns, setColumns] = useState<Columns>(() => group(tasks)); const [columns, setColumns] = useState<Columns>(() => group(tasks));
@@ -224,6 +227,7 @@ export function TaskBoard({
key={status} key={status}
status={status} status={status}
tasks={columns[status]} tasks={columns[status]}
limit={columnLimit}
onAdd={() => setCreatingIn(status)} onAdd={() => setCreatingIn(status)}
onEdit={setEditing} onEdit={setEditing}
/> />
@@ -252,16 +256,24 @@ export function TaskBoard({
function TaskColumn({ function TaskColumn({
status, status,
tasks, tasks,
limit,
onAdd, onAdd,
onEdit, onEdit,
}: { }: {
status: TaskStatus; status: TaskStatus;
tasks: Task[]; tasks: Task[];
limit: number;
onAdd: () => void; onAdd: () => void;
onEdit: (task: Task) => void; onEdit: (task: Task) => void;
}) { }) {
const meta = TASK_STATUS_META[status]; const meta = TASK_STATUS_META[status];
const { setNodeRef, isOver } = useDroppable({ id: status }); const { setNodeRef, isOver } = useDroppable({ id: status });
const [entfaltet, setEntfaltet] = useState(false);
// Nur die Anzeige wird gekuerzt – `tasks` bleibt oben vollstaendig, sonst
// wuerde das Umsortieren die verborgenen Aufgaben aus dem Brett loeschen.
const { sichtbar, verborgen } = spaltenAnzeige(tasks.length, limit, entfaltet);
const gezeigt = tasks.slice(0, sichtbar);
return ( return (
<div <div
@@ -284,12 +296,14 @@ function TaskColumn({
<span className="ml-auto text-[10px] tabular-nums text-slate-600">{tasks.length}</span> <span className="ml-auto text-[10px] tabular-nums text-slate-600">{tasks.length}</span>
</div> </div>
{/* Nur die gezeigten Karten stehen im SortableContext – was nicht im
DOM ist, kann dnd-kit ohnehin nicht treffen. */}
<SortableContext <SortableContext
items={tasks.map((task) => task.id)} items={gezeigt.map((task) => task.id)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<div className="flex flex-1 flex-col gap-1.5"> <div className="flex flex-1 flex-col gap-1.5">
{tasks.map((task) => ( {gezeigt.map((task) => (
<SortableTask key={task.id} task={task} onEdit={onEdit} /> <SortableTask key={task.id} task={task} onEdit={onEdit} />
))} ))}
{tasks.length === 0 ? ( {tasks.length === 0 ? (
@@ -300,6 +314,27 @@ function TaskColumn({
</div> </div>
</SortableContext> </SortableContext>
{verborgen > 0 || entfaltet ? (
<button
type="button"
onClick={() => setEntfaltet((vorher) => !vorher)}
aria-expanded={entfaltet}
className="mt-1.5 flex items-center justify-center gap-1 rounded-lg border border-dashed border-white/10 px-2 py-1.5 text-[11px] text-slate-500 transition-colors hover:border-white/20 hover:text-slate-300"
>
{entfaltet ? (
<>
<ChevronUp className="h-3 w-3" />
weniger anzeigen
</>
) : (
<>
<ChevronDown className="h-3 w-3" />
{verborgen} weitere anzeigen
</>
)}
</button>
) : null}
<button <button
type="button" type="button"
onClick={onAdd} onClick={onAdd}
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { Task } from "@prisma/client";
import { TaskCard } from "./TaskCard";
/**
* Die Karte ist die einzige Stelle im Projekt, an der eine Aufgabe im
* Vorbeigehen zu sehen ist – der Bearbeiten-Dialog verlangt einen Klick.
* Steht die Verknuepfung nur dort, weiss niemand vorher, welche Aufgabe
* ein Issue hat.
*
* Serverseitig gerendert wie bei Markdown.test.tsx: es geht um das
* erzeugte Markup, nicht um Verhalten im Browser.
*/
function aufgabe(felder: Partial<Task> = {}): Task {
return {
id: "t1",
projectId: "p1",
title: "Etwas tun",
description: null,
status: "TODO",
position: 0,
dueDate: null,
labels: [],
recurrence: null,
sourceIssue: null,
sourceUrl: null,
sourceRepositoryId: null,
createdAt: new Date("2026-08-18T10:00:00Z"),
updatedAt: new Date("2026-08-18T10:00:00Z"),
...felder,
} as Task;
}
const markup = (task: Task) => renderToStaticMarkup(<TaskCard task={task} />);
describe("Kennzeichen fuer ein verknuepftes Issue", () => {
it("zeigt die Nummer, sobald eine Aufgabe an einem Issue haengt", () => {
const html = markup(aufgabe({ sourceIssue: 42 }));
expect(html).toContain("#42");
expect(html).toContain("Verkn");
});
it("erscheint auch ohne Beschreibung, Datum und Labels", () => {
// Der eigentliche Fallstrick: die Fusszeile der Karte wird nur
// gerendert, wenn es etwas zu zeigen gibt. Faellt die Verknuepfung aus
// dieser Bedingung heraus, bleibt genau die nackte Aufgabe unbeschriftet
// – und das ist der haeufige Fall bei einer aus einem Issue erzeugten.
const html = markup(
aufgabe({ sourceIssue: 7, description: null, dueDate: null, labels: [], recurrence: null }),
);
expect(html).toContain("#7");
});
it("bleibt weg, wenn keine Verknuepfung besteht", () => {
const html = markup(aufgabe({ description: "Text" }));
expect(html).not.toContain("Verkn");
});
it("bleibt auf einer erledigten Aufgabe sichtbar", () => {
// Die erledigte Karte streicht ihren Titel durch; das Kennzeichen bleibt
// in voller Helligkeit. Abzudunkeln war versucht und wieder verworfen –
// axe-core rechnete es als Kontrastverstoss ab (10px Text, 3,2:1).
// Dass hier ein Issue mitgeschlossen wurde, ist ohnehin gerade dann die
// interessante Auskunft.
const html = markup(aufgabe({ sourceIssue: 5, status: "DONE" }));
expect(html).toContain("#5");
});
it("baut keinen Link in die Karte", () => {
// Die Karte steckt in einem <button>; ein <a> darin waere ungueltiges
// Markup und im Browser nicht verlaesslich anklickbar.
const html = markup(aufgabe({ sourceIssue: 42, sourceUrl: "https://example.com/issues/42" }));
expect(html).not.toContain("<a ");
});
it("nennt die Verknuepfung auch fuer Vorlesesoftware", () => {
// "#42" allein ergibt vorgelesen keinen Sinn.
const html = markup(aufgabe({ sourceIssue: 42 }));
expect(html).toMatch(/sr-only[^>]*>Verkn/);
});
});
+26 -2
View File
@@ -1,6 +1,12 @@
"use client"; "use client";
import { AlignLeft, CalendarClock, GripVertical, Repeat } from "lucide-react"; import {
AlignLeft,
CalendarClock,
GitPullRequestArrow,
GripVertical,
Repeat,
} from "lucide-react";
import type { Task } from "@prisma/client"; import type { Task } from "@prisma/client";
import { RECURRENCE_META } from "@/lib/tasks"; import { RECURRENCE_META } from "@/lib/tasks";
import { cn, formatDate } from "@/lib/utils"; import { cn, formatDate } from "@/lib/utils";
@@ -70,8 +76,26 @@ export function TaskCard({
{task.title} {task.title}
</p> </p>
{(task.description || task.dueDate || task.recurrence || task.labels.length > 0) && ( {(task.description ||
task.dueDate ||
task.recurrence ||
task.sourceIssue ||
task.labels.length > 0) && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]"> <div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]">
{/* Steht vorn, damit sich eine Spalte danach ueberfliegen laesst.
Bewusst kein Link: die Karte steckt in einem <button>, und ein
<a> darin waere ungueltig. Der Weg zum Issue fuehrt ueber den
Bearbeiten-Dialog, der ihn ohnehin anbietet. */}
{task.sourceIssue ? (
<span
className="inline-flex items-center gap-1 text-violet-300"
title={`Verknüpft mit Issue #${task.sourceIssue}`}
>
<GitPullRequestArrow className="h-2.5 w-2.5" aria-hidden />
<span className="sr-only">Verknüpft mit </span>#{task.sourceIssue}
</span>
) : null}
{task.description ? ( {task.description ? (
<AlignLeft className="h-2.5 w-2.5 text-slate-600" aria-label="hat Beschreibung" /> <AlignLeft className="h-2.5 w-2.5 text-slate-600" aria-label="hat Beschreibung" />
) : null} ) : null}
+89
View File
@@ -41,6 +41,95 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
}; };
export const CHANGELOG: Release[] = [ export const CHANGELOG: Release[] = [
{
version: "1.11.0",
date: "2026-09-10",
headline: "Der Abgleich sagt, was er nicht holen konnte — und verliert dabei nichts mehr.",
changes: [
{
kind: "behoben",
text:
"Ein fehlgeschlagener Teil-Abruf sah aus wie ein leeres Ergebnis. Ein Repository " +
"mit 65 Commits meldete „Snapshot aktualisiert: 0 Branches, 0 Tags“ und überschrieb " +
"dabei den letzten guten Stand. Jetzt steht am Repository, welcher Teil nicht kam " +
"und woran es lag — und die vorhandenen Daten bleiben stehen.",
},
{
kind: "behoben",
text:
"Die Commit-Heatmap kannte bei Gitea nur die jüngsten 50 Commits und gab deren " +
"ältesten als Anfang des Zeitraums aus. Grund war eine Seitengröße, die angefragt " +
"statt gemessen wurde: Gitea deckelt jede Antwort auf 50, egal was dasteht. Bei " +
"einem Repository mit 76 Commits fehlte damit ein Drittel.",
},
{
kind: "besser",
text:
"Ein Abruf, der in eine Zeitüberschreitung läuft, wird einmal nachgeschlagen — " +
"einzeln statt zusammen mit den sieben anderen. Bei fehlenden Rechten oder einem " +
"Rate-Limit passiert das bewusst nicht; das wird durch Wiederholen nicht besser.",
},
{
kind: "neu",
text:
"Das Aufgabenbrett zeigt je Spalte höchstens so viele Karten, wie unter " +
"Admin-Einstellungen eingestellt ist. Der Rest steht hinter „n weitere anzeigen“; " +
"die Zahl an der Spalte nennt weiterhin alle.",
},
{
kind: "neu",
text:
"Eine Aufgabenkarte trägt die Nummer ihres verknüpften Git-Issues. Bisher stand " +
"die Verknüpfung nur im Bearbeiten-Dialog.",
},
{
kind: "besser",
text:
"Die Rechte, die eine Git-Verbindung braucht, stehen Aufruf für Aufruf in der " +
"README — getrennt nach reinem Abgleich und zusätzlicher Issue-Anbindung. Der " +
"Hinweis unter dem Token-Feld nannte die Rechte für Actions nicht, ohne die bei " +
"GitHub kein CI-Zustand ankommt.",
},
{
kind: "behoben",
text:
"Ein unbrauchbarer Wert in GIT_API_TIMEOUT_MS legte den ganzen Abgleich still: " +
"eine leere oder unlesbare Angabe wurde zu 0 ms, und damit brach jeder Aufruf ab, " +
"bevor er begann. Jetzt gilt die Vorgabe, mit Warnung im Log.",
},
],
},
{
version: "1.10.1",
date: "2026-08-18",
headline: "Sichtbar, welche Aufgabe ein Issue hat — und nachlesbar, was ein Token dafür braucht.",
changes: [
{
kind: "neu",
text:
"Auf dem Aufgabenbrett eines Projekts trägt eine Karte die Nummer ihres " +
"verknüpften Git-Issues. Bisher stand die Verknüpfung nur im Bearbeiten-Dialog " +
"und in der projektübergreifenden Übersicht — welche Aufgabe schon ein Issue " +
"hat, war im Vorbeigehen nicht zu sehen.",
},
{
kind: "besser",
text:
"Die Rechte einer Git-Verbindung stehen jetzt Aufruf für Aufruf in der README: " +
"je System eine Tabelle aus Endpunkt, Zweck und benötigtem Recht. Getrennt nach " +
"reinem Abgleich und zusätzlicher Issue-Anbindung, damit erkennbar ist, was sich " +
"weglassen lässt. Bei GitLab ist belegt, dass es unterhalb von „api“ keinen " +
"Schreibzugriff auf Issues gibt.",
},
{
kind: "behoben",
text:
"Der Hinweis unter dem Token-Feld nannte die Rechte für Actions nicht, obwohl " +
"der CI-Zustand bei GitHub genau daran hängt. Wer sich daran hielt, sah keinen " +
"Build-Status.",
},
],
},
{ {
version: "1.10.0", version: "1.10.0",
date: "2026-08-18", date: "2026-08-18",
+76 -6
View File
@@ -18,6 +18,19 @@ import {
const JETZT = new Date("2026-07-26T12:00:00.000Z"); const JETZT = new Date("2026-07-26T12:00:00.000Z");
const TAG = 86_400_000; const TAG = 86_400_000;
/**
* Eine Quelle, die es nur eine Seite lang gibt.
*
* Diese Tests pruefen das Zaehlen und Filtern, nicht das Blaettern. Ihr
* Fixture lieferte urspruenglich fuer *jede* Seite dieselben Daten und hielt
* nur deshalb an, weil die alte Regel eine kurze Seite fuer die letzte hielt
* – genau der Fehler, um den es hier geht. Kein Server verhaelt sich so;
* nach der letzten Seite kommt nichts mehr.
*/
function eineSeite(daten: Array<string | null>) {
return async (seite: number) => (seite === 1 ? daten : []);
}
/** Erzeugt n Commit-Zeitpunkte, beginnend vor `abTagen` Tagen, einer je Tag. */ /** Erzeugt n Commit-Zeitpunkte, beginnend vor `abTagen` Tagen, einer je Tag. */
function commits(anzahl: number, abTagen: number): string[] { function commits(anzahl: number, abTagen: number): string[] {
return Array.from({ length: anzahl }, (_, index) => return Array.from({ length: anzahl }, (_, index) =>
@@ -45,14 +58,14 @@ describe("calendarByPaging", () => {
it("zaehlt mehrere Commits desselben Tages zusammen", async () => { it("zaehlt mehrere Commits desselben Tages zusammen", async () => {
const heute = JETZT.toISOString(); const heute = JETZT.toISOString();
const kalender = await calendarByPaging(async () => [heute, heute, heute], JETZT); const kalender = await calendarByPaging(eineSeite([heute, heute, heute]), JETZT);
expect(kalender?.days["2026-07-26"]).toBe(3); expect(kalender?.days["2026-07-26"]).toBe(3);
expect(calendarTotal(kalender!)).toBe(3); expect(calendarTotal(kalender!)).toBe(3);
}); });
it("beginnt beim aeltesten bekannten Commit, nicht vor einem Jahr", async () => { it("beginnt beim aeltesten bekannten Commit, nicht vor einem Jahr", async () => {
// Nur eine halbe Seite, zehn Tage zurueck: alles davor ist unbekannt. // Nur eine halbe Seite, zehn Tage zurueck: alles davor ist unbekannt.
const kalender = await calendarByPaging(async () => commits(10, 0), JETZT); const kalender = await calendarByPaging(eineSeite(commits(10, 0)), JETZT);
expect(kalender?.from).toBe("2026-07-17"); expect(kalender?.from).toBe("2026-07-17");
expect(kalender?.to).toBe("2026-07-26"); expect(kalender?.to).toBe("2026-07-26");
expect(kalender?.truncated).toBe(false); expect(kalender?.truncated).toBe(false);
@@ -93,10 +106,10 @@ describe("calendarByPaging", () => {
it("uebergeht Commits mit Datum in der Zukunft", async () => { it("uebergeht Commits mit Datum in der Zukunft", async () => {
// Falsch gestellte Uhren gibt es; sie duerfen den Zeitraum nicht verlaengern. // Falsch gestellte Uhren gibt es; sie duerfen den Zeitraum nicht verlaengern.
const kalender = await calendarByPaging( const kalender = await calendarByPaging(
async () => [ eineSeite([
new Date(JETZT.getTime() + 5 * TAG).toISOString(), new Date(JETZT.getTime() + 5 * TAG).toISOString(),
JETZT.toISOString(), JETZT.toISOString(),
], ]),
JETZT, JETZT,
); );
expect(kalender?.to).toBe("2026-07-26"); expect(kalender?.to).toBe("2026-07-26");
@@ -105,7 +118,7 @@ describe("calendarByPaging", () => {
it("uebergeht Unbrauchbares, statt daran zu scheitern", async () => { it("uebergeht Unbrauchbares, statt daran zu scheitern", async () => {
const kalender = await calendarByPaging( const kalender = await calendarByPaging(
async () => [null, "gestern", "", JETZT.toISOString()], eineSeite([null, "gestern", "", JETZT.toISOString()]),
JETZT, JETZT,
); );
expect(calendarTotal(kalender!)).toBe(1); expect(calendarTotal(kalender!)).toBe(1);
@@ -114,9 +127,66 @@ describe("calendarByPaging", () => {
it("laesst Tage ohne Commits weg", async () => { it("laesst Tage ohne Commits weg", async () => {
// Ein Jahr als Liste waeren 365 Eintraege, die meisten davon Nullen. // Ein Jahr als Liste waeren 365 Eintraege, die meisten davon Nullen.
const kalender = await calendarByPaging( const kalender = await calendarByPaging(
async () => [JETZT.toISOString(), new Date(JETZT.getTime() - 30 * TAG).toISOString()], eineSeite([JETZT.toISOString(), new Date(JETZT.getTime() - 30 * TAG).toISOString()]),
JETZT, JETZT,
); );
expect(Object.keys(kalender!.days)).toHaveLength(2); expect(Object.keys(kalender!.days)).toHaveLength(2);
}); });
}); });
/**
* Gitea deckelt jede Antwort auf MAX_RESPONSE_ITEMS (Voreinstellung 50),
* egal was angefragt wurde. Wer gegen die angefragten 100 vergleicht, haelt
* dort jede erste Seite fuer die letzte: die Heatmap kannte nur die
* juengsten 50 Commits und gab deren aeltesten als Anfang des Zeitraums aus.
*/
describe("calendarByPaging gegen eine deckelnde Gegenseite", () => {
/** Antwortet nie mit mehr als `deckel` Eintraegen – wie Gitea. */
function gedeckelteQuelle(gesamt: number, deckel: number) {
const alle = commits(gesamt, 0);
return vi.fn(async (seite: number) =>
alle.slice((seite - 1) * deckel, seite * deckel),
);
}
it("blaettert weiter, obwohl die Seite kleiner ist als angefragt", async () => {
// 76 Commits bei einem Deckel von 50 – der Fall aus florian.wilde/flimmer.
const ladeSeite = gedeckelteQuelle(76, 50);
const kalender = await calendarByPaging(ladeSeite, JETZT);
// Vorher: eine Seite, 50 Commits, "seit" beim 50. – und truncated false.
expect(ladeSeite).toHaveBeenCalledTimes(2);
expect(calendarTotal(kalender!)).toBe(76);
expect(kalender?.truncated).toBe(false);
});
it("nimmt die erste Antwort als Maßstab, nicht die angefragte Groesse", async () => {
// Selbst ein sehr kleiner Deckel darf nicht dazu fuehren, dass nach der
// ersten Seite Schluss ist.
const ladeSeite = gedeckelteQuelle(25, 10);
const kalender = await calendarByPaging(ladeSeite, JETZT);
expect(ladeSeite).toHaveBeenCalledTimes(3);
expect(calendarTotal(kalender!)).toBe(25);
});
it("haelt den Deckel weiterhin ein", async () => {
// Mehr Commits als CALENDAR_MAX_PAGES Seiten fassen: abbrechen, aber
// ehrlich sein.
const ladeSeite = gedeckelteQuelle(50 * CALENDAR_MAX_PAGES + 20, 50);
const kalender = await calendarByPaging(ladeSeite, JETZT);
expect(ladeSeite).toHaveBeenCalledTimes(CALENDAR_MAX_PAGES);
expect(kalender?.truncated).toBe(true);
expect(calendarTotal(kalender!)).toBe(50 * CALENDAR_MAX_PAGES);
});
it("meldet keine Kuerzung, wenn die Daten einfach zu Ende sind", async () => {
// Genau eine volle Seite und dann nichts mehr: das ist kein Deckel.
const ladeSeite = gedeckelteQuelle(50, 50);
const kalender = await calendarByPaging(ladeSeite, JETZT);
expect(ladeSeite).toHaveBeenCalledTimes(2);
expect(kalender?.truncated).toBe(false);
});
});
+31 -4
View File
@@ -16,11 +16,20 @@
export const CALENDAR_DAYS = 365; export const CALENDAR_DAYS = 365;
/** /**
* Hoechstzahl Seiten, wo seitenweise geholt werden muss. Fuenf Seiten sind * Hoechstzahl Seiten, wo seitenweise geholt werden muss. Genug fuer die
* 500 Commits – genug fuer die allermeisten Repositories und wenig genug, * allermeisten Repositories und wenig genug, um kein fremdes Kontingent zu
* um kein fremdes Kontingent zu sprengen. * sprengen.
*/ */
export const CALENDAR_MAX_PAGES = 5; export const CALENDAR_MAX_PAGES = 5;
/**
* Angefragte Seitengroesse – eine Bitte, keine Zusage.
*
* Gitea antwortet hoechstens mit MAX_RESPONSE_ITEMS Eintraegen
* (Voreinstellung 50), egal was hier steht; GitHub und GitLab erlauben 100.
* Wie viele wirklich kommen, entscheidet deshalb erst die erste Antwort –
* siehe calendarByPaging.
*/
export const CALENDAR_PAGE_SIZE = 100; export const CALENDAR_PAGE_SIZE = 100;
export interface CommitCalendar { export interface CommitCalendar {
@@ -55,6 +64,15 @@ export function calendarSince(now: Date = new Date()): Date {
* Abgebrochen wird, sobald eine Seite nicht mehr voll ist (dann gibt es * Abgebrochen wird, sobald eine Seite nicht mehr voll ist (dann gibt es
* nichts weiter), sobald die Seite vollstaendig aus der Zeit vor dem * nichts weiter), sobald die Seite vollstaendig aus der Zeit vor dem
* Zeitraum stammt, oder wenn der Deckel greift. * Zeitraum stammt, oder wenn der Deckel greift.
*
* "Voll" bemisst sich an der **ersten geliefertern Seite**, nicht an dem, was
* angefragt wurde. Gitea deckelt jede Antwort auf MAX_RESPONSE_ITEMS
* (Voreinstellung 50), unabhaengig vom angefragten limit. Wer gegen die
* angefragten 100 vergleicht, haelt bei Gitea jede erste Seite fuer die
* letzte – die Heatmap kannte deshalb nur die juengsten 50 Commits und gab
* deren aeltesten als Anfang des Zeitraums aus. Bei einem Repository mit 76
* Commits stand dort "50 an 2 Tagen seit dem 01.09.", und das las sich wie
* eine Aussage ueber das Repository statt ueber die Seitengroesse.
*/ */
export async function calendarByPaging( export async function calendarByPaging(
ladeSeite: (seite: number) => Promise<Array<string | null>>, ladeSeite: (seite: number) => Promise<Array<string | null>>,
@@ -64,11 +82,18 @@ export async function calendarByPaging(
const days: Record<string, number> = {}; const days: Record<string, number> = {};
let aeltester: number | null = null; let aeltester: number | null = null;
let gedeckelt = false; let gedeckelt = false;
/** Was die Gegenseite tatsaechlich als volle Seite ansieht. */
let seitengroesse: number | null = null;
for (let seite = 1; seite <= CALENDAR_MAX_PAGES; seite += 1) { for (let seite = 1; seite <= CALENDAR_MAX_PAGES; seite += 1) {
const daten = await ladeSeite(seite); const daten = await ladeSeite(seite);
if (daten.length === 0) break; if (daten.length === 0) break;
// Die erste Antwort setzt den Maßstab. Sie kann kleiner ausfallen als
// angefragt; entscheidend ist nur, dass die folgenden Seiten daran
// gemessen werden.
if (seitengroesse === null) seitengroesse = daten.length;
let innerhalb = 0; let innerhalb = 0;
for (const value of daten) { for (const value of daten) {
if (!value) continue; if (!value) continue;
@@ -85,10 +110,12 @@ export async function calendarByPaging(
} }
// Nicht mehr voll: das war die letzte Seite. // Nicht mehr voll: das war die letzte Seite.
if (daten.length < CALENDAR_PAGE_SIZE) break; if (daten.length < seitengroesse) break;
// Die ganze Seite lag schon vor dem Zeitraum – weiter zurueck lohnt nicht. // Die ganze Seite lag schon vor dem Zeitraum – weiter zurueck lohnt nicht.
if (innerhalb === 0) break; if (innerhalb === 0) break;
// Letzte erlaubte Seite war voll und lag im Zeitraum: es gibt Aelteres,
// das wir nicht geholt haben. Das gehoert an die Anzeige.
if (seite === CALENDAR_MAX_PAGES) gedeckelt = true; if (seite === CALENDAR_MAX_PAGES) gedeckelt = true;
} }
+110 -35
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 { import {
CALENDAR_PAGE_SIZE, CALENDAR_PAGE_SIZE,
calendarByPaging, calendarByPaging,
@@ -62,7 +70,11 @@ export class GiteaClient implements GitClient {
return { number: angelegt.number, url: angelegt.html_url ?? null }; return { number: angelegt.number, url: angelegt.html_url ?? null };
} }
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> { async setIssueState(
ref: RepoRef,
number: number,
open: boolean,
): Promise<void> {
await sendJson( await sendJson(
`${this.repoBase(ref)}/issues/${number}`, `${this.repoBase(ref)}/issues/${number}`,
"PATCH", "PATCH",
@@ -71,7 +83,10 @@ export class GiteaClient implements GitClient {
); );
} }
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> { async getIssueState(
ref: RepoRef,
number: number,
): Promise<"open" | "closed" | null> {
const issue = await getJson<{ state?: string } | null>( const issue = await getJson<{ state?: string } | null>(
`${this.repoBase(ref)}/issues/${number}`, `${this.repoBase(ref)}/issues/${number}`,
{ headers: this.headers(), allow404: true }, { headers: this.headers(), allow404: true },
@@ -97,9 +112,12 @@ export class GiteaClient implements GitClient {
}; };
} }
const user = await getJson<{ login: string; full_name?: string }>(`${this.apiBase}/user`, { const user = await getJson<{ login: string; full_name?: string }>(
headers: this.headers(), `${this.apiBase}/user`,
}); {
headers: this.headers(),
},
);
return { return {
ok: true, ok: true,
message: `Verbunden als ${user.login}${version.version ? ` (Gitea ${version.version})` : ""}`, message: `Verbunden als ${user.login}${version.version ? ` (Gitea ${version.version})` : ""}`,
@@ -107,7 +125,10 @@ export class GiteaClient implements GitClient {
version: version.version ?? null, version: version.version ?? null,
}; };
} catch (error) { } catch (error) {
return { ok: false, message: error instanceof Error ? error.message : String(error) }; return {
ok: false,
message: error instanceof Error ? error.message : String(error),
};
} }
} }
@@ -123,7 +144,9 @@ export class GiteaClient implements GitClient {
const needle = query?.toLowerCase().trim(); const needle = query?.toLowerCase().trim();
return repos return repos
.filter((repo) => !needle || repo.full_name.toLowerCase().includes(needle)) .filter(
(repo) => !needle || repo.full_name.toLowerCase().includes(needle),
)
.map((repo) => ({ .map((repo) => ({
fullPath: repo.full_name, fullPath: repo.full_name,
owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "", owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "",
@@ -144,31 +167,76 @@ export class GiteaClient implements GitClient {
const defaultBranch = repo.default_branch ?? ""; const defaultBranch = repo.default_branch ?? "";
const [branches, tags, commits, releases, languages, pulls, issues, status] = // Sammelt, welche Liste nicht geholt werden konnte und warum. Frueher
await Promise.all([ // stand hier getJsonSafe, das jeden Fehler in eine leere Liste verwandelt
getJsonSafe<GiteaBranch[]>(`${base}/branches?limit=50`, [], { headers }), // hat – siehe Teilabrufe.
getJsonSafe<GiteaTag[]>(`${base}/tags?limit=25`, [], { headers }), const teil = new Teilabrufe();
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
getJsonSafe<GiteaCommit[]>(`${base}/commits?limit=100&stat=false`, [], { headers }), const [
getJsonSafe<GiteaRelease[]>(`${base}/releases?limit=10`, [], { headers }), branches,
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }), tags,
getJsonSafe<GiteaPull[]>(`${base}/pulls?state=open&limit=50`, [], { headers }), commits,
// type=issues, sonst kommen bei Gitea die Pull Requests mit. releases,
getJsonSafe<GiteaIssue[]>(`${base}/issues?state=open&type=issues&limit=50`, [], { languages,
pulls,
issues,
status,
] = await Promise.all([
teil.hol<GiteaBranch[]>("branches", `${base}/branches?limit=50`, [], {
headers,
}),
teil.hol<GiteaTag[]>("tags", `${base}/tags?limit=25`, [], { headers }),
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
// Gitea liefert davon hoechstens MAX_RESPONSE_ITEMS (Voreinstellung 50).
// Das ist hier hinnehmbar - die Kurve kuerzt sich ehrlich auf den
// tatsaechlich belegten Zeitraum (siehe bucketCommitActivity), und
// blaettern wuerde jeden Abgleich teurer machen. Beim Kalender, der
// hoechstens einmal am Tag laeuft, wird dagegen geblaettert.
teil.hol<GiteaCommit[]>(
"commits",
`${base}/commits?limit=100&stat=false`,
[],
{
headers, headers,
}), },
// Gitea meldet CI-Ergebnisse als Commit-Status. Das deckt Gitea ),
// Actions und externe Systeme wie Woodpecker gleichermassen ab – teil.hol<GiteaRelease[]>("releases", `${base}/releases?limit=10`, [], {
// anders als ein reiner Actions-Endpunkt, den aeltere Instanzen headers,
// ohnehin nicht kennen. }),
defaultBranch teil.hol<Record<string, number>>(
? getJsonSafe<GiteaCombinedStatus>( "languages",
`${base}/commits/${encodeURIComponent(defaultBranch)}/status`, `${base}/languages`,
{} as GiteaCombinedStatus, {},
{ headers }, { headers },
) ),
: Promise.resolve({} as GiteaCombinedStatus), teil.hol<GiteaPull[]>(
]); "pullRequests",
`${base}/pulls?state=open&limit=50`,
[],
{
headers,
},
),
// type=issues, sonst kommen bei Gitea die Pull Requests mit.
teil.hol<GiteaIssue[]>(
"issues",
`${base}/issues?state=open&type=issues&limit=50`,
[],
{ headers },
),
// Gitea meldet CI-Ergebnisse als Commit-Status. Das deckt Gitea
// Actions und externe Systeme wie Woodpecker gleichermassen ab –
// anders als ein reiner Actions-Endpunkt, den aeltere Instanzen
// ohnehin nicht kennen.
defaultBranch
? teil.hol<GiteaCombinedStatus>(
"ci",
`${base}/commits/${encodeURIComponent(defaultBranch)}/status`,
{} as GiteaCombinedStatus,
{ headers },
)
: Promise.resolve({} as GiteaCombinedStatus),
]);
const mappedBranches: BranchInfo[] = branches.map((branch) => ({ const mappedBranches: BranchInfo[] = branches.map((branch) => ({
name: branch.name, name: branch.name,
@@ -186,7 +254,9 @@ export class GiteaClient implements GitClient {
sha: tag.commit?.sha ?? tag.id ?? null, sha: tag.commit?.sha ?? tag.id ?? null,
message: tag.message ?? null, message: tag.message ?? null,
date: toIso(tag.commit?.created), date: toIso(tag.commit?.created),
url: tag.zipball_url ? `${repo.html_url}/releases/tag/${encodeURIComponent(tag.name)}` : null, url: tag.zipball_url
? `${repo.html_url}/releases/tag/${encodeURIComponent(tag.name)}`
: null,
})); }));
const mappedCommits: CommitInfo[] = commits.map((commit) => ({ const mappedCommits: CommitInfo[] = commits.map((commit) => ({
@@ -259,7 +329,10 @@ export class GiteaClient implements GitClient {
pullRequests: mappedPulls, pullRequests: mappedPulls,
issues: mappedIssues, issues: mappedIssues,
ci, ci,
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)), commitActivity: bucketCommitActivity(
mappedCommits.map((commit) => commit.date),
),
missing: teil.fehler,
}; };
} }
@@ -298,7 +371,9 @@ export class GiteaClient implements GitClient {
{ headers }, { headers },
); );
const name = pickReadme( const name = pickReadme(
eintraege.filter((eintrag) => eintrag.type === "file").map((eintrag) => eintrag.name), eintraege
.filter((eintrag) => eintrag.type === "file")
.map((eintrag) => eintrag.name),
); );
if (!name) return null; if (!name) return null;
+129 -38
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 { import type {
BranchInfo, BranchInfo,
CiInfo, CiInfo,
@@ -31,7 +39,9 @@ export class GitHubClient implements GitClient {
constructor(config: ProviderConfig) { constructor(config: ProviderConfig) {
const base = normalizeBaseUrl(config.baseUrl || "https://github.com"); const base = normalizeBaseUrl(config.baseUrl || "https://github.com");
this.apiBase = /github\.com$/i.test(base) ? "https://api.github.com" : `${base}/api/v3`; this.apiBase = /github\.com$/i.test(base)
? "https://api.github.com"
: `${base}/api/v3`;
this.token = config.token; this.token = config.token;
} }
@@ -61,7 +71,11 @@ export class GitHubClient implements GitClient {
return { number: angelegt.number, url: angelegt.html_url ?? null }; return { number: angelegt.number, url: angelegt.html_url ?? null };
} }
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> { async setIssueState(
ref: RepoRef,
number: number,
open: boolean,
): Promise<void> {
await sendJson( await sendJson(
`${this.repoBase(ref)}/issues/${number}`, `${this.repoBase(ref)}/issues/${number}`,
"PATCH", "PATCH",
@@ -70,7 +84,10 @@ export class GitHubClient implements GitClient {
); );
} }
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> { async getIssueState(
ref: RepoRef,
number: number,
): Promise<"open" | "closed" | null> {
const issue = await getJson<{ state?: string } | null>( const issue = await getJson<{ state?: string } | null>(
`${this.repoBase(ref)}/issues/${number}`, `${this.repoBase(ref)}/issues/${number}`,
{ headers: this.headers(), allow404: true }, { headers: this.headers(), allow404: true },
@@ -82,25 +99,42 @@ export class GitHubClient implements GitClient {
async check(): Promise<ConnectionCheck> { async check(): Promise<ConnectionCheck> {
try { try {
if (!this.token) { if (!this.token) {
await getJson(`${this.apiBase}/rate_limit`, { headers: this.headers() }); await getJson(`${this.apiBase}/rate_limit`, {
headers: this.headers(),
});
return { return {
ok: true, ok: true,
message: "Erreichbar (ohne Token – nur oeffentliche Repos, striktes Rate-Limit).", message:
"Erreichbar (ohne Token – nur oeffentliche Repos, striktes Rate-Limit).",
user: null, user: null,
}; };
} }
const user = await getJson<{ login: string; name?: string }>(`${this.apiBase}/user`, { const user = await getJson<{ login: string; name?: string }>(
headers: this.headers(), `${this.apiBase}/user`,
}); {
return { ok: true, message: `Verbunden als ${user.login}`, user: user.login }; headers: this.headers(),
},
);
return {
ok: true,
message: `Verbunden als ${user.login}`,
user: user.login,
};
} catch (error) { } catch (error) {
return { ok: false, message: error instanceof Error ? error.message : String(error) }; return {
ok: false,
message: error instanceof Error ? error.message : String(error),
};
} }
} }
async listRepos(query?: string): Promise<RepoListItem[]> { async listRepos(query?: string): Promise<RepoListItem[]> {
if (!this.token) { if (!this.token) {
throw new GitApiError("Fuer das Auflisten von Repos wird ein Token benoetigt.", 401, this.apiBase); throw new GitApiError(
"Fuer das Auflisten von Repos wird ein Token benoetigt.",
401,
this.apiBase,
);
} }
const repos = await getJson<GitHubRepo[]>( const repos = await getJson<GitHubRepo[]>(
`${this.apiBase}/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member`, `${this.apiBase}/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member`,
@@ -108,7 +142,9 @@ export class GitHubClient implements GitClient {
); );
const needle = query?.toLowerCase().trim(); const needle = query?.toLowerCase().trim();
return (repos ?? []) return (repos ?? [])
.filter((repo) => !needle || repo.full_name.toLowerCase().includes(needle)) .filter(
(repo) => !needle || repo.full_name.toLowerCase().includes(needle),
)
.map((repo) => ({ .map((repo) => ({
fullPath: repo.full_name, fullPath: repo.full_name,
owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "", owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "",
@@ -129,23 +165,63 @@ export class GitHubClient implements GitClient {
const defaultBranch = repo.default_branch; const defaultBranch = repo.default_branch;
const [branches, tags, commits, releases, languages, pulls, issues, ciRuns] = // Sammelt, welche Liste nicht geholt werden konnte und warum.
await Promise.all([ const teil = new Teilabrufe();
getJsonSafe<GitHubBranch[]>(`${base}/branches?per_page=50`, [], { headers }),
getJsonSafe<GitHubTag[]>(`${base}/tags?per_page=25`, [], { headers }), const [
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve. branches,
getJsonSafe<GitHubCommit[]>(`${base}/commits?per_page=100`, [], { headers }), tags,
getJsonSafe<GitHubRelease[]>(`${base}/releases?per_page=10`, [], { headers }), commits,
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }), releases,
getJsonSafe<GitHubPull[]>(`${base}/pulls?state=open&per_page=50`, [], { headers }), languages,
// /issues liefert bei GitHub auch Pull Requests – die filtern wir raus. pulls,
getJsonSafe<GitHubIssue[]>(`${base}/issues?state=open&per_page=50`, [], { headers }), issues,
getJsonSafe<{ workflow_runs?: GitHubRun[] }>( ciRuns,
`${base}/actions/runs?per_page=1&branch=${encodeURIComponent(defaultBranch)}`, ] = await Promise.all([
{}, teil.hol<GitHubBranch[]>("branches", `${base}/branches?per_page=50`, [], {
{ headers }, headers,
), }),
]); teil.hol<GitHubTag[]>("tags", `${base}/tags?per_page=25`, [], {
headers,
}),
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
// Kommen weniger an, kuerzt sich die Kurve ehrlich auf den tatsaechlich
// belegten Zeitraum (siehe bucketCommitActivity).
teil.hol<GitHubCommit[]>("commits", `${base}/commits?per_page=100`, [], {
headers,
}),
teil.hol<GitHubRelease[]>(
"releases",
`${base}/releases?per_page=10`,
[],
{ headers },
),
teil.hol<Record<string, number>>(
"languages",
`${base}/languages`,
{},
{ headers },
),
teil.hol<GitHubPull[]>(
"pullRequests",
`${base}/pulls?state=open&per_page=50`,
[],
{ headers },
),
// /issues liefert bei GitHub auch Pull Requests – die filtern wir raus.
teil.hol<GitHubIssue[]>(
"issues",
`${base}/issues?state=open&per_page=50`,
[],
{ headers },
),
teil.hol<{ workflow_runs?: GitHubRun[] }>(
"ci",
`${base}/actions/runs?per_page=1&branch=${encodeURIComponent(defaultBranch)}`,
{},
{ headers },
),
]);
const mappedBranches: BranchInfo[] = branches.map((branch) => ({ const mappedBranches: BranchInfo[] = branches.map((branch) => ({
name: branch.name, name: branch.name,
@@ -208,7 +284,10 @@ export class GitHubClient implements GitClient {
// open_issues_count zaehlt bei GitHub PRs mit – hier wieder herausrechnen. // open_issues_count zaehlt bei GitHub PRs mit – hier wieder herausrechnen.
const openPullRequests = pulls.length; const openPullRequests = pulls.length;
const openIssues = Math.max(0, (repo.open_issues_count ?? 0) - openPullRequests); const openIssues = Math.max(
0,
(repo.open_issues_count ?? 0) - openPullRequests,
);
const run = ciRuns?.workflow_runs?.[0]; const run = ciRuns?.workflow_runs?.[0];
const ci: CiInfo | null = run const ci: CiInfo | null = run
@@ -242,7 +321,10 @@ export class GitHubClient implements GitClient {
pullRequests: mappedPulls, pullRequests: mappedPulls,
issues: mappedIssues, issues: mappedIssues,
ci, ci,
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)), commitActivity: bucketCommitActivity(
mappedCommits.map((commit) => commit.date),
),
missing: teil.fehler,
}; };
} }
@@ -260,9 +342,12 @@ export class GitHubClient implements GitClient {
*/ */
async fetchCommitCalendar(ref: RepoRef): Promise<CommitCalendar | null> { async fetchCommitCalendar(ref: RepoRef): Promise<CommitCalendar | null> {
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`; const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
const wochen = await getJson<GitHubWeek[]>(`${base}/stats/commit_activity`, { const wochen = await getJson<GitHubWeek[]>(
headers: this.headers(), `${base}/stats/commit_activity`,
}); {
headers: this.headers(),
},
);
if (!Array.isArray(wochen) || wochen.length === 0) { if (!Array.isArray(wochen) || wochen.length === 0) {
// Entweder rechnet GitHub noch, oder das Repository hat keine Commits. // Entweder rechnet GitHub noch, oder das Repository hat keine Commits.
@@ -300,7 +385,9 @@ export class GitHubClient implements GitClient {
let datei: GitHubContent; let datei: GitHubContent;
try { try {
datei = await getJson<GitHubContent>(`${base}/readme`, { headers: this.headers() }); datei = await getJson<GitHubContent>(`${base}/readme`, {
headers: this.headers(),
});
} catch (error) { } catch (error) {
if (error instanceof GitApiError && error.status === 404) return null; if (error instanceof GitApiError && error.status === 404) return null;
throw error; throw error;
@@ -337,8 +424,12 @@ interface GitHubWeek {
} }
/** GitHub trennt Lauf-Status und Ergebnis; hier auf einen Wert zusammenfuehren. */ /** GitHub trennt Lauf-Status und Ergebnis; hier auf einen Wert zusammenfuehren. */
function mapRunState(status: string | null, conclusion: string | null): CiState { function mapRunState(
if (status === "queued" || status === "waiting" || status === "pending") return "pending"; status: string | null,
conclusion: string | null,
): CiState {
if (status === "queued" || status === "waiting" || status === "pending")
return "pending";
if (status === "in_progress") return "running"; if (status === "in_progress") return "running";
switch (conclusion) { switch (conclusion) {
case "success": case "success":
+135 -38
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 { import {
CALENDAR_PAGE_SIZE, CALENDAR_PAGE_SIZE,
calendarByPaging, calendarByPaging,
@@ -61,7 +69,11 @@ export class GitLabClient implements GitClient {
return { number: angelegt.iid, url: angelegt.web_url ?? null }; return { number: angelegt.iid, url: angelegt.web_url ?? null };
} }
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> { async setIssueState(
ref: RepoRef,
number: number,
open: boolean,
): Promise<void> {
await sendJson( await sendJson(
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`, `${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
"PUT", "PUT",
@@ -70,7 +82,10 @@ export class GitLabClient implements GitClient {
); );
} }
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> { async getIssueState(
ref: RepoRef,
number: number,
): Promise<"open" | "closed" | null> {
const issue = await getJson<{ state?: string } | null>( const issue = await getJson<{ state?: string } | null>(
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`, `${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
{ headers: this.headers(), allow404: true }, { headers: this.headers(), allow404: true },
@@ -83,15 +98,31 @@ export class GitLabClient implements GitClient {
async check(): Promise<ConnectionCheck> { async check(): Promise<ConnectionCheck> {
try { try {
if (!this.token) { if (!this.token) {
await getJson(`${this.apiBase}/projects?per_page=1`, { headers: this.headers() }); await getJson(`${this.apiBase}/projects?per_page=1`, {
return { ok: true, message: "Erreichbar (ohne Token – nur oeffentliche Projekte).", user: null }; headers: this.headers(),
});
return {
ok: true,
message: "Erreichbar (ohne Token – nur oeffentliche Projekte).",
user: null,
};
} }
const user = await getJson<{ username: string; name?: string }>(`${this.apiBase}/user`, { const user = await getJson<{ username: string; name?: string }>(
headers: this.headers(), `${this.apiBase}/user`,
}); {
return { ok: true, message: `Verbunden als ${user.username}`, user: user.username }; headers: this.headers(),
},
);
return {
ok: true,
message: `Verbunden als ${user.username}`,
user: user.username,
};
} catch (error) { } catch (error) {
return { ok: false, message: error instanceof Error ? error.message : String(error) }; return {
ok: false,
message: error instanceof Error ? error.message : String(error),
};
} }
} }
@@ -101,7 +132,9 @@ export class GitLabClient implements GitClient {
? `${this.apiBase}/projects?membership=true&per_page=100&order_by=last_activity_at${search}` ? `${this.apiBase}/projects?membership=true&per_page=100&order_by=last_activity_at${search}`
: `${this.apiBase}/projects?per_page=50&order_by=last_activity_at${search}`; : `${this.apiBase}/projects?per_page=50&order_by=last_activity_at${search}`;
const projects = await getJson<GitLabProject[]>(url, { headers: this.headers() }); const projects = await getJson<GitLabProject[]>(url, {
headers: this.headers(),
});
return (projects ?? []).map((project) => { return (projects ?? []).map((project) => {
const path = project.path_with_namespace; const path = project.path_with_namespace;
const segments = path.split("/"); const segments = path.split("/");
@@ -127,35 +160,85 @@ export class GitLabClient implements GitClient {
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`; const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
const headers = this.headers(); const headers = this.headers();
const project = await getJson<GitLabProject>(`${base}?statistics=true`, { headers }); const project = await getJson<GitLabProject>(`${base}?statistics=true`, {
headers,
});
const defaultBranch = project.default_branch ?? ""; const defaultBranch = project.default_branch ?? "";
const [branches, tags, commits, releases, languages, mergeRequests, issues, pipelines] = // Sammelt, welche Liste nicht geholt werden konnte und warum.
await Promise.all([ const teil = new Teilabrufe();
getJsonSafe<GitLabBranch[]>(`${base}/repository/branches?per_page=50`, [], { headers }),
getJsonSafe<GitLabTag[]>(`${base}/repository/tags?per_page=25`, [], { headers }), const [
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve. branches,
getJsonSafe<GitLabCommit[]>(`${base}/repository/commits?per_page=100`, [], { headers }), tags,
getJsonSafe<GitLabRelease[]>(`${base}/releases?per_page=10`, [], { headers }), commits,
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }), releases,
getJsonSafe<GitLabMergeRequest[]>(`${base}/merge_requests?state=opened&per_page=50`, [], { languages,
mergeRequests,
issues,
pipelines,
] = await Promise.all([
teil.hol<GitLabBranch[]>(
"branches",
`${base}/repository/branches?per_page=50`,
[],
{ headers },
),
teil.hol<GitLabTag[]>("tags", `${base}/repository/tags?per_page=25`, [], {
headers,
}),
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
// Kommen weniger an, kuerzt sich die Kurve ehrlich auf den tatsaechlich
// belegten Zeitraum (siehe bucketCommitActivity).
teil.hol<GitLabCommit[]>(
"commits",
`${base}/repository/commits?per_page=100`,
[],
{ headers },
),
teil.hol<GitLabRelease[]>(
"releases",
`${base}/releases?per_page=10`,
[],
{ headers },
),
teil.hol<Record<string, number>>(
"languages",
`${base}/languages`,
{},
{ headers },
),
teil.hol<GitLabMergeRequest[]>(
"pullRequests",
`${base}/merge_requests?state=opened&per_page=50`,
[],
{
headers, headers,
}), },
getJsonSafe<GitLabIssue[]>(`${base}/issues?state=opened&per_page=50`, [], { headers }), ),
getJsonSafe<GitLabPipeline[]>( teil.hol<GitLabIssue[]>(
`${base}/pipelines?per_page=1${defaultBranch ? `&ref=${encodeURIComponent(defaultBranch)}` : ""}`, "issues",
[], `${base}/issues?state=opened&per_page=50`,
{ headers }, [],
), { headers },
]); ),
teil.hol<GitLabPipeline[]>(
"ci",
`${base}/pipelines?per_page=1${defaultBranch ? `&ref=${encodeURIComponent(defaultBranch)}` : ""}`,
[],
{ headers },
),
]);
const mappedBranches: BranchInfo[] = branches.map((branch) => ({ const mappedBranches: BranchInfo[] = branches.map((branch) => ({
name: branch.name, name: branch.name,
sha: branch.commit?.id ?? null, sha: branch.commit?.id ?? null,
isDefault: Boolean(branch.default), isDefault: Boolean(branch.default),
protected: Boolean(branch.protected), protected: Boolean(branch.protected),
lastCommitAt: toIso(branch.commit?.committed_date ?? branch.commit?.created_at), lastCommitAt: toIso(
branch.commit?.committed_date ?? branch.commit?.created_at,
),
lastCommitMessage: branch.commit?.title ?? null, lastCommitMessage: branch.commit?.title ?? null,
lastCommitAuthor: branch.commit?.author_name ?? null, lastCommitAuthor: branch.commit?.author_name ?? null,
url: branch.web_url ?? null, url: branch.web_url ?? null,
@@ -240,7 +323,10 @@ export class GitLabClient implements GitClient {
pullRequests: mappedMergeRequests, pullRequests: mappedMergeRequests,
issues: mappedIssues, issues: mappedIssues,
ci, ci,
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)), commitActivity: bucketCommitActivity(
mappedCommits.map((commit) => commit.date),
),
missing: teil.fehler,
}; };
} }
@@ -260,7 +346,9 @@ export class GitLabClient implements GitClient {
`&since=${encodeURIComponent(seit)}`, `&since=${encodeURIComponent(seit)}`,
{ headers: this.headers() }, { headers: this.headers() },
); );
return commits.map((commit) => toIso(commit.committed_date ?? commit.created_at)); return commits.map((commit) =>
toIso(commit.committed_date ?? commit.created_at),
);
}); });
} }
@@ -278,18 +366,27 @@ export class GitLabClient implements GitClient {
{ headers }, { headers },
); );
const name = pickReadme( const name = pickReadme(
eintraege.filter((eintrag) => eintrag.type === "blob").map((eintrag) => eintrag.name), eintraege
.filter((eintrag) => eintrag.type === "blob")
.map((eintrag) => eintrag.name),
); );
if (!name) return null; if (!name) return null;
const datei = await getJson<{ content?: string; encoding?: string; file_path?: string }>( const datei = await getJson<{
`${base}/repository/files/${encodeURIComponent(name)}?ref=HEAD`, content?: string;
{ headers }, encoding?: string;
); file_path?: string;
}>(`${base}/repository/files/${encodeURIComponent(name)}?ref=HEAD`, {
headers,
});
if (!datei?.content || datei.encoding !== "base64") return null; if (!datei?.content || datei.encoding !== "base64") return null;
const { content, truncated } = capReadme(decodeBase64(datei.content)); const { content, truncated } = capReadme(decodeBase64(datei.content));
const projekt = await getJsonSafe<GitLabProject>(base, {} as GitLabProject, { headers }); const projekt = await getJsonSafe<GitLabProject>(
base,
{} as GitLabProject,
{ headers },
);
return { return {
content, content,
+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 { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
import { GitApiError } from "./types"; import { GitApiError } from "./types";
const DEFAULT_TIMEOUT = Number(process.env.GIT_API_TIMEOUT_MS ?? 15000); const TIMEOUT_VORGABE = 15000;
/**
* Zeitlimit je Aufruf.
*
* Bewusst mit Pruefung: Number("") ist 0 und Number("abc") ist NaN – beides
* laesst setTimeout sofort ausloesen, und dann bricht jeder ausgehende
* Aufruf ab, bevor er begonnen hat. Eine verrutschte Zeile in der .env
* legte damit den ganzen Abgleich still, ohne dass irgendwo etwas stand.
*/
export function zeitlimit(
roh: string | undefined,
warnen: (text: string) => void = console.warn,
): number {
const wert = roh?.trim();
if (!wert) return TIMEOUT_VORGABE;
const zahl = Number(wert);
// Untergrenze 1000 ms: darunter ist kein Aufruf ueber das Netz zu
// schaffen, und wer das eintraegt, hat sich vertan.
if (!Number.isFinite(zahl) || zahl < 1000) {
warnen(
`[nebula] GIT_API_TIMEOUT_MS="${wert}" ist unbrauchbar (Zahl ab 1000 erwartet). ` +
`Es gelten ${TIMEOUT_VORGABE} ms.`,
);
return TIMEOUT_VORGABE;
}
return zahl;
}
const DEFAULT_TIMEOUT = zeitlimit(process.env.GIT_API_TIMEOUT_MS);
export interface FetchOptions { export interface FetchOptions {
headers?: Record<string, string>; headers?: Record<string, string>;
@@ -73,7 +103,11 @@ export async function getJson<T>(
if (!response.ok) { if (!response.ok) {
const body = await response.text().catch(() => ""); const body = await response.text().catch(() => "");
throw new GitApiError(friendlyMessage(response.status, url, body), response.status, url); throw new GitApiError(
friendlyMessage(response.status, url, body),
response.status,
url,
);
} }
const text = await response.text(); const text = await response.text();
@@ -150,7 +184,11 @@ export async function sendJson<T>(
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => ""); const text = await response.text().catch(() => "");
throw new GitApiError(friendlyMessage(response.status, url, text), response.status, url); throw new GitApiError(
friendlyMessage(response.status, url, text),
response.status,
url,
);
} }
const text = await response.text(); const text = await response.text();
@@ -175,7 +213,14 @@ export async function sendJson<T>(
} }
} }
/** Wie getJson, gibt aber bei jedem Fehler den Fallback zurueck. */ /**
* Wie getJson, gibt aber bei jedem Fehler den Fallback zurueck.
*
* Nur fuer Abrufe benutzen, bei denen ein Fehlschlag wirklich nichts
* bedeutet. Fuer die Listen eines Snapshots ist das falsch – dort ist
* "nicht geholt" etwas anderes als "nichts vorhanden". Dafuer gibt es
* Teilabrufe.
*/
export async function getJsonSafe<T>( export async function getJsonSafe<T>(
url: string, url: string,
fallback: T, fallback: T,
@@ -189,6 +234,121 @@ export async function getJsonSafe<T>(
} }
} }
/**
* Wie viele zweite Versuche ein Abgleich hoechstens unternimmt.
*
* Zwei, nicht acht: jede Zeitueberschreitung kostet volle 15 Sekunden, und
* acht davon hintereinander waeren zwei Minuten, in denen "Jetzt
* synchronisieren" wie ein Absturz aussieht. Wer wirklich weg ist, soll
* schnell als weg erkannt werden.
*/
const NACHSCHLAG_MAX = 2;
/** Warum ein einzelner Teil-Abruf nicht geklappt hat. */
export type Teilfehler = Record<string, string>;
/**
* Sammelstelle fuer die Listen eines Snapshots.
*
* Der Vorgaenger dieser Klasse war ein getJsonSafe je Liste. Das hat jeden
* Fehler in eine leere Liste verwandelt, und damit war ein 403 nicht mehr
* von einem Repository ohne Branches zu unterscheiden: Der Abgleich meldete
* "Snapshot aktualisiert: 0 Branches, 0 Tags", schrieb Nullen ueber den
* letzten guten Stand und niemand konnte sehen, woran es lag.
*
* Der Fallback bleibt – ein einzelner Fehlschlag darf den ganzen Abgleich
* nicht abbrechen –, aber der Grund wird festgehalten.
*/
export class Teilabrufe {
readonly fehler: Teilfehler = {};
/**
* Nachschlaege haengen aneinander, damit sie nacheinander laufen.
*
* Die Runde selbst ist gleichzeitig – acht Aufrufe auf einmal. Genau das
* scheint sie gelegentlich zu erwuergen: beobachtet wurden zwei von acht
* mit Zeitueberschreitung nach 15 s, waehrend dieselben Aufrufe einzeln in
* 80 ms antworteten. Der zweite Versuch soll deshalb nicht wieder in
* denselben Schwung geraten.
*/
private kette: Promise<unknown> = Promise.resolve();
private nachgeschlagen = 0;
async hol<T>(
feld: string,
url: string,
fallback: T,
options: FetchOptions = {},
): Promise<T> {
try {
const result = await getJson<T>(url, options);
return (result ?? fallback) as T;
} catch (error) {
if (this.lohntNachschlag(error)) {
const zweiter = await this.nachschlag<T>(url, options);
if (zweiter.ok) return (zweiter.wert ?? fallback) as T;
this.fehler[feld] = `${beschreibe(zweiter.fehler)} (auch im zweiten Versuch)`;
return fallback;
}
this.fehler[feld] = beschreibe(error);
return fallback;
}
}
/**
* Ein zweiter Versuch hilft nur bei Fehlern, die nichts ueber die Sache
* aussagen: Zeitueberschreitung (408) und eine Verbindung, die gar nicht
* zustande kam (0). Ein 403 wird durch Wiederholen nicht besser, ein 429
* durch sofortiges Wiederholen schlimmer, und ein 404 bleibt ein 404.
*/
private lohntNachschlag(error: unknown): boolean {
if (this.nachgeschlagen >= NACHSCHLAG_MAX) return false;
if (!(error instanceof GitApiError)) return false;
return error.status === 408 || error.status === 0;
}
private async nachschlag<T>(
url: string,
options: FetchOptions,
): Promise<{ ok: boolean; wert?: T; fehler?: unknown }> {
this.nachgeschlagen += 1;
const versuch = this.kette.then(async () => getJson<T>(url, options));
// Auch ein Fehlschlag darf die Kette nicht abreissen lassen, sonst
// scheitern die folgenden Nachschlaege gleich mit.
this.kette = versuch.catch(() => undefined);
try {
return { ok: true, wert: await versuch };
} catch (fehler) {
return { ok: false, fehler };
}
}
/** true, sobald mindestens ein Teil-Abruf gescheitert ist. */
get unvollstaendig(): boolean {
return Object.keys(this.fehler).length > 0;
}
}
/**
* Kurzfassung eines Fehlers, wie sie am Repository angezeigt wird.
*
* getJson gibt ausschliesslich GitApiError heraus – ein blockiertes Ziel
* kommt als 403, eine Zeitueberschreitung als 408, ein nicht erreichter
* Host als 0. Der Status ist damit die eigentliche Auskunft, und die
* uebrigen Zweige sind nur fuer den Fall da, dass sich das einmal aendert.
*/
function beschreibe(error: unknown): string {
if (error instanceof GitApiError) {
return error.status
? `HTTP ${error.status}: ${error.message}`
: error.message;
}
if (error instanceof Error) return `${error.name}: ${error.message}`;
return String(error);
}
export function toIso(value: unknown): string | null { export function toIso(value: unknown): string | null {
if (!value) return null; if (!value) return null;
const date = new Date(String(value)); const date = new Date(String(value));
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { PROVIDER_META, guessProvider } from "./meta";
const PROVIDER = ["GITHUB", "GITLAB", "GITEA"] as const;
describe("PROVIDER_META", () => {
it("kennt jedes unterstuetzte System", () => {
for (const provider of PROVIDER) {
expect(PROVIDER_META[provider], provider).toBeDefined();
}
// Und nichts darueber hinaus – ein Eintrag ohne Gegenstueck im Schema
// waere ein Ueberbleibsel.
expect(Object.keys(PROVIDER_META).sort()).toEqual([...PROVIDER].sort());
});
it("hat zu jedem System Beschriftung, Basis-URL und Dokuverweis", () => {
for (const provider of PROVIDER) {
const meta = PROVIDER_META[provider];
expect(meta.label.length, provider).toBeGreaterThan(0);
expect(meta.defaultBaseUrl, provider).toMatch(/^https:\/\//);
expect(meta.docsUrl, provider).toMatch(/^https:\/\//);
}
});
/**
* Der eigentliche Waechter.
*
* Der Hinweis am Eingabefeld war eine Zeit lang rein lesend formuliert,
* obwohl Nebula seit v1.7.0 Issues anlegt und schliesst – wer sich daran
* hielt, bekam einen 403. Jeder Provider in GitClient muss createIssue und
* setIssueState koennen; folglich muss auch jeder Hinweis sagen, was das
* an Rechten kostet.
*/
it("sagt bei jedem System etwas ueber die Rechte fuer Issues", () => {
for (const provider of PROVIDER) {
expect(PROVIDER_META[provider].tokenHelp, provider).toMatch(/issue/i);
}
});
it("nennt einen konkreten Scope statt nur 'Leserechte'", () => {
// GitLab nennt Scopes in Anfuehrungszeichen, GitHub und Gitea
// Berechtigungsnamen – gemeinsam ist, dass etwas Nachschlagbares dasteht.
expect(PROVIDER_META.GITLAB.tokenHelp).toMatch(/read_api/);
expect(PROVIDER_META.GITEA.tokenHelp).toMatch(/read:repository/);
expect(PROVIDER_META.GITHUB.tokenHelp).toMatch(/Metadata/);
});
});
describe("guessProvider", () => {
it("erkennt die oeffentlichen Instanzen am Hostnamen", () => {
expect(guessProvider("github.com")).toBe("GITHUB");
expect(guessProvider("gitlab.com")).toBe("GITLAB");
});
it("faellt bei allem anderen auf Gitea zurueck", () => {
// Selbst gehostet ist hier der haeufigste Fall, und Gitea die
// wahrscheinlichste Wahl dafuer.
expect(guessProvider("git.wilde-server.de")).toBe("GITEA");
});
});
+9 -3
View File
@@ -12,19 +12,25 @@ export const PROVIDER_META: Record<
label: "GitHub", label: "GitHub",
defaultBaseUrl: "https://github.com", defaultBaseUrl: "https://github.com",
tokenHelp: tokenHelp:
"Fine-grained Token mit Lesezugriff auf Contents, Metadata, Issues und Pull Requests.", "Fine-grained Token, alle nur lesend: Metadata, Contents, Issues, Pull requests " +
"und Actions. Nur wenn Aufgaben als Issue angelegt werden sollen, Issues " +
"zusaetzlich schreiben.",
docsUrl: "https://github.com/settings/tokens", docsUrl: "https://github.com/settings/tokens",
}, },
GITLAB: { GITLAB: {
label: "GitLab", label: "GitLab",
defaultBaseUrl: "https://gitlab.com", defaultBaseUrl: "https://gitlab.com",
tokenHelp: "Personal Access Token mit Scope 'read_api' (bzw. 'read_repository').", tokenHelp:
"Scope 'read_api' genuegt fuer den Abgleich. Aufgaben als Issue anzulegen verlangt " +
"'api' – GitLab kennt keinen schmaleren Schreibzugriff.",
docsUrl: "https://gitlab.com/-/user_settings/personal_access_tokens", docsUrl: "https://gitlab.com/-/user_settings/personal_access_tokens",
}, },
GITEA: { GITEA: {
label: "Gitea / Forgejo", label: "Gitea / Forgejo",
defaultBaseUrl: "https://git.example.com", defaultBaseUrl: "https://git.example.com",
tokenHelp: "Access Token mit Leserechten auf repository, issue und user.", tokenHelp:
"read:repository, read:issue und read:user. Nur wenn Aufgaben als Issue angelegt " +
"werden sollen, zusaetzlich write:issue.",
docsUrl: "https://docs.gitea.com/development/api-usage", docsUrl: "https://docs.gitea.com/development/api-usage",
}, },
}; };
+60
View File
@@ -0,0 +1,60 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
/**
* Waechter ueber eine Zusage in der Dokumentation.
*
* README und FEATURES sagen zu, dass Nebula an genau zwei Stellen nach
* draussen schreibt: ein Issue anlegen und seinen Zustand umsetzen. Auf
* dieser Zusage steht die Empfehlung, ein rein lesendes Token zu vergeben,
* solange die Issue-Anbindung nicht gebraucht wird.
*
* Kaeme ein dritter Schreibaufruf dazu, wuerde die Empfehlung still falsch:
* niemand bekaeme einen Fehler, nur ein 403 an einer Stelle, die laut
* Dokumentation gar nicht schreibt. Deshalb wird hier der Quelltext selbst
* abgeklopft – sendJson ist der einzige Weg aus lib/git heraus, der eine
* andere Methode als GET benutzt (siehe http.ts).
*/
const CLIENTS = ["gitea.ts", "github.ts", "gitlab.ts"] as const;
/** Die Zeile ueber dem Methoden-Argument nennt den Endpunkt. */
function schreibaufrufe(quelltext: string): string[] {
const zeilen = quelltext.split("\n");
const gefunden: string[] = [];
zeilen.forEach((zeile, i) => {
if (!/\bsendJson[<(]/.test(zeile)) return;
// sendJson(url, METHODE, …) – Endpunkt und Methode stehen darunter.
const block = zeilen.slice(i, i + 4).join(" ");
const methode = block.match(/"(POST|PATCH|PUT)"/)?.[1];
const pfad = block.match(/\/issues[^`"']*/)?.[0] ?? "?";
gefunden.push(`${methode ?? "?"} ${pfad}`);
});
return gefunden;
}
describe("Schreibzugriffe nach draussen", () => {
it("beschraenken sich auf Issues anlegen und Zustand setzen", () => {
for (const datei of CLIENTS) {
const quelltext = readFileSync(join(__dirname, datei), "utf8");
const aufrufe = schreibaufrufe(quelltext);
expect(aufrufe.length, `${datei}: Anzahl Schreibaufrufe`).toBe(2);
for (const aufruf of aufrufe) {
expect(aufruf, `${datei}: ${aufruf}`).toMatch(/^(POST|PATCH|PUT) \/issues/);
}
// Genau einer legt an, genau einer setzt um.
expect(aufrufe.filter((a) => a.startsWith("POST")).length, datei).toBe(1);
expect(aufrufe.filter((a) => /^(PATCH|PUT)/.test(a)).length, datei).toBe(1);
}
});
it("laufen ueber sendJson, nicht an http.ts vorbei", () => {
// Ein direkter fetch in einem Client haette weder SSRF-Schutz noch
// Zeitbegrenzung – und wuerde am Waechter oben vorbeilaufen.
for (const datei of CLIENTS) {
const quelltext = readFileSync(join(__dirname, datei), "utf8");
expect(quelltext, datei).not.toMatch(/\bfetch\s*\(/);
}
});
});
+9
View File
@@ -99,6 +99,15 @@ export interface RepoSnapshotData extends RepoOverview {
ci: CiInfo | null; ci: CiInfo | null;
/** Commits je Tag der letzten 30 Tage, aeltester zuerst. */ /** Commits je Tag der letzten 30 Tage, aeltester zuerst. */
commitActivity: number[]; commitActivity: number[];
/**
* Teil-Abrufe, die nicht geklappt haben: Feldname -> Grund mit HTTP-Status.
* Leer heisst, dass alle Listen wirklich geholt wurden.
*
* Der Unterschied ist wesentlich: eine leere Liste in `tags` bedeutet mit
* einem Eintrag hier "nicht geholt" und ohne ihn "es gibt keine Tags".
* Nur im ersten Fall darf der gespeicherte Stand stehen bleiben.
*/
missing: Record<string, string>;
} }
/** Tage, ueber die die Commit-Aktivitaet gebildet wird. */ /** Tage, ueber die die Commit-Aktivitaet gebildet wird. */
+116 -31
View File
@@ -12,6 +12,15 @@ export interface SyncResult {
ok: boolean; ok: boolean;
message: string; message: string;
fetchedAt?: string; fetchedAt?: string;
/**
* Der Abgleich lief durch, aber einzelne Listen kamen nicht an.
*
* Getrennt von `ok`, weil beides verschiedene Folgen hat: `ok: false`
* setzt syncError, zaehlt Fehlversuche und haelt das Repository beim
* naechsten Durchlauf zurueck. Ein unvollstaendiger Abgleich soll nichts
* davon ausloesen – der Rest ist ja frisch – aber sichtbar sein.
*/
unvollstaendig?: boolean;
} }
/** /**
@@ -19,7 +28,9 @@ export interface SyncResult {
* in die Datenbank. Fehler werden am Repository vermerkt statt geworfen, * in die Datenbank. Fehler werden am Repository vermerkt statt geworfen,
* damit ein kaputtes Repo nicht das ganze Dashboard blockiert. * damit ein kaputtes Repo nicht das ganze Dashboard blockiert.
*/ */
export async function syncRepository(repositoryId: string): Promise<SyncResult> { export async function syncRepository(
repositoryId: string,
): Promise<SyncResult> {
const repository = await prisma.repository.findUnique({ const repository = await prisma.repository.findUnique({
where: { id: repositoryId }, where: { id: repositoryId },
include: { connection: true }, include: { connection: true },
@@ -33,7 +44,8 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
if (!ref) { if (!ref) {
return { return {
ok: false, ok: false,
message: "Kein Repository hinterlegt – bitte Repo-URL oder Owner/Name setzen.", message:
"Kein Repository hinterlegt – bitte Repo-URL oder Owner/Name setzen.",
}; };
} }
@@ -46,7 +58,8 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
if (!parsed) { if (!parsed) {
return { return {
ok: false, ok: false,
message: "Keine Git-Verbindung ausgewaehlt und die Repo-URL ist nicht auswertbar.", message:
"Keine Git-Verbindung ausgewaehlt und die Repo-URL ist nicht auswertbar.",
}; };
} }
client = createClient({ client = createClient({
@@ -81,10 +94,28 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
// Nach dem Speichern, damit die Meldung den neuen Stand sieht. // Nach dem Speichern, damit die Meldung den neuen Stand sieht.
await notifyAfterSync(repository.id); await notifyAfterSync(repository.id);
/**
* Fehlgeschlagene Teile gehoeren in die Meldung.
*
* Vorher stand hier "Snapshot aktualisiert: 0 Branches, 0 Tags" – und das
* war die einzige Spur, wenn saemtliche Listen unterwegs verloren gingen.
* Wer das liest, denkt an ein leeres Repository, nicht an einen Fehler.
*/
// Sortiert, damit die Meldung bei jedem Durchlauf gleich aussieht –
// die Reihenfolge im Objekt haengt sonst daran, welcher Aufruf zuerst
// gescheitert ist.
const fehlend = Object.keys(data.missing).sort();
const zahlen = fehlend.length
? `Teilweise aktualisiert – nicht geholt: ${fehlend.join(", ")}. ` +
`Der vorherige Stand bleibt dafuer stehen.`
: `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`;
return { return {
ok: true, ok: true,
unvollstaendig: fehlend.length > 0,
message: message:
`Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.` + zahlen +
(abgehakt > 0 (abgehakt > 0
? ` ${abgehakt} Aufgabe(n) abgehakt, weil das Issue geschlossen wurde.` ? ` ${abgehakt} Aufgabe(n) abgehakt, weil das Issue geschlossen wurde.`
: ""), : ""),
@@ -127,7 +158,9 @@ export interface ProjectSyncResult extends SyncResult {
* beim selben Anbieter, und ein Schwung gleichzeitiger Anfragen ist genau * beim selben Anbieter, und ein Schwung gleichzeitiger Anfragen ist genau
* das, worauf Rate-Limits reagieren. * das, worauf Rate-Limits reagieren.
*/ */
export async function syncProject(projectId: string): Promise<ProjectSyncResult> { export async function syncProject(
projectId: string,
): Promise<ProjectSyncResult> {
const repositories = await prisma.repository.findMany({ const repositories = await prisma.repository.findMany({
where: { projectId }, where: { projectId },
orderBy: [{ position: "asc" }, { createdAt: "asc" }], orderBy: [{ position: "asc" }, { createdAt: "asc" }],
@@ -214,7 +247,10 @@ async function maybeRefreshCalendar(
// Rate-Limit, Zeitueberschreitung, Ausfall: der Kalender ist Beiwerk und // Rate-Limit, Zeitueberschreitung, Ausfall: der Kalender ist Beiwerk und
// soll den Abgleich nicht scheitern lassen. Ohne Zeitstempel versucht es // soll den Abgleich nicht scheitern lassen. Ohne Zeitstempel versucht es
// der naechste Durchgang von selbst wieder. // der naechste Durchgang von selbst wieder.
console.error("[nebula] Commit-Kalender konnte nicht geholt werden:", error); console.error(
"[nebula] Commit-Kalender konnte nicht geholt werden:",
error,
);
} }
} }
@@ -294,7 +330,9 @@ async function noteNewCommits(
// Zahl als mit einer falschen. // Zahl als mit einer falschen.
const index = data.commits.findIndex((commit) => commit.sha === vorher); const index = data.commits.findIndex((commit) => commit.sha === vorher);
const anzahl = index > 0 ? index : null; const anzahl = index > 0 ? index : null;
const wo = data.defaultBranch ? `${repoTitle(repository)} · ${data.defaultBranch}` : repoTitle(repository); const wo = data.defaultBranch
? `${repoTitle(repository)} · ${data.defaultBranch}`
: repoTitle(repository);
const betreff = shorten(data.commits[0]?.message ?? "", 60); const betreff = shorten(data.commits[0]?.message ?? "", 60);
await recordActivity({ await recordActivity({
@@ -304,16 +342,38 @@ async function noteNewCommits(
anzahl === null anzahl === null
? `Neue Commits in ${wo}: ${betreff}` ? `Neue Commits in ${wo}: ${betreff}`
: `${anzahl} neue${anzahl === 1 ? "r" : ""} Commit${anzahl === 1 ? "" : "s"} in ${wo}: ${betreff}`, : `${anzahl} neue${anzahl === 1 ? "r" : ""} Commit${anzahl === 1 ? "" : "s"} in ${wo}: ${betreff}`,
meta: { repositoryId: repository.id, from: vorher, to: neuester, count: anzahl }, meta: {
repositoryId: repository.id,
from: vorher,
to: neuester,
count: anzahl,
},
}); });
} }
async function persistSnapshot(repository: Repository, data: RepoSnapshotData) { async function persistSnapshot(repository: Repository, data: RepoSnapshotData) {
const latestCommit = data.commits[0] ?? null; const latestCommit = data.commits[0] ?? null;
const latestRelease = const latestRelease =
data.releases.find((release) => !release.draft && !release.prerelease) ?? data.releases[0] ?? null; data.releases.find((release) => !release.draft && !release.prerelease) ??
data.releases[0] ??
null;
const latestTag = data.tags[0]?.name ?? latestRelease?.tag ?? null; const latestTag = data.tags[0]?.name ?? latestRelease?.tag ?? null;
/**
* Ein Teil, der nicht geholt werden konnte, bleibt so, wie er war.
*
* Vorher wurde bedingungslos ueberschrieben. Kam eine Liste leer zurueck,
* weil der Abruf gescheitert war, ersetzte sie den letzten guten Stand –
* ein Klick auf "Jetzt synchronisieren" genuegte, und die Commits eines
* Repositories waren weg. Der Snapshot ist ein Zwischenspeicher: ein
* alter Stand ist immer brauchbarer als eine leere Liste, die aussieht
* wie eine Aussage.
*/
const geholt = <T>(feld: string, wert: T): T | undefined =>
feld in data.missing ? undefined : wert;
const vollstaendig = Object.keys(data.missing).length === 0;
const payload = { const payload = {
fullName: data.fullName, fullName: data.fullName,
description: data.description, description: data.description,
@@ -325,30 +385,55 @@ async function persistSnapshot(repository: Repository, data: RepoSnapshotData) {
openIssues: data.openIssues, openIssues: data.openIssues,
openPullRequests: data.openPullRequests, openPullRequests: data.openPullRequests,
sizeKb: data.sizeKb, sizeKb: data.sizeKb,
lastCommitSha: latestCommit?.sha ?? null, // Die Commit-Felder haengen alle an derselben Liste – entweder alle
lastCommitMessage: latestCommit?.message ?? null, // neu oder alle unveraendert. Halb aktualisiert waeren sie in sich
lastCommitAuthor: latestCommit?.author ?? null, // widerspruechlich.
lastCommitAt: latestCommit?.date ? new Date(latestCommit.date) : null, lastCommitSha: geholt("commits", latestCommit?.sha ?? null),
latestTag, lastCommitMessage: geholt("commits", latestCommit?.message ?? null),
latestRelease: latestRelease?.name ?? null, lastCommitAuthor: geholt("commits", latestCommit?.author ?? null),
latestReleaseAt: latestRelease?.publishedAt ? new Date(latestRelease.publishedAt) : null, lastCommitAt: geholt(
branchCount: data.branches.length, "commits",
tagCount: data.tags.length, latestCommit?.date ? new Date(latestCommit.date) : null,
branches: data.branches as unknown as object, ),
tags: data.tags as unknown as object, // latestTag faellt auf den Release-Tag zurueck; steht einer der beiden
commits: data.commits as unknown as object, // Abrufe aus, ist die Ableitung nicht mehr belastbar.
releases: data.releases as unknown as object, latestTag:
languages: data.languages as unknown as object, "tags" in data.missing || "releases" in data.missing
pullRequests: data.pullRequests as unknown as object, ? undefined
issues: data.issues as unknown as object, : latestTag,
commitActivity: data.commitActivity as unknown as object, latestRelease: geholt("releases", latestRelease?.name ?? null),
ciState: data.ci?.state ?? null, latestReleaseAt: geholt(
ciName: data.ci?.name ?? null, "releases",
ciBranch: data.ci?.branch ?? null, latestRelease?.publishedAt ? new Date(latestRelease.publishedAt) : null,
ciUrl: data.ci?.url ?? null, ),
ciFinishedAt: data.ci?.finishedAt ? new Date(data.ci.finishedAt) : null, branchCount: geholt("branches", data.branches.length),
tagCount: geholt("tags", data.tags.length),
branches: geholt("branches", data.branches as unknown as object),
tags: geholt("tags", data.tags as unknown as object),
commits: geholt("commits", data.commits as unknown as object),
releases: geholt("releases", data.releases as unknown as object),
languages: geholt("languages", data.languages as unknown as object),
pullRequests: geholt(
"pullRequests",
data.pullRequests as unknown as object,
),
issues: geholt("issues", data.issues as unknown as object),
// Die Kurve wird aus den Commits gerechnet – ohne sie waere sie leer.
commitActivity: geholt("commits", data.commitActivity as unknown as object),
ciState: geholt("ci", data.ci?.state ?? null),
ciName: geholt("ci", data.ci?.name ?? null),
ciBranch: geholt("ci", data.ci?.branch ?? null),
ciUrl: geholt("ci", data.ci?.url ?? null),
ciFinishedAt: geholt(
"ci",
data.ci?.finishedAt ? new Date(data.ci.finishedAt) : null,
),
pushedAt: data.pushedAt ? new Date(data.pushedAt) : null, pushedAt: data.pushedAt ? new Date(data.pushedAt) : null,
missing: data.missing as unknown as object,
fetchedAt: new Date(), fetchedAt: new Date(),
// Nur setzen, wenn wirklich alles ankam. Sonst bleibt der Zeitpunkt
// stehen, an dem der Stand zuletzt vollstaendig war.
...(vollstaendig ? { completeAt: new Date() } : {}),
}; };
// Vor dem Ueberschreiben lesen – danach ist der alte Stand weg. // Vor dem Ueberschreiben lesen – danach ist der alte Stand weg.
+46
View File
@@ -7,6 +7,7 @@ import {
dueBucket, dueBucket,
nextDueDate, nextDueDate,
progressFromTasks, progressFromTasks,
spaltenAnzeige,
} from "./tasks"; } from "./tasks";
describe("progressFromTasks", () => { describe("progressFromTasks", () => {
@@ -136,3 +137,48 @@ describe("dueBucket", () => {
} }
}); });
}); });
describe("spaltenAnzeige", () => {
it("zeigt alles, solange die Spalte unter der Grenze bleibt", () => {
expect(spaltenAnzeige(3, 15, false)).toEqual({ sichtbar: 3, verborgen: 0 });
// Genau auf der Grenze ist noch nichts zu verbergen – sonst stuende
// "0 weitere anzeigen" an der Spalte.
expect(spaltenAnzeige(15, 15, false)).toEqual({ sichtbar: 15, verborgen: 0 });
});
it("kappt darueber und sagt, wie viele dahinter warten", () => {
expect(spaltenAnzeige(40, 15, false)).toEqual({ sichtbar: 15, verborgen: 25 });
});
it("nimmt 0 als 'ohne Begrenzung'", () => {
// Dieselbe Lesart wie SESSION_IDLE_HOURS. Waere 0 woertlich gemeint,
// zeigte das Brett gar nichts mehr.
expect(spaltenAnzeige(200, 0, false)).toEqual({ sichtbar: 200, verborgen: 0 });
});
it("behandelt eine negative Grenze wie keine", () => {
// Die API laesst sie nicht durch; ein alter Datenbankstand oder ein
// Handgriff in psql koennte sie aber liefern.
expect(spaltenAnzeige(9, -5, false)).toEqual({ sichtbar: 9, verborgen: 0 });
});
it("zeigt entfaltet wieder alles", () => {
expect(spaltenAnzeige(40, 15, true)).toEqual({ sichtbar: 40, verborgen: 0 });
});
it("meldet nie mehr Sichtbares als vorhanden", () => {
// Sonst liefe das slice() in der Spalte ins Leere und die Zahl am Knopf
// waere negativ.
for (const anzahl of [0, 1, 7, 15, 16, 99]) {
for (const limit of [0, 1, 15, 100]) {
for (const entfaltet of [true, false]) {
const { sichtbar, verborgen } = spaltenAnzeige(anzahl, limit, entfaltet);
expect(sichtbar, `${anzahl}/${limit}/${entfaltet}`).toBeLessThanOrEqual(anzahl);
expect(verborgen).toBeGreaterThanOrEqual(0);
// Zusammen ergeben sie immer die ganze Spalte – nichts faellt weg.
expect(sichtbar + verborgen).toBe(anzahl);
}
}
}
});
});
+23
View File
@@ -168,6 +168,29 @@ export async function spawnRecurrence(taskId: string) {
* nicht die Spalte, sondern der Termin: projektuebergreifend ist "was ist * nicht die Spalte, sondern der Termin: projektuebergreifend ist "was ist
* ueberfaellig" die Frage, nicht "was steht in welcher Spalte". * ueberfaellig" die Frage, nicht "was steht in welcher Spalte".
*/ */
/**
* Wie viele Karten eine Spalte des Aufgabenbretts zeigt – und wie viele
* dahinter warten.
*
* Begrenzt wird ausschliesslich die *Anzeige*. Der Zustand im Brett bleibt
* vollstaendig, weil das Umsortieren je Spalte die ganze Liste an den Server
* schickt: waere sie hier schon gekuerzt, wuerden die verborgenen Aufgaben
* beim naechsten Ziehen aus dem Brett fallen.
*
* `limit` 0 heisst "ohne Begrenzung" – dieselbe Lesart wie bei
* SESSION_IDLE_HOURS.
*/
export function spaltenAnzeige(
anzahl: number,
limit: number,
entfaltet: boolean,
): { sichtbar: number; verborgen: number } {
if (limit <= 0 || entfaltet || anzahl <= limit) {
return { sichtbar: anzahl, verborgen: 0 };
}
return { sichtbar: limit, verborgen: anzahl - limit };
}
export const DUE_BUCKET_ORDER = ["overdue", "today", "week", "later", "someday"] as const; export const DUE_BUCKET_ORDER = ["overdue", "today", "week", "later", "someday"] as const;
export type DueBucket = (typeof DUE_BUCKET_ORDER)[number]; export type DueBucket = (typeof DUE_BUCKET_ORDER)[number];
+2
View File
@@ -264,6 +264,8 @@ export const adminSettingsSchema = z.object({
autoSyncIntervalMinutes: z.coerce.number().int().min(5).max(10080).optional(), autoSyncIntervalMinutes: z.coerce.number().int().min(5).max(10080).optional(),
autoSyncActiveOnly: z.boolean().optional(), autoSyncActiveOnly: z.boolean().optional(),
autoSyncBatchSize: z.coerce.number().int().min(1).max(200).optional(), autoSyncBatchSize: z.coerce.number().int().min(1).max(200).optional(),
/** Karten je Spalte im Aufgabenbrett; 0 schaltet die Begrenzung ab. */
taskColumnLimit: z.coerce.number().int().min(0).max(500).optional(),
}); });
export const adminUserCreateSchema = z.object({ export const adminUserCreateSchema = z.object({
+17 -2
View File
@@ -47,7 +47,10 @@ export interface ConnectionWithCount extends ConnectionSafe {
* repositorySafeSelect. Ob einer eingerichtet ist, reicht die Projektseite * repositorySafeSelect. Ob einer eingerichtet ist, reicht die Projektseite
* getrennt durch. * getrennt durch.
*/ */
export interface RepositoryWithSnapshot extends Omit<Repository, "webhookSecret"> { export interface RepositoryWithSnapshot extends Omit<
Repository,
"webhookSecret"
> {
connection: ConnectionSafe | null; connection: ConnectionSafe | null;
snapshot: RepoSnapshot | null; snapshot: RepoSnapshot | null;
} }
@@ -159,7 +162,8 @@ export function asArray<T>(value: unknown): T[] {
export function asCalendar(value: unknown): CommitCalendar | null { export function asCalendar(value: unknown): CommitCalendar | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null; if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const kandidat = value as Partial<CommitCalendar>; const kandidat = value as Partial<CommitCalendar>;
if (typeof kandidat.from !== "string" || typeof kandidat.to !== "string") return null; if (typeof kandidat.from !== "string" || typeof kandidat.to !== "string")
return null;
if (!kandidat.days || typeof kandidat.days !== "object") return null; if (!kandidat.days || typeof kandidat.days !== "object") return null;
return { return {
from: kandidat.from, from: kandidat.from,
@@ -176,3 +180,14 @@ export function asRecord(value: unknown): Record<string, number> {
} }
return {}; return {};
} }
/**
* Wie asRecord, aber fuer Text – etwa `missing` am Snapshot, das je Feld
* den Grund des Fehlschlags traegt.
*/
export function asTextRecord(value: unknown): Record<string, string> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, string>;
}
return {};
}