fix(sync): Teil-Abrufe verschlucken ihre Fehler nicht mehr
CI / pruefen (pull_request) Canceled after 0s
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
This commit is contained in:
@@ -85,6 +85,16 @@ SSRF-Schutz (`lib/security/ssrf.ts`).
|
|||||||
`100vh` beziehen sich weiter auf das volle. `components/ui/Modal.tsx` hängt
|
`100vh` beziehen sich weiter auf das volle. `components/ui/Modal.tsx` hängt
|
||||||
dafür an `window.visualViewport`; wer ein eigenes Overlay baut, braucht
|
dafür an `window.visualViewport`; wer ein eigenes Overlay baut, braucht
|
||||||
dasselbe.
|
dasselbe.
|
||||||
|
- **Ein verschluckter Fehler ist schlimmer als ein lauter.** `getJsonSafe` machte aus
|
||||||
|
jedem Fehlschlag eine leere Liste. Damit war ein 403 nicht mehr von „das Repository
|
||||||
|
hat keine Branches" zu unterscheiden: Der Abgleich meldete Erfolg, überschrieb den
|
||||||
|
letzten guten Stand mit Nullen, und die Ursache war von außen nicht zu ermitteln —
|
||||||
|
die Auskunft, die sie benannt hätte, war ja gerade weggeworfen worden. Wo ein
|
||||||
|
Fallback nötig ist, gehört der Grund festgehalten (`Teilabrufe` in `lib/git/http.ts`).
|
||||||
|
- **`Number()` auf einer Umgebungsvariable prüft nichts.** `Number("")` ist `0`,
|
||||||
|
`Number("abc")` ist `NaN`, und `setTimeout` löst bei beidem sofort aus. Eine
|
||||||
|
verrutschte Zeile in der `.env` konnte so den kompletten Abgleich stilllegen, ohne
|
||||||
|
dass irgendwo etwas stand.
|
||||||
- **Blättern über Cursor, nicht über `skip`.** Der Aktivitätsverlauf schreibt
|
- **Blättern über Cursor, nicht über `skip`.** Der Aktivitätsverlauf schreibt
|
||||||
mehrere Zeilen im selben Augenblick; ein Offset über so eine Grenze hinweg
|
mehrere Zeilen im selben Augenblick; ein Offset über so eine Grenze hinweg
|
||||||
überspringt oder verdoppelt lautlos. Muster in `lib/timeline.ts`:
|
überspringt oder verdoppelt lautlos. Muster in `lib/timeline.ts`:
|
||||||
|
|||||||
+27
@@ -553,6 +553,33 @@ deshalb ohne Risiko.
|
|||||||
| Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung |
|
| Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung |
|
||||||
| Sprachen | Verteilung als Balken mit Prozentwerten |
|
| Sprachen | Verteilung als Balken mit Prozentwerten |
|
||||||
|
|
||||||
|
#### Wenn ein Teil nicht ankommt
|
||||||
|
|
||||||
|
Diese Listen werden gleichzeitig geholt, und einzelne davon können scheitern, während
|
||||||
|
der Rest durchgeht — ein 403 wegen fehlender Scopes, ein Rate-Limit, eine
|
||||||
|
Zeitüberschreitung.
|
||||||
|
|
||||||
|
**Ein 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.10.2 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 |
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ Alle Variablen sind in [`.env.example`](.env.example) dokumentiert.
|
|||||||
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` |
|
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` |
|
||||||
| `POSTGRES_PORT` | Host-Port der Datenbank — wirkt nur mit `docker-compose.dev.yml`, sonst hat die DB keinen | `127.0.0.1:5432` |
|
| `POSTGRES_PORT` | Host-Port der Datenbank — wirkt nur mit `docker-compose.dev.yml`, sonst hat die DB keinen | `127.0.0.1:5432` |
|
||||||
| `ADMINER_PORT` | Host-Port von Adminer (`--profile tools`) | `127.0.0.1:8080` |
|
| `ADMINER_PORT` | Host-Port von Adminer (`--profile tools`) | `127.0.0.1:8080` |
|
||||||
| `GIT_API_TIMEOUT_MS` | Timeout für Git-API-Aufrufe | `15000` |
|
| `GIT_API_TIMEOUT_MS` | Timeout für Git-API-Aufrufe, in Millisekunden. Alles unter 1000 oder Unlesbares wird verworfen (mit Warnung im Log) — sonst bräche jeder Aufruf sofort ab | `15000` |
|
||||||
| `GIT_ALLOW_LOOPBACK` | Git-Instanz auf demselben Rechner erlauben | `false` |
|
| `GIT_ALLOW_LOOPBACK` | Git-Instanz auf demselben Rechner erlauben | `false` |
|
||||||
| `GIT_BLOCK_PRIVATE` | Zugriff ins private LAN sperren | `false` |
|
| `GIT_BLOCK_PRIVATE` | Zugriff ins private LAN sperren | `false` |
|
||||||
| `GIT_ALLOWED_HOSTS` | Strengster Modus: nur diese Hosts erlauben | leer |
|
| `GIT_ALLOWED_HOSTS` | Strengster Modus: nur diese Hosts erlauben | leer |
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "RepoSnapshot" ADD COLUMN "completeAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "missing" JSONB NOT NULL DEFAULT '{}';
|
||||||
@@ -649,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?
|
||||||
}
|
}
|
||||||
|
|||||||
+205
-47
@@ -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(
|
||||||
|
`/api/projects/${project.id}/tasks/from-issues`,
|
||||||
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ repositoryId: repository.id }),
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+92
-22
@@ -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 }>(
|
||||||
|
`${this.apiBase}/user`,
|
||||||
|
{
|
||||||
headers: this.headers(),
|
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,25 +167,65 @@ 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,
|
headers,
|
||||||
}),
|
}),
|
||||||
|
teil.hol<GiteaTag[]>("tags", `${base}/tags?limit=25`, [], { headers }),
|
||||||
|
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||||||
|
teil.hol<GiteaCommit[]>(
|
||||||
|
"commits",
|
||||||
|
`${base}/commits?limit=100&stat=false`,
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
teil.hol<GiteaRelease[]>("releases", `${base}/releases?limit=10`, [], {
|
||||||
|
headers,
|
||||||
|
}),
|
||||||
|
teil.hol<Record<string, number>>(
|
||||||
|
"languages",
|
||||||
|
`${base}/languages`,
|
||||||
|
{},
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
teil.hol<GiteaPull[]>(
|
||||||
|
"pullRequests",
|
||||||
|
`${base}/pulls?state=open&limit=50`,
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// type=issues, sonst kommen bei Gitea die Pull Requests mit.
|
||||||
|
teil.hol<GiteaIssue[]>(
|
||||||
|
"issues",
|
||||||
|
`${base}/issues?state=open&type=issues&limit=50`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
// Gitea meldet CI-Ergebnisse als Commit-Status. Das deckt Gitea
|
// Gitea meldet CI-Ergebnisse als Commit-Status. Das deckt Gitea
|
||||||
// Actions und externe Systeme wie Woodpecker gleichermassen ab –
|
// Actions und externe Systeme wie Woodpecker gleichermassen ab –
|
||||||
// anders als ein reiner Actions-Endpunkt, den aeltere Instanzen
|
// anders als ein reiner Actions-Endpunkt, den aeltere Instanzen
|
||||||
// ohnehin nicht kennen.
|
// ohnehin nicht kennen.
|
||||||
defaultBranch
|
defaultBranch
|
||||||
? getJsonSafe<GiteaCombinedStatus>(
|
? teil.hol<GiteaCombinedStatus>(
|
||||||
|
"ci",
|
||||||
`${base}/commits/${encodeURIComponent(defaultBranch)}/status`,
|
`${base}/commits/${encodeURIComponent(defaultBranch)}/status`,
|
||||||
{} as GiteaCombinedStatus,
|
{} as GiteaCombinedStatus,
|
||||||
{ headers },
|
{ headers },
|
||||||
@@ -186,7 +249,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 +324,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 +366,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;
|
||||||
|
|
||||||
|
|||||||
+118
-29
@@ -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 }>(
|
||||||
|
`${this.apiBase}/user`,
|
||||||
|
{
|
||||||
headers: this.headers(),
|
headers: this.headers(),
|
||||||
});
|
},
|
||||||
return { ok: true, message: `Verbunden als ${user.login}`, user: user.login };
|
);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: `Verbunden als ${user.login}`,
|
||||||
|
user: user.login,
|
||||||
|
};
|
||||||
} catch (error) {
|
} 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,18 +165,56 @@ 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 [
|
||||||
|
branches,
|
||||||
|
tags,
|
||||||
|
commits,
|
||||||
|
releases,
|
||||||
|
languages,
|
||||||
|
pulls,
|
||||||
|
issues,
|
||||||
|
ciRuns,
|
||||||
|
] = await Promise.all([
|
||||||
|
teil.hol<GitHubBranch[]>("branches", `${base}/branches?per_page=50`, [], {
|
||||||
|
headers,
|
||||||
|
}),
|
||||||
|
teil.hol<GitHubTag[]>("tags", `${base}/tags?per_page=25`, [], {
|
||||||
|
headers,
|
||||||
|
}),
|
||||||
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||||||
getJsonSafe<GitHubCommit[]>(`${base}/commits?per_page=100`, [], { headers }),
|
teil.hol<GitHubCommit[]>("commits", `${base}/commits?per_page=100`, [], {
|
||||||
getJsonSafe<GitHubRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
|
headers,
|
||||||
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
}),
|
||||||
getJsonSafe<GitHubPull[]>(`${base}/pulls?state=open&per_page=50`, [], { 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.
|
// /issues liefert bei GitHub auch Pull Requests – die filtern wir raus.
|
||||||
getJsonSafe<GitHubIssue[]>(`${base}/issues?state=open&per_page=50`, [], { headers }),
|
teil.hol<GitHubIssue[]>(
|
||||||
getJsonSafe<{ workflow_runs?: GitHubRun[] }>(
|
"issues",
|
||||||
|
`${base}/issues?state=open&per_page=50`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
teil.hol<{ workflow_runs?: GitHubRun[] }>(
|
||||||
|
"ci",
|
||||||
`${base}/actions/runs?per_page=1&branch=${encodeURIComponent(defaultBranch)}`,
|
`${base}/actions/runs?per_page=1&branch=${encodeURIComponent(defaultBranch)}`,
|
||||||
{},
|
{},
|
||||||
{ headers },
|
{ headers },
|
||||||
@@ -208,7 +282,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 +319,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 +340,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[]>(
|
||||||
|
`${base}/stats/commit_activity`,
|
||||||
|
{
|
||||||
headers: this.headers(),
|
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 +383,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 +422,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":
|
||||||
|
|||||||
+126
-31
@@ -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 };
|
|
||||||
}
|
|
||||||
const user = await getJson<{ username: string; name?: string }>(`${this.apiBase}/user`, {
|
|
||||||
headers: this.headers(),
|
headers: this.headers(),
|
||||||
});
|
});
|
||||||
return { ok: true, message: `Verbunden als ${user.username}`, user: user.username };
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: "Erreichbar (ohne Token – nur oeffentliche Projekte).",
|
||||||
|
user: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const user = await getJson<{ username: string; name?: string }>(
|
||||||
|
`${this.apiBase}/user`,
|
||||||
|
{
|
||||||
|
headers: this.headers(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: `Verbunden als ${user.username}`,
|
||||||
|
user: user.username,
|
||||||
|
};
|
||||||
} catch (error) {
|
} 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,23 +160,69 @@ 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,
|
headers,
|
||||||
}),
|
}),
|
||||||
getJsonSafe<GitLabIssue[]>(`${base}/issues?state=opened&per_page=50`, [], { headers }),
|
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||||||
getJsonSafe<GitLabPipeline[]>(
|
teil.hol<GitLabCommit[]>(
|
||||||
|
"commits",
|
||||||
|
`${base}/repository/commits?per_page=100`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
teil.hol<GitLabRelease[]>(
|
||||||
|
"releases",
|
||||||
|
`${base}/releases?per_page=10`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
teil.hol<Record<string, number>>(
|
||||||
|
"languages",
|
||||||
|
`${base}/languages`,
|
||||||
|
{},
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
teil.hol<GitLabMergeRequest[]>(
|
||||||
|
"pullRequests",
|
||||||
|
`${base}/merge_requests?state=opened&per_page=50`,
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
teil.hol<GitLabIssue[]>(
|
||||||
|
"issues",
|
||||||
|
`${base}/issues?state=opened&per_page=50`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
teil.hol<GitLabPipeline[]>(
|
||||||
|
"ci",
|
||||||
`${base}/pipelines?per_page=1${defaultBranch ? `&ref=${encodeURIComponent(defaultBranch)}` : ""}`,
|
`${base}/pipelines?per_page=1${defaultBranch ? `&ref=${encodeURIComponent(defaultBranch)}` : ""}`,
|
||||||
[],
|
[],
|
||||||
{ headers },
|
{ headers },
|
||||||
@@ -155,7 +234,9 @@ export class GitLabClient implements GitClient {
|
|||||||
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 +321,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 +344,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 +364,27 @@ export class GitLabClient implements GitClient {
|
|||||||
{ headers },
|
{ headers },
|
||||||
);
|
);
|
||||||
const name = pickReadme(
|
const name = pickReadme(
|
||||||
eintraege.filter((eintrag) => eintrag.type === "blob").map((eintrag) => eintrag.name),
|
eintraege
|
||||||
|
.filter((eintrag) => eintrag.type === "blob")
|
||||||
|
.map((eintrag) => eintrag.name),
|
||||||
);
|
);
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
|
|
||||||
const datei = await getJson<{ content?: string; encoding?: string; file_path?: string }>(
|
const datei = await getJson<{
|
||||||
`${base}/repository/files/${encodeURIComponent(name)}?ref=HEAD`,
|
content?: string;
|
||||||
{ headers },
|
encoding?: string;
|
||||||
);
|
file_path?: string;
|
||||||
|
}>(`${base}/repository/files/${encodeURIComponent(name)}?ref=HEAD`, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
if (!datei?.content || datei.encoding !== "base64") return null;
|
if (!datei?.content || datei.encoding !== "base64") return null;
|
||||||
|
|
||||||
const { content, truncated } = capReadme(decodeBase64(datei.content));
|
const { content, truncated } = capReadme(decodeBase64(datei.content));
|
||||||
const projekt = await getJsonSafe<GitLabProject>(base, {} as GitLabProject, { headers });
|
const projekt = await getJsonSafe<GitLabProject>(
|
||||||
|
base,
|
||||||
|
{} as GitLabProject,
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content,
|
content,
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+106
-4
@@ -1,7 +1,37 @@
|
|||||||
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
|
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
|
||||||
import { GitApiError } from "./types";
|
import { GitApiError } from "./types";
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT = Number(process.env.GIT_API_TIMEOUT_MS ?? 15000);
|
const TIMEOUT_VORGABE = 15000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeitlimit je Aufruf.
|
||||||
|
*
|
||||||
|
* Bewusst mit Pruefung: Number("") ist 0 und Number("abc") ist NaN – beides
|
||||||
|
* laesst setTimeout sofort ausloesen, und dann bricht jeder ausgehende
|
||||||
|
* Aufruf ab, bevor er begonnen hat. Eine verrutschte Zeile in der .env
|
||||||
|
* legte damit den ganzen Abgleich still, ohne dass irgendwo etwas stand.
|
||||||
|
*/
|
||||||
|
export function zeitlimit(
|
||||||
|
roh: string | undefined,
|
||||||
|
warnen: (text: string) => void = console.warn,
|
||||||
|
): number {
|
||||||
|
const wert = roh?.trim();
|
||||||
|
if (!wert) return TIMEOUT_VORGABE;
|
||||||
|
|
||||||
|
const zahl = Number(wert);
|
||||||
|
// Untergrenze 1000 ms: darunter ist kein Aufruf ueber das Netz zu
|
||||||
|
// schaffen, und wer das eintraegt, hat sich vertan.
|
||||||
|
if (!Number.isFinite(zahl) || zahl < 1000) {
|
||||||
|
warnen(
|
||||||
|
`[nebula] GIT_API_TIMEOUT_MS="${wert}" ist unbrauchbar (Zahl ab 1000 erwartet). ` +
|
||||||
|
`Es gelten ${TIMEOUT_VORGABE} ms.`,
|
||||||
|
);
|
||||||
|
return TIMEOUT_VORGABE;
|
||||||
|
}
|
||||||
|
return zahl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT = zeitlimit(process.env.GIT_API_TIMEOUT_MS);
|
||||||
|
|
||||||
export interface FetchOptions {
|
export interface FetchOptions {
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
@@ -73,7 +103,11 @@ export async function getJson<T>(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.text().catch(() => "");
|
const body = await response.text().catch(() => "");
|
||||||
throw new GitApiError(friendlyMessage(response.status, url, body), response.status, url);
|
throw new GitApiError(
|
||||||
|
friendlyMessage(response.status, url, body),
|
||||||
|
response.status,
|
||||||
|
url,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
@@ -150,7 +184,11 @@ export async function sendJson<T>(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text().catch(() => "");
|
const text = await response.text().catch(() => "");
|
||||||
throw new GitApiError(friendlyMessage(response.status, url, text), response.status, url);
|
throw new GitApiError(
|
||||||
|
friendlyMessage(response.status, url, text),
|
||||||
|
response.status,
|
||||||
|
url,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
@@ -175,7 +213,14 @@ export async function sendJson<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wie getJson, gibt aber bei jedem Fehler den Fallback zurueck. */
|
/**
|
||||||
|
* Wie getJson, gibt aber bei jedem Fehler den Fallback zurueck.
|
||||||
|
*
|
||||||
|
* Nur fuer Abrufe benutzen, bei denen ein Fehlschlag wirklich nichts
|
||||||
|
* bedeutet. Fuer die Listen eines Snapshots ist das falsch – dort ist
|
||||||
|
* "nicht geholt" etwas anderes als "nichts vorhanden". Dafuer gibt es
|
||||||
|
* Teilabrufe.
|
||||||
|
*/
|
||||||
export async function getJsonSafe<T>(
|
export async function getJsonSafe<T>(
|
||||||
url: string,
|
url: string,
|
||||||
fallback: T,
|
fallback: T,
|
||||||
@@ -189,6 +234,63 @@ export async function getJsonSafe<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 = {};
|
||||||
|
|
||||||
|
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) {
|
||||||
|
this.fehler[feld] = beschreibe(error);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** true, sobald mindestens ein Teil-Abruf gescheitert ist. */
|
||||||
|
get unvollstaendig(): boolean {
|
||||||
|
return Object.keys(this.fehler).length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kurzfassung eines Fehlers, wie sie am Repository angezeigt wird.
|
||||||
|
*
|
||||||
|
* getJson gibt ausschliesslich GitApiError heraus – ein blockiertes Ziel
|
||||||
|
* kommt als 403, eine Zeitueberschreitung als 408, ein nicht erreichter
|
||||||
|
* Host als 0. Der Status ist damit die eigentliche Auskunft, und die
|
||||||
|
* uebrigen Zweige sind nur fuer den Fall da, dass sich das einmal aendert.
|
||||||
|
*/
|
||||||
|
function beschreibe(error: unknown): string {
|
||||||
|
if (error instanceof GitApiError) {
|
||||||
|
return error.status
|
||||||
|
? `HTTP ${error.status}: ${error.message}`
|
||||||
|
: error.message;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) return `${error.name}: ${error.message}`;
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
export function toIso(value: unknown): string | null {
|
export function toIso(value: unknown): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const date = new Date(String(value));
|
const date = new Date(String(value));
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ export interface RepoSnapshotData extends RepoOverview {
|
|||||||
ci: CiInfo | null;
|
ci: CiInfo | null;
|
||||||
/** Commits je Tag der letzten 30 Tage, aeltester zuerst. */
|
/** Commits je Tag der letzten 30 Tage, aeltester zuerst. */
|
||||||
commitActivity: number[];
|
commitActivity: number[];
|
||||||
|
/**
|
||||||
|
* Teil-Abrufe, die nicht geklappt haben: Feldname -> Grund mit HTTP-Status.
|
||||||
|
* Leer heisst, dass alle Listen wirklich geholt wurden.
|
||||||
|
*
|
||||||
|
* Der Unterschied ist wesentlich: eine leere Liste in `tags` bedeutet mit
|
||||||
|
* einem Eintrag hier "nicht geholt" und ohne ihn "es gibt keine Tags".
|
||||||
|
* Nur im ersten Fall darf der gespeicherte Stand stehen bleiben.
|
||||||
|
*/
|
||||||
|
missing: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tage, ueber die die Commit-Aktivitaet gebildet wird. */
|
/** Tage, ueber die die Commit-Aktivitaet gebildet wird. */
|
||||||
|
|||||||
+116
-31
@@ -12,6 +12,15 @@ export interface SyncResult {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
fetchedAt?: string;
|
fetchedAt?: string;
|
||||||
|
/**
|
||||||
|
* Der Abgleich lief durch, aber einzelne Listen kamen nicht an.
|
||||||
|
*
|
||||||
|
* Getrennt von `ok`, weil beides verschiedene Folgen hat: `ok: false`
|
||||||
|
* setzt syncError, zaehlt Fehlversuche und haelt das Repository beim
|
||||||
|
* naechsten Durchlauf zurueck. Ein unvollstaendiger Abgleich soll nichts
|
||||||
|
* davon ausloesen – der Rest ist ja frisch – aber sichtbar sein.
|
||||||
|
*/
|
||||||
|
unvollstaendig?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,7 +28,9 @@ export interface SyncResult {
|
|||||||
* in die Datenbank. Fehler werden am Repository vermerkt statt geworfen,
|
* in die Datenbank. Fehler werden am Repository vermerkt statt geworfen,
|
||||||
* damit ein kaputtes Repo nicht das ganze Dashboard blockiert.
|
* damit ein kaputtes Repo nicht das ganze Dashboard blockiert.
|
||||||
*/
|
*/
|
||||||
export async function syncRepository(repositoryId: string): Promise<SyncResult> {
|
export async function syncRepository(
|
||||||
|
repositoryId: string,
|
||||||
|
): Promise<SyncResult> {
|
||||||
const repository = await prisma.repository.findUnique({
|
const repository = await prisma.repository.findUnique({
|
||||||
where: { id: repositoryId },
|
where: { id: repositoryId },
|
||||||
include: { connection: true },
|
include: { connection: true },
|
||||||
@@ -33,7 +44,8 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
|
|||||||
if (!ref) {
|
if (!ref) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: "Kein Repository hinterlegt – bitte Repo-URL oder Owner/Name setzen.",
|
message:
|
||||||
|
"Kein Repository hinterlegt – bitte Repo-URL oder Owner/Name setzen.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +58,8 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
|
|||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: "Keine Git-Verbindung ausgewaehlt und die Repo-URL ist nicht auswertbar.",
|
message:
|
||||||
|
"Keine Git-Verbindung ausgewaehlt und die Repo-URL ist nicht auswertbar.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
client = createClient({
|
client = createClient({
|
||||||
@@ -81,10 +94,28 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
|
|||||||
|
|
||||||
// Nach dem Speichern, damit die Meldung den neuen Stand sieht.
|
// Nach dem Speichern, damit die Meldung den neuen Stand sieht.
|
||||||
await notifyAfterSync(repository.id);
|
await notifyAfterSync(repository.id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehlgeschlagene Teile gehoeren in die Meldung.
|
||||||
|
*
|
||||||
|
* Vorher stand hier "Snapshot aktualisiert: 0 Branches, 0 Tags" – und das
|
||||||
|
* war die einzige Spur, wenn saemtliche Listen unterwegs verloren gingen.
|
||||||
|
* Wer das liest, denkt an ein leeres Repository, nicht an einen Fehler.
|
||||||
|
*/
|
||||||
|
// Sortiert, damit die Meldung bei jedem Durchlauf gleich aussieht –
|
||||||
|
// die Reihenfolge im Objekt haengt sonst daran, welcher Aufruf zuerst
|
||||||
|
// gescheitert ist.
|
||||||
|
const fehlend = Object.keys(data.missing).sort();
|
||||||
|
const zahlen = fehlend.length
|
||||||
|
? `Teilweise aktualisiert – nicht geholt: ${fehlend.join(", ")}. ` +
|
||||||
|
`Der vorherige Stand bleibt dafuer stehen.`
|
||||||
|
: `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
unvollstaendig: fehlend.length > 0,
|
||||||
message:
|
message:
|
||||||
`Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.` +
|
zahlen +
|
||||||
(abgehakt > 0
|
(abgehakt > 0
|
||||||
? ` ${abgehakt} Aufgabe(n) abgehakt, weil das Issue geschlossen wurde.`
|
? ` ${abgehakt} Aufgabe(n) abgehakt, weil das Issue geschlossen wurde.`
|
||||||
: ""),
|
: ""),
|
||||||
@@ -127,7 +158,9 @@ export interface ProjectSyncResult extends SyncResult {
|
|||||||
* beim selben Anbieter, und ein Schwung gleichzeitiger Anfragen ist genau
|
* beim selben Anbieter, und ein Schwung gleichzeitiger Anfragen ist genau
|
||||||
* das, worauf Rate-Limits reagieren.
|
* das, worauf Rate-Limits reagieren.
|
||||||
*/
|
*/
|
||||||
export async function syncProject(projectId: string): Promise<ProjectSyncResult> {
|
export async function syncProject(
|
||||||
|
projectId: string,
|
||||||
|
): Promise<ProjectSyncResult> {
|
||||||
const repositories = await prisma.repository.findMany({
|
const repositories = await prisma.repository.findMany({
|
||||||
where: { projectId },
|
where: { projectId },
|
||||||
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||||
@@ -214,7 +247,10 @@ async function maybeRefreshCalendar(
|
|||||||
// Rate-Limit, Zeitueberschreitung, Ausfall: der Kalender ist Beiwerk und
|
// Rate-Limit, Zeitueberschreitung, Ausfall: der Kalender ist Beiwerk und
|
||||||
// soll den Abgleich nicht scheitern lassen. Ohne Zeitstempel versucht es
|
// soll den Abgleich nicht scheitern lassen. Ohne Zeitstempel versucht es
|
||||||
// der naechste Durchgang von selbst wieder.
|
// der naechste Durchgang von selbst wieder.
|
||||||
console.error("[nebula] Commit-Kalender konnte nicht geholt werden:", error);
|
console.error(
|
||||||
|
"[nebula] Commit-Kalender konnte nicht geholt werden:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +330,9 @@ async function noteNewCommits(
|
|||||||
// Zahl als mit einer falschen.
|
// Zahl als mit einer falschen.
|
||||||
const index = data.commits.findIndex((commit) => commit.sha === vorher);
|
const index = data.commits.findIndex((commit) => commit.sha === vorher);
|
||||||
const anzahl = index > 0 ? index : null;
|
const anzahl = index > 0 ? index : null;
|
||||||
const wo = data.defaultBranch ? `${repoTitle(repository)} · ${data.defaultBranch}` : repoTitle(repository);
|
const wo = data.defaultBranch
|
||||||
|
? `${repoTitle(repository)} · ${data.defaultBranch}`
|
||||||
|
: repoTitle(repository);
|
||||||
const betreff = shorten(data.commits[0]?.message ?? "", 60);
|
const betreff = shorten(data.commits[0]?.message ?? "", 60);
|
||||||
|
|
||||||
await recordActivity({
|
await recordActivity({
|
||||||
@@ -304,16 +342,38 @@ async function noteNewCommits(
|
|||||||
anzahl === null
|
anzahl === null
|
||||||
? `Neue Commits in ${wo}: ${betreff}`
|
? `Neue Commits in ${wo}: ${betreff}`
|
||||||
: `${anzahl} neue${anzahl === 1 ? "r" : ""} Commit${anzahl === 1 ? "" : "s"} in ${wo}: ${betreff}`,
|
: `${anzahl} neue${anzahl === 1 ? "r" : ""} Commit${anzahl === 1 ? "" : "s"} in ${wo}: ${betreff}`,
|
||||||
meta: { repositoryId: repository.id, from: vorher, to: neuester, count: anzahl },
|
meta: {
|
||||||
|
repositoryId: repository.id,
|
||||||
|
from: vorher,
|
||||||
|
to: neuester,
|
||||||
|
count: anzahl,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistSnapshot(repository: Repository, data: RepoSnapshotData) {
|
async function persistSnapshot(repository: Repository, data: RepoSnapshotData) {
|
||||||
const latestCommit = data.commits[0] ?? null;
|
const latestCommit = data.commits[0] ?? null;
|
||||||
const latestRelease =
|
const latestRelease =
|
||||||
data.releases.find((release) => !release.draft && !release.prerelease) ?? data.releases[0] ?? null;
|
data.releases.find((release) => !release.draft && !release.prerelease) ??
|
||||||
|
data.releases[0] ??
|
||||||
|
null;
|
||||||
const latestTag = data.tags[0]?.name ?? latestRelease?.tag ?? null;
|
const latestTag = data.tags[0]?.name ?? latestRelease?.tag ?? null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Teil, der nicht geholt werden konnte, bleibt so, wie er war.
|
||||||
|
*
|
||||||
|
* Vorher wurde bedingungslos ueberschrieben. Kam eine Liste leer zurueck,
|
||||||
|
* weil der Abruf gescheitert war, ersetzte sie den letzten guten Stand –
|
||||||
|
* ein Klick auf "Jetzt synchronisieren" genuegte, und die Commits eines
|
||||||
|
* Repositories waren weg. Der Snapshot ist ein Zwischenspeicher: ein
|
||||||
|
* alter Stand ist immer brauchbarer als eine leere Liste, die aussieht
|
||||||
|
* wie eine Aussage.
|
||||||
|
*/
|
||||||
|
const geholt = <T>(feld: string, wert: T): T | undefined =>
|
||||||
|
feld in data.missing ? undefined : wert;
|
||||||
|
|
||||||
|
const vollstaendig = Object.keys(data.missing).length === 0;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
fullName: data.fullName,
|
fullName: data.fullName,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
@@ -325,30 +385,55 @@ async function persistSnapshot(repository: Repository, data: RepoSnapshotData) {
|
|||||||
openIssues: data.openIssues,
|
openIssues: data.openIssues,
|
||||||
openPullRequests: data.openPullRequests,
|
openPullRequests: data.openPullRequests,
|
||||||
sizeKb: data.sizeKb,
|
sizeKb: data.sizeKb,
|
||||||
lastCommitSha: latestCommit?.sha ?? null,
|
// Die Commit-Felder haengen alle an derselben Liste – entweder alle
|
||||||
lastCommitMessage: latestCommit?.message ?? null,
|
// neu oder alle unveraendert. Halb aktualisiert waeren sie in sich
|
||||||
lastCommitAuthor: latestCommit?.author ?? null,
|
// widerspruechlich.
|
||||||
lastCommitAt: latestCommit?.date ? new Date(latestCommit.date) : null,
|
lastCommitSha: geholt("commits", latestCommit?.sha ?? null),
|
||||||
latestTag,
|
lastCommitMessage: geholt("commits", latestCommit?.message ?? null),
|
||||||
latestRelease: latestRelease?.name ?? null,
|
lastCommitAuthor: geholt("commits", latestCommit?.author ?? null),
|
||||||
latestReleaseAt: latestRelease?.publishedAt ? new Date(latestRelease.publishedAt) : null,
|
lastCommitAt: geholt(
|
||||||
branchCount: data.branches.length,
|
"commits",
|
||||||
tagCount: data.tags.length,
|
latestCommit?.date ? new Date(latestCommit.date) : null,
|
||||||
branches: data.branches as unknown as object,
|
),
|
||||||
tags: data.tags as unknown as object,
|
// latestTag faellt auf den Release-Tag zurueck; steht einer der beiden
|
||||||
commits: data.commits as unknown as object,
|
// Abrufe aus, ist die Ableitung nicht mehr belastbar.
|
||||||
releases: data.releases as unknown as object,
|
latestTag:
|
||||||
languages: data.languages as unknown as object,
|
"tags" in data.missing || "releases" in data.missing
|
||||||
pullRequests: data.pullRequests as unknown as object,
|
? undefined
|
||||||
issues: data.issues as unknown as object,
|
: latestTag,
|
||||||
commitActivity: data.commitActivity as unknown as object,
|
latestRelease: geholt("releases", latestRelease?.name ?? null),
|
||||||
ciState: data.ci?.state ?? null,
|
latestReleaseAt: geholt(
|
||||||
ciName: data.ci?.name ?? null,
|
"releases",
|
||||||
ciBranch: data.ci?.branch ?? null,
|
latestRelease?.publishedAt ? new Date(latestRelease.publishedAt) : null,
|
||||||
ciUrl: data.ci?.url ?? null,
|
),
|
||||||
ciFinishedAt: data.ci?.finishedAt ? new Date(data.ci.finishedAt) : null,
|
branchCount: geholt("branches", data.branches.length),
|
||||||
|
tagCount: geholt("tags", data.tags.length),
|
||||||
|
branches: geholt("branches", data.branches as unknown as object),
|
||||||
|
tags: geholt("tags", data.tags as unknown as object),
|
||||||
|
commits: geholt("commits", data.commits as unknown as object),
|
||||||
|
releases: geholt("releases", data.releases as unknown as object),
|
||||||
|
languages: geholt("languages", data.languages as unknown as object),
|
||||||
|
pullRequests: geholt(
|
||||||
|
"pullRequests",
|
||||||
|
data.pullRequests as unknown as object,
|
||||||
|
),
|
||||||
|
issues: geholt("issues", data.issues as unknown as object),
|
||||||
|
// Die Kurve wird aus den Commits gerechnet – ohne sie waere sie leer.
|
||||||
|
commitActivity: geholt("commits", data.commitActivity as unknown as object),
|
||||||
|
ciState: geholt("ci", data.ci?.state ?? null),
|
||||||
|
ciName: geholt("ci", data.ci?.name ?? null),
|
||||||
|
ciBranch: geholt("ci", data.ci?.branch ?? null),
|
||||||
|
ciUrl: geholt("ci", data.ci?.url ?? null),
|
||||||
|
ciFinishedAt: geholt(
|
||||||
|
"ci",
|
||||||
|
data.ci?.finishedAt ? new Date(data.ci.finishedAt) : null,
|
||||||
|
),
|
||||||
pushedAt: data.pushedAt ? new Date(data.pushedAt) : null,
|
pushedAt: data.pushedAt ? new Date(data.pushedAt) : null,
|
||||||
|
missing: data.missing as unknown as object,
|
||||||
fetchedAt: new Date(),
|
fetchedAt: new Date(),
|
||||||
|
// Nur setzen, wenn wirklich alles ankam. Sonst bleibt der Zeitpunkt
|
||||||
|
// stehen, an dem der Stand zuletzt vollstaendig war.
|
||||||
|
...(vollstaendig ? { completeAt: new Date() } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Vor dem Ueberschreiben lesen – danach ist der alte Stand weg.
|
// Vor dem Ueberschreiben lesen – danach ist der alte Stand weg.
|
||||||
|
|||||||
+17
-2
@@ -47,7 +47,10 @@ export interface ConnectionWithCount extends ConnectionSafe {
|
|||||||
* repositorySafeSelect. Ob einer eingerichtet ist, reicht die Projektseite
|
* repositorySafeSelect. Ob einer eingerichtet ist, reicht die Projektseite
|
||||||
* getrennt durch.
|
* getrennt durch.
|
||||||
*/
|
*/
|
||||||
export interface RepositoryWithSnapshot extends Omit<Repository, "webhookSecret"> {
|
export interface RepositoryWithSnapshot extends Omit<
|
||||||
|
Repository,
|
||||||
|
"webhookSecret"
|
||||||
|
> {
|
||||||
connection: ConnectionSafe | null;
|
connection: ConnectionSafe | null;
|
||||||
snapshot: RepoSnapshot | null;
|
snapshot: RepoSnapshot | null;
|
||||||
}
|
}
|
||||||
@@ -159,7 +162,8 @@ export function asArray<T>(value: unknown): T[] {
|
|||||||
export function asCalendar(value: unknown): CommitCalendar | null {
|
export function asCalendar(value: unknown): CommitCalendar | null {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||||
const kandidat = value as Partial<CommitCalendar>;
|
const kandidat = value as Partial<CommitCalendar>;
|
||||||
if (typeof kandidat.from !== "string" || typeof kandidat.to !== "string") return null;
|
if (typeof kandidat.from !== "string" || typeof kandidat.to !== "string")
|
||||||
|
return null;
|
||||||
if (!kandidat.days || typeof kandidat.days !== "object") return null;
|
if (!kandidat.days || typeof kandidat.days !== "object") return null;
|
||||||
return {
|
return {
|
||||||
from: kandidat.from,
|
from: kandidat.from,
|
||||||
@@ -176,3 +180,14 @@ export function asRecord(value: unknown): Record<string, number> {
|
|||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie asRecord, aber fuer Text – etwa `missing` am Snapshot, das je Feld
|
||||||
|
* den Grund des Fehlschlags traegt.
|
||||||
|
*/
|
||||||
|
export function asTextRecord(value: unknown): Record<string, string> {
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
return value as Record<string, string>;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user