@@ -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?
|
||||||
}
|
}
|
||||||
|
|||||||
+208
-50
@@ -30,13 +30,14 @@ import { RepositoryDialog } from "@/components/RepositoryDialog";
|
|||||||
import { WebhookDialog } from "@/components/WebhookDialog";
|
import { WebhookDialog } from "@/components/WebhookDialog";
|
||||||
import { Sparkline } from "@/components/Sparkline";
|
import { Sparkline } from "@/components/Sparkline";
|
||||||
import { repoTitle } from "@/lib/git/meta";
|
import { repoTitle } from "@/lib/git/meta";
|
||||||
import { asArray, asCalendar, asRecord } from "@/types";
|
import { asArray, asCalendar, asRecord, asTextRecord } from "@/types";
|
||||||
import type {
|
import type {
|
||||||
BranchInfo,
|
BranchInfo,
|
||||||
CommitInfo,
|
CommitInfo,
|
||||||
ConnectionSafe,
|
ConnectionSafe,
|
||||||
ProjectDetail,
|
ProjectDetail,
|
||||||
ReleaseInfo,
|
ReleaseInfo,
|
||||||
|
RepoSnapshot,
|
||||||
RepositoryWithSnapshot,
|
RepositoryWithSnapshot,
|
||||||
TagInfo,
|
TagInfo,
|
||||||
TicketInfo,
|
TicketInfo,
|
||||||
@@ -69,17 +70,26 @@ export function GitPanel({
|
|||||||
const [tab, setTab] = useState<TabKey>("pulls");
|
const [tab, setTab] = useState<TabKey>("pulls");
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
|
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
const [webhookOpen, setWebhookOpen] = useState(false);
|
const [webhookOpen, setWebhookOpen] = useState(false);
|
||||||
|
|
||||||
const repositories = project.repositories;
|
const repositories = project.repositories;
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(repositories[0]?.id ?? null);
|
const [selectedId, setSelectedId] = useState<string | null>(
|
||||||
|
repositories[0]?.id ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
// Nach dem Entfernen zeigt die Auswahl ins Leere – dann auf das fuehrende
|
// Nach dem Entfernen zeigt die Auswahl ins Leere – dann auf das fuehrende
|
||||||
// zurueckfallen, statt eine leere Ansicht zu zeigen.
|
// zurueckfallen, statt eine leere Ansicht zu zeigen.
|
||||||
const repository = repositories.find((entry) => entry.id === selectedId) ?? repositories[0] ?? null;
|
const repository =
|
||||||
const webhookAktiv = repository ? webhookRepoIds.includes(repository.id) : false;
|
repositories.find((entry) => entry.id === selectedId) ??
|
||||||
|
repositories[0] ??
|
||||||
|
null;
|
||||||
|
const webhookAktiv = repository
|
||||||
|
? webhookRepoIds.includes(repository.id)
|
||||||
|
: false;
|
||||||
|
|
||||||
/** Offene Issues als Aufgaben anlegen – ohne erneuten Abruf beim Provider. */
|
/** Offene Issues als Aufgaben anlegen – ohne erneuten Abruf beim Provider. */
|
||||||
async function importIssues() {
|
async function importIssues() {
|
||||||
@@ -87,13 +97,17 @@ export function GitPanel({
|
|||||||
setImporting(true);
|
setImporting(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/projects/${project.id}/tasks/from-issues`, {
|
const response = await fetch(
|
||||||
method: "POST",
|
`/api/projects/${project.id}/tasks/from-issues`,
|
||||||
headers: { "Content-Type": "application/json" },
|
{
|
||||||
body: JSON.stringify({ repositoryId: repository.id }),
|
method: "POST",
|
||||||
});
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ repositoryId: repository.id }),
|
||||||
|
},
|
||||||
|
);
|
||||||
const body = await response.json().catch(() => ({}));
|
const body = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) throw new Error(body.error ?? "Uebernahme fehlgeschlagen.");
|
if (!response.ok)
|
||||||
|
throw new Error(body.error ?? "Uebernahme fehlgeschlagen.");
|
||||||
setMessage({
|
setMessage({
|
||||||
ok: true,
|
ok: true,
|
||||||
text:
|
text:
|
||||||
@@ -103,7 +117,10 @@ export function GitPanel({
|
|||||||
});
|
});
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setMessage({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
setMessage({
|
||||||
|
ok: false,
|
||||||
|
text: caught instanceof Error ? caught.message : String(caught),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
}
|
}
|
||||||
@@ -130,7 +147,10 @@ export function GitPanel({
|
|||||||
: `/api/projects/${project.id}/sync`;
|
: `/api/projects/${project.id}/sync`;
|
||||||
const response = await fetch(url, { method: "POST" });
|
const response = await fetch(url, { method: "POST" });
|
||||||
const payload = await response.json().catch(() => ({}));
|
const payload = await response.json().catch(() => ({}));
|
||||||
setMessage({ ok: response.ok, text: payload.message ?? "Unbekannte Antwort." });
|
setMessage({
|
||||||
|
ok: response.ok,
|
||||||
|
text: payload.message ?? "Unbekannte Antwort.",
|
||||||
|
});
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setMessage({
|
setMessage({
|
||||||
@@ -150,7 +170,9 @@ export function GitPanel({
|
|||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await fetch(`/api/repositories/${entry.id}`, { method: "DELETE" }).catch(() => undefined);
|
await fetch(`/api/repositories/${entry.id}`, { method: "DELETE" }).catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
setSelectedId(null);
|
setSelectedId(null);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
@@ -160,12 +182,19 @@ export function GitPanel({
|
|||||||
<>
|
<>
|
||||||
<div className="glass px-5 py-10 text-center">
|
<div className="glass px-5 py-10 text-center">
|
||||||
<GitBranch className="mx-auto h-8 w-8 text-slate-600" />
|
<GitBranch className="mx-auto h-8 w-8 text-slate-600" />
|
||||||
<h3 className="mt-3 text-sm font-semibold text-slate-200">Kein Repository verknuepft</h3>
|
<h3 className="mt-3 text-sm font-semibold text-slate-200">
|
||||||
|
Kein Repository verknuepft
|
||||||
|
</h3>
|
||||||
<p className="mx-auto mt-1 max-w-sm text-sm text-slate-500">
|
<p className="mx-auto mt-1 max-w-sm text-sm text-slate-500">
|
||||||
Verknuepfe ein Repository, um Branches, Tags, Commits und Releases hier zu sehen.
|
Verknuepfe ein Repository, um Branches, Tags, Commits und Releases
|
||||||
Ein Projekt darf auch mehrere haben – etwa Frontend und Backend.
|
hier zu sehen. Ein Projekt darf auch mehrere haben – etwa Frontend
|
||||||
|
und Backend.
|
||||||
</p>
|
</p>
|
||||||
<button type="button" className="btn btn-primary mt-4" onClick={() => setAddOpen(true)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary mt-4"
|
||||||
|
onClick={() => setAddOpen(true)}
|
||||||
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Repository verknuepfen
|
Repository verknuepfen
|
||||||
</button>
|
</button>
|
||||||
@@ -180,7 +209,10 @@ export function GitPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const languageTotal = Object.values(languages).reduce((sum, value) => sum + value, 0);
|
const languageTotal = Object.values(languages).reduce(
|
||||||
|
(sum, value) => sum + value,
|
||||||
|
0,
|
||||||
|
);
|
||||||
const topLanguages = Object.entries(languages)
|
const topLanguages = Object.entries(languages)
|
||||||
.sort((a, b) => b[1] - a[1])
|
.sort((a, b) => b[1] - a[1])
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
@@ -190,7 +222,10 @@ export function GitPanel({
|
|||||||
{/* Auswahl, sobald es mehr als eines gibt. Bei einem einzigen waere eine
|
{/* Auswahl, sobald es mehr als eines gibt. Bei einem einzigen waere eine
|
||||||
Leiste mit genau einem Knopf nur Ballast. */}
|
Leiste mit genau einem Knopf nur Ballast. */}
|
||||||
{repositories.length > 1 ? (
|
{repositories.length > 1 ? (
|
||||||
<div className="glass flex flex-wrap items-center gap-1.5 p-2" role="tablist">
|
<div
|
||||||
|
className="glass flex flex-wrap items-center gap-1.5 p-2"
|
||||||
|
role="tablist"
|
||||||
|
>
|
||||||
{repositories.map((entry) => {
|
{repositories.map((entry) => {
|
||||||
const aktiv = entry.id === repository.id;
|
const aktiv = entry.id === repository.id;
|
||||||
return (
|
return (
|
||||||
@@ -256,7 +291,11 @@ export function GitPanel({
|
|||||||
<ExternalLink className="h-3.5 w-3.5" />
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} url={snapshot?.ciUrl} />
|
<CiBadge
|
||||||
|
state={snapshot?.ciState}
|
||||||
|
name={snapshot?.ciName}
|
||||||
|
url={snapshot?.ciUrl}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-slate-500" suppressHydrationWarning>
|
<p className="mt-1 text-xs text-slate-500" suppressHydrationWarning>
|
||||||
{repository.lastSyncedAt
|
{repository.lastSyncedAt
|
||||||
@@ -368,12 +407,22 @@ export function GitPanel({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<UnvollstaendigerStand snapshot={snapshot} />
|
||||||
|
|
||||||
{snapshot ? (
|
{snapshot ? (
|
||||||
<>
|
<>
|
||||||
{/* Kennzahlen */}
|
{/* Kennzahlen */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
<Metric icon={Star} label="Stars" value={formatNumber(snapshot.stars)} />
|
<Metric
|
||||||
<Metric icon={GitMerge} label="Forks" value={formatNumber(snapshot.forks)} />
|
icon={Star}
|
||||||
|
label="Stars"
|
||||||
|
value={formatNumber(snapshot.stars)}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
icon={GitMerge}
|
||||||
|
label="Forks"
|
||||||
|
value={formatNumber(snapshot.forks)}
|
||||||
|
/>
|
||||||
<Metric
|
<Metric
|
||||||
icon={GitPullRequest}
|
icon={GitPullRequest}
|
||||||
label="Offene PRs/MRs"
|
label="Offene PRs/MRs"
|
||||||
@@ -384,11 +433,19 @@ export function GitPanel({
|
|||||||
label="Offene Issues"
|
label="Offene Issues"
|
||||||
value={formatNumber(snapshot.openIssues)}
|
value={formatNumber(snapshot.openIssues)}
|
||||||
/>
|
/>
|
||||||
<Metric icon={GitBranch} label="Branches" value={formatNumber(snapshot.branchCount)} />
|
<Metric
|
||||||
|
icon={GitBranch}
|
||||||
|
label="Branches"
|
||||||
|
value={formatNumber(snapshot.branchCount)}
|
||||||
|
/>
|
||||||
<Metric
|
<Metric
|
||||||
icon={HardDrive}
|
icon={HardDrive}
|
||||||
label="Groesse"
|
label="Groesse"
|
||||||
value={snapshot.sizeKb > 1024 ? `${Math.round(snapshot.sizeKb / 1024)} MB` : `${snapshot.sizeKb} KB`}
|
value={
|
||||||
|
snapshot.sizeKb > 1024
|
||||||
|
? `${Math.round(snapshot.sizeKb / 1024)} MB`
|
||||||
|
: `${snapshot.sizeKb} KB`
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -408,11 +465,15 @@ export function GitPanel({
|
|||||||
{snapshot.lastCommitSha.slice(0, 7)}
|
{snapshot.lastCommitSha.slice(0, 7)}
|
||||||
</span>
|
</span>
|
||||||
<span>{snapshot.lastCommitAuthor ?? "unbekannt"}</span>
|
<span>{snapshot.lastCommitAuthor ?? "unbekannt"}</span>
|
||||||
<span suppressHydrationWarning>{formatRelative(snapshot.lastCommitAt)}</span>
|
<span suppressHydrationWarning>
|
||||||
|
{formatRelative(snapshot.lastCommitAt)}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-2 text-sm text-slate-500">Keine Commit-Daten.</p>
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
Keine Commit-Daten.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -424,8 +485,11 @@ export function GitPanel({
|
|||||||
<span className="font-mono text-2xl text-white">
|
<span className="font-mono text-2xl text-white">
|
||||||
{snapshot.latestTag ?? snapshot.latestRelease ?? "–"}
|
{snapshot.latestTag ?? snapshot.latestRelease ?? "–"}
|
||||||
</span>
|
</span>
|
||||||
{snapshot.latestRelease && snapshot.latestRelease !== snapshot.latestTag ? (
|
{snapshot.latestRelease &&
|
||||||
<span className="text-sm text-slate-400">{snapshot.latestRelease}</span>
|
snapshot.latestRelease !== snapshot.latestTag ? (
|
||||||
|
<span className="text-sm text-slate-400">
|
||||||
|
{snapshot.latestRelease}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
@@ -470,7 +534,8 @@ export function GitPanel({
|
|||||||
title={`${name} – ${Math.round((bytes / languageTotal) * 100)}%`}
|
title={`${name} – ${Math.round((bytes / languageTotal) * 100)}%`}
|
||||||
style={{
|
style={{
|
||||||
width: `${(bytes / languageTotal) * 100}%`,
|
width: `${(bytes / languageTotal) * 100}%`,
|
||||||
backgroundColor: LANGUAGE_COLORS[index % LANGUAGE_COLORS.length],
|
backgroundColor:
|
||||||
|
LANGUAGE_COLORS[index % LANGUAGE_COLORS.length],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -480,7 +545,10 @@ export function GitPanel({
|
|||||||
<span key={name} className="inline-flex items-center gap-1.5">
|
<span key={name} className="inline-flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
className="h-2 w-2 rounded-full"
|
className="h-2 w-2 rounded-full"
|
||||||
style={{ backgroundColor: LANGUAGE_COLORS[index % LANGUAGE_COLORS.length] }}
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
LANGUAGE_COLORS[index % LANGUAGE_COLORS.length],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{name}
|
{name}
|
||||||
<span className="text-slate-600">
|
<span className="text-slate-600">
|
||||||
@@ -537,8 +605,8 @@ export function GitPanel({
|
|||||||
{issues.length > 0 ? (
|
{issues.length > 0 ? (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-white/6 bg-white/2 px-4 py-2">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-white/6 bg-white/2 px-4 py-2">
|
||||||
<p className="text-[11px] text-slate-500">
|
<p className="text-[11px] text-slate-500">
|
||||||
Aus offenen Issues Aufgaben machen. Bereits uebernommene werden
|
Aus offenen Issues Aufgaben machen. Bereits uebernommene
|
||||||
uebersprungen.
|
werden uebersprungen.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -555,7 +623,11 @@ export function GitPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<TicketList tickets={issues} empty="Keine offenen Issues." icon={CircleDot} />
|
<TicketList
|
||||||
|
tickets={issues}
|
||||||
|
empty="Keine offenen Issues."
|
||||||
|
icon={CircleDot}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{tab === "branches" ? <BranchList branches={branches} /> : null}
|
{tab === "branches" ? <BranchList branches={branches} /> : null}
|
||||||
@@ -567,7 +639,8 @@ export function GitPanel({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="glass px-5 py-10 text-center text-sm text-slate-500">
|
<div className="glass px-5 py-10 text-center text-sm text-slate-500">
|
||||||
Noch keine Daten geladen. Mit „Jetzt synchronisieren“ den ersten Snapshot holen.
|
Noch keine Daten geladen. Mit „Jetzt synchronisieren“ den ersten
|
||||||
|
Snapshot holen.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -591,7 +664,9 @@ function Metric({
|
|||||||
<Icon className="h-3 w-3" />
|
<Icon className="h-3 w-3" />
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-lg font-semibold tabular-nums text-slate-100">{value}</p>
|
<p className="mt-1 text-lg font-semibold tabular-nums text-slate-100">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -639,10 +714,15 @@ function TicketList({
|
|||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-slate-500">
|
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-slate-500">
|
||||||
<span className="font-mono text-slate-600">#{ticket.number}</span>
|
<span className="font-mono text-slate-600">
|
||||||
|
#{ticket.number}
|
||||||
|
</span>
|
||||||
<span>{ticket.author ?? "unbekannt"}</span>
|
<span>{ticket.author ?? "unbekannt"}</span>
|
||||||
{ticket.labels.slice(0, 3).map((label) => (
|
{ticket.labels.slice(0, 3).map((label) => (
|
||||||
<span key={label} className="rounded bg-white/6 px-1 py-0.5 text-[10px]">
|
<span
|
||||||
|
key={label}
|
||||||
|
className="rounded bg-white/6 px-1 py-0.5 text-[10px]"
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -650,7 +730,9 @@ function TicketList({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
|
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
|
||||||
<span suppressHydrationWarning>{formatRelative(ticket.createdAt)}</span>
|
<span suppressHydrationWarning>
|
||||||
|
{formatRelative(ticket.createdAt)}
|
||||||
|
</span>
|
||||||
{ticket.url ? (
|
{ticket.url ? (
|
||||||
<a
|
<a
|
||||||
href={ticket.url}
|
href={ticket.url}
|
||||||
@@ -677,7 +759,9 @@ function BranchList({ branches }: { branches: BranchInfo[] }) {
|
|||||||
<Row key={branch.name}>
|
<Row key={branch.name}>
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<GitBranch className="h-3.5 w-3.5 shrink-0 text-slate-600" />
|
<GitBranch className="h-3.5 w-3.5 shrink-0 text-slate-600" />
|
||||||
<span className="truncate font-mono text-xs text-slate-200">{branch.name}</span>
|
<span className="truncate font-mono text-xs text-slate-200">
|
||||||
|
{branch.name}
|
||||||
|
</span>
|
||||||
{branch.isDefault ? (
|
{branch.isDefault ? (
|
||||||
<span className="shrink-0 rounded bg-violet-500/15 px-1.5 py-0.5 text-[10px] text-violet-200">
|
<span className="shrink-0 rounded bg-violet-500/15 px-1.5 py-0.5 text-[10px] text-violet-200">
|
||||||
default
|
default
|
||||||
@@ -689,10 +773,14 @@ function BranchList({ branches }: { branches: BranchInfo[] }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500">
|
<div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500">
|
||||||
{branch.lastCommitAt ? (
|
{branch.lastCommitAt ? (
|
||||||
<span suppressHydrationWarning>{formatRelative(branch.lastCommitAt)}</span>
|
<span suppressHydrationWarning>
|
||||||
|
{formatRelative(branch.lastCommitAt)}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{branch.sha ? (
|
{branch.sha ? (
|
||||||
<span className="font-mono text-slate-600">{branch.sha.slice(0, 7)}</span>
|
<span className="font-mono text-slate-600">
|
||||||
|
{branch.sha.slice(0, 7)}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -709,15 +797,23 @@ function TagList({ tags }: { tags: TagInfo[] }) {
|
|||||||
<Row key={tag.name}>
|
<Row key={tag.name}>
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<Tag className="h-3.5 w-3.5 shrink-0 text-slate-600" />
|
<Tag className="h-3.5 w-3.5 shrink-0 text-slate-600" />
|
||||||
<span className="truncate font-mono text-xs text-slate-200">{tag.name}</span>
|
<span className="truncate font-mono text-xs text-slate-200">
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
{tag.message ? (
|
{tag.message ? (
|
||||||
<span className="truncate text-[11px] text-slate-500">{tag.message}</span>
|
<span className="truncate text-[11px] text-slate-500">
|
||||||
|
{tag.message}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500">
|
<div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500">
|
||||||
{tag.date ? <span suppressHydrationWarning>{formatRelative(tag.date)}</span> : null}
|
{tag.date ? (
|
||||||
|
<span suppressHydrationWarning>{formatRelative(tag.date)}</span>
|
||||||
|
) : null}
|
||||||
{tag.sha ? (
|
{tag.sha ? (
|
||||||
<span className="font-mono text-slate-600">{tag.sha.slice(0, 7)}</span>
|
<span className="font-mono text-slate-600">
|
||||||
|
{tag.sha.slice(0, 7)}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -735,7 +831,9 @@ function CommitList({ commits }: { commits: CommitInfo[] }) {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-xs text-slate-200">{commit.message}</p>
|
<p className="truncate text-xs text-slate-200">{commit.message}</p>
|
||||||
<p className="mt-0.5 flex items-center gap-2 text-[11px] text-slate-500">
|
<p className="mt-0.5 flex items-center gap-2 text-[11px] text-slate-500">
|
||||||
<span className="font-mono text-violet-300/80">{commit.shortSha}</span>
|
<span className="font-mono text-violet-300/80">
|
||||||
|
{commit.shortSha}
|
||||||
|
</span>
|
||||||
<span>{commit.author ?? "unbekannt"}</span>
|
<span>{commit.author ?? "unbekannt"}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -760,14 +858,17 @@ function CommitList({ commits }: { commits: CommitInfo[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ReleaseList({ releases }: { releases: ReleaseInfo[] }) {
|
function ReleaseList({ releases }: { releases: ReleaseInfo[] }) {
|
||||||
if (releases.length === 0) return <Empty text="Keine Releases veroeffentlicht." />;
|
if (releases.length === 0)
|
||||||
|
return <Empty text="Keine Releases veroeffentlicht." />;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{releases.map((release) => (
|
{releases.map((release) => (
|
||||||
<Row key={`${release.tag}-${release.name}`}>
|
<Row key={`${release.tag}-${release.name}`}>
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<Package className="h-3.5 w-3.5 shrink-0 text-slate-600" />
|
<Package className="h-3.5 w-3.5 shrink-0 text-slate-600" />
|
||||||
<span className="truncate text-xs text-slate-200">{release.name}</span>
|
<span className="truncate text-xs text-slate-200">
|
||||||
|
{release.name}
|
||||||
|
</span>
|
||||||
<span className="shrink-0 rounded bg-white/6 px-1.5 py-0.5 font-mono text-[10px] text-slate-400">
|
<span className="shrink-0 rounded bg-white/6 px-1.5 py-0.5 font-mono text-[10px] text-slate-400">
|
||||||
{release.tag}
|
{release.tag}
|
||||||
</span>
|
</span>
|
||||||
@@ -783,7 +884,9 @@ function ReleaseList({ releases }: { releases: ReleaseInfo[] }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
|
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
|
||||||
<span suppressHydrationWarning>{formatRelative(release.publishedAt)}</span>
|
<span suppressHydrationWarning>
|
||||||
|
{formatRelative(release.publishedAt)}
|
||||||
|
</span>
|
||||||
{release.url ? (
|
{release.url ? (
|
||||||
<a
|
<a
|
||||||
href={release.url}
|
href={release.url}
|
||||||
@@ -801,3 +904,58 @@ function ReleaseList({ releases }: { releases: ReleaseInfo[] }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teile, die beim letzten Abgleich nicht ankamen.
|
||||||
|
*
|
||||||
|
* Bernstein statt Rot: der Abgleich ist nicht gescheitert, es fehlt nur
|
||||||
|
* etwas – und was danebensteht, ist der letzte gute Stand, keine Null.
|
||||||
|
*
|
||||||
|
* Bis v1.10.1 sah eine nicht geholte Liste genauso aus wie eine leere. Ein
|
||||||
|
* Repository mit 65 Commits meldete "Snapshot aktualisiert: 0 Branches,
|
||||||
|
* 0 Tags", und aus der Oberflaeche war nicht zu erkennen, dass ueberhaupt
|
||||||
|
* etwas schiefgegangen war.
|
||||||
|
*/
|
||||||
|
function UnvollstaendigerStand({
|
||||||
|
snapshot,
|
||||||
|
}: {
|
||||||
|
snapshot: RepoSnapshot | null;
|
||||||
|
}) {
|
||||||
|
if (!snapshot) return null;
|
||||||
|
|
||||||
|
const fehlend = asTextRecord(snapshot.missing);
|
||||||
|
// Sortiert: die Reihenfolge im Objekt haengt daran, welcher Aufruf zuerst
|
||||||
|
// gescheitert ist, und das waere bei jedem Abgleich eine andere.
|
||||||
|
const felder = Object.keys(fehlend).sort();
|
||||||
|
if (felder.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-sm text-amber-100">
|
||||||
|
<p className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Beim letzten Abgleich nicht geholt:{" "}
|
||||||
|
<strong>{felder.join(", ")}</strong>. Dafuer steht hier weiterhin der
|
||||||
|
vorherige Stand
|
||||||
|
{snapshot.completeAt ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
von{" "}
|
||||||
|
<span suppressHydrationWarning>
|
||||||
|
{formatDateTime(snapshot.completeAt)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 space-y-0.5 pl-6 text-[11px] text-amber-200/90">
|
||||||
|
{felder.map((feld) => (
|
||||||
|
<li key={feld}>
|
||||||
|
<span className="font-mono">{feld}</span> — {fehlend[feld]}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+105
-35
@@ -1,4 +1,12 @@
|
|||||||
import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http";
|
import {
|
||||||
|
Teilabrufe,
|
||||||
|
getJson,
|
||||||
|
getJsonSafe,
|
||||||
|
normalizeBaseUrl,
|
||||||
|
sendJson,
|
||||||
|
shortSha,
|
||||||
|
toIso,
|
||||||
|
} from "./http";
|
||||||
import {
|
import {
|
||||||
CALENDAR_PAGE_SIZE,
|
CALENDAR_PAGE_SIZE,
|
||||||
calendarByPaging,
|
calendarByPaging,
|
||||||
@@ -62,7 +70,11 @@ export class GiteaClient implements GitClient {
|
|||||||
return { number: angelegt.number, url: angelegt.html_url ?? null };
|
return { number: angelegt.number, url: angelegt.html_url ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> {
|
async setIssueState(
|
||||||
|
ref: RepoRef,
|
||||||
|
number: number,
|
||||||
|
open: boolean,
|
||||||
|
): Promise<void> {
|
||||||
await sendJson(
|
await sendJson(
|
||||||
`${this.repoBase(ref)}/issues/${number}`,
|
`${this.repoBase(ref)}/issues/${number}`,
|
||||||
"PATCH",
|
"PATCH",
|
||||||
@@ -71,7 +83,10 @@ export class GiteaClient implements GitClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
|
async getIssueState(
|
||||||
|
ref: RepoRef,
|
||||||
|
number: number,
|
||||||
|
): Promise<"open" | "closed" | null> {
|
||||||
const issue = await getJson<{ state?: string } | null>(
|
const issue = await getJson<{ state?: string } | null>(
|
||||||
`${this.repoBase(ref)}/issues/${number}`,
|
`${this.repoBase(ref)}/issues/${number}`,
|
||||||
{ headers: this.headers(), allow404: true },
|
{ headers: this.headers(), allow404: true },
|
||||||
@@ -97,9 +112,12 @@ export class GiteaClient implements GitClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getJson<{ login: string; full_name?: string }>(`${this.apiBase}/user`, {
|
const user = await getJson<{ login: string; full_name?: string }>(
|
||||||
headers: this.headers(),
|
`${this.apiBase}/user`,
|
||||||
});
|
{
|
||||||
|
headers: this.headers(),
|
||||||
|
},
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
message: `Verbunden als ${user.login}${version.version ? ` (Gitea ${version.version})` : ""}`,
|
message: `Verbunden als ${user.login}${version.version ? ` (Gitea ${version.version})` : ""}`,
|
||||||
@@ -107,7 +125,10 @@ export class GiteaClient implements GitClient {
|
|||||||
version: version.version ?? null,
|
version: version.version ?? null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +144,9 @@ export class GiteaClient implements GitClient {
|
|||||||
const needle = query?.toLowerCase().trim();
|
const needle = query?.toLowerCase().trim();
|
||||||
|
|
||||||
return repos
|
return repos
|
||||||
.filter((repo) => !needle || repo.full_name.toLowerCase().includes(needle))
|
.filter(
|
||||||
|
(repo) => !needle || repo.full_name.toLowerCase().includes(needle),
|
||||||
|
)
|
||||||
.map((repo) => ({
|
.map((repo) => ({
|
||||||
fullPath: repo.full_name,
|
fullPath: repo.full_name,
|
||||||
owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "",
|
owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "",
|
||||||
@@ -144,31 +167,71 @@ export class GiteaClient implements GitClient {
|
|||||||
|
|
||||||
const defaultBranch = repo.default_branch ?? "";
|
const defaultBranch = repo.default_branch ?? "";
|
||||||
|
|
||||||
const [branches, tags, commits, releases, languages, pulls, issues, status] =
|
// Sammelt, welche Liste nicht geholt werden konnte und warum. Frueher
|
||||||
await Promise.all([
|
// stand hier getJsonSafe, das jeden Fehler in eine leere Liste verwandelt
|
||||||
getJsonSafe<GiteaBranch[]>(`${base}/branches?limit=50`, [], { headers }),
|
// hat – siehe Teilabrufe.
|
||||||
getJsonSafe<GiteaTag[]>(`${base}/tags?limit=25`, [], { headers }),
|
const teil = new Teilabrufe();
|
||||||
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
|
||||||
getJsonSafe<GiteaCommit[]>(`${base}/commits?limit=100&stat=false`, [], { headers }),
|
const [
|
||||||
getJsonSafe<GiteaRelease[]>(`${base}/releases?limit=10`, [], { headers }),
|
branches,
|
||||||
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
tags,
|
||||||
getJsonSafe<GiteaPull[]>(`${base}/pulls?state=open&limit=50`, [], { headers }),
|
commits,
|
||||||
// type=issues, sonst kommen bei Gitea die Pull Requests mit.
|
releases,
|
||||||
getJsonSafe<GiteaIssue[]>(`${base}/issues?state=open&type=issues&limit=50`, [], {
|
languages,
|
||||||
|
pulls,
|
||||||
|
issues,
|
||||||
|
status,
|
||||||
|
] = await Promise.all([
|
||||||
|
teil.hol<GiteaBranch[]>("branches", `${base}/branches?limit=50`, [], {
|
||||||
|
headers,
|
||||||
|
}),
|
||||||
|
teil.hol<GiteaTag[]>("tags", `${base}/tags?limit=25`, [], { headers }),
|
||||||
|
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||||||
|
teil.hol<GiteaCommit[]>(
|
||||||
|
"commits",
|
||||||
|
`${base}/commits?limit=100&stat=false`,
|
||||||
|
[],
|
||||||
|
{
|
||||||
headers,
|
headers,
|
||||||
}),
|
},
|
||||||
// Gitea meldet CI-Ergebnisse als Commit-Status. Das deckt Gitea
|
),
|
||||||
// Actions und externe Systeme wie Woodpecker gleichermassen ab –
|
teil.hol<GiteaRelease[]>("releases", `${base}/releases?limit=10`, [], {
|
||||||
// anders als ein reiner Actions-Endpunkt, den aeltere Instanzen
|
headers,
|
||||||
// ohnehin nicht kennen.
|
}),
|
||||||
defaultBranch
|
teil.hol<Record<string, number>>(
|
||||||
? getJsonSafe<GiteaCombinedStatus>(
|
"languages",
|
||||||
`${base}/commits/${encodeURIComponent(defaultBranch)}/status`,
|
`${base}/languages`,
|
||||||
{} as GiteaCombinedStatus,
|
{},
|
||||||
{ headers },
|
{ headers },
|
||||||
)
|
),
|
||||||
: Promise.resolve({} as GiteaCombinedStatus),
|
teil.hol<GiteaPull[]>(
|
||||||
]);
|
"pullRequests",
|
||||||
|
`${base}/pulls?state=open&limit=50`,
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// type=issues, sonst kommen bei Gitea die Pull Requests mit.
|
||||||
|
teil.hol<GiteaIssue[]>(
|
||||||
|
"issues",
|
||||||
|
`${base}/issues?state=open&type=issues&limit=50`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
// Gitea meldet CI-Ergebnisse als Commit-Status. Das deckt Gitea
|
||||||
|
// Actions und externe Systeme wie Woodpecker gleichermassen ab –
|
||||||
|
// anders als ein reiner Actions-Endpunkt, den aeltere Instanzen
|
||||||
|
// ohnehin nicht kennen.
|
||||||
|
defaultBranch
|
||||||
|
? teil.hol<GiteaCombinedStatus>(
|
||||||
|
"ci",
|
||||||
|
`${base}/commits/${encodeURIComponent(defaultBranch)}/status`,
|
||||||
|
{} as GiteaCombinedStatus,
|
||||||
|
{ headers },
|
||||||
|
)
|
||||||
|
: Promise.resolve({} as GiteaCombinedStatus),
|
||||||
|
]);
|
||||||
|
|
||||||
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
||||||
name: branch.name,
|
name: branch.name,
|
||||||
@@ -186,7 +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;
|
||||||
|
|
||||||
|
|||||||
+127
-38
@@ -1,4 +1,12 @@
|
|||||||
import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http";
|
import {
|
||||||
|
Teilabrufe,
|
||||||
|
getJson,
|
||||||
|
getJsonSafe,
|
||||||
|
normalizeBaseUrl,
|
||||||
|
sendJson,
|
||||||
|
shortSha,
|
||||||
|
toIso,
|
||||||
|
} from "./http";
|
||||||
import type {
|
import type {
|
||||||
BranchInfo,
|
BranchInfo,
|
||||||
CiInfo,
|
CiInfo,
|
||||||
@@ -31,7 +39,9 @@ export class GitHubClient implements GitClient {
|
|||||||
|
|
||||||
constructor(config: ProviderConfig) {
|
constructor(config: ProviderConfig) {
|
||||||
const base = normalizeBaseUrl(config.baseUrl || "https://github.com");
|
const base = normalizeBaseUrl(config.baseUrl || "https://github.com");
|
||||||
this.apiBase = /github\.com$/i.test(base) ? "https://api.github.com" : `${base}/api/v3`;
|
this.apiBase = /github\.com$/i.test(base)
|
||||||
|
? "https://api.github.com"
|
||||||
|
: `${base}/api/v3`;
|
||||||
this.token = config.token;
|
this.token = config.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +71,11 @@ export class GitHubClient implements GitClient {
|
|||||||
return { number: angelegt.number, url: angelegt.html_url ?? null };
|
return { number: angelegt.number, url: angelegt.html_url ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> {
|
async setIssueState(
|
||||||
|
ref: RepoRef,
|
||||||
|
number: number,
|
||||||
|
open: boolean,
|
||||||
|
): Promise<void> {
|
||||||
await sendJson(
|
await sendJson(
|
||||||
`${this.repoBase(ref)}/issues/${number}`,
|
`${this.repoBase(ref)}/issues/${number}`,
|
||||||
"PATCH",
|
"PATCH",
|
||||||
@@ -70,7 +84,10 @@ export class GitHubClient implements GitClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
|
async getIssueState(
|
||||||
|
ref: RepoRef,
|
||||||
|
number: number,
|
||||||
|
): Promise<"open" | "closed" | null> {
|
||||||
const issue = await getJson<{ state?: string } | null>(
|
const issue = await getJson<{ state?: string } | null>(
|
||||||
`${this.repoBase(ref)}/issues/${number}`,
|
`${this.repoBase(ref)}/issues/${number}`,
|
||||||
{ headers: this.headers(), allow404: true },
|
{ headers: this.headers(), allow404: true },
|
||||||
@@ -82,25 +99,42 @@ export class GitHubClient implements GitClient {
|
|||||||
async check(): Promise<ConnectionCheck> {
|
async check(): Promise<ConnectionCheck> {
|
||||||
try {
|
try {
|
||||||
if (!this.token) {
|
if (!this.token) {
|
||||||
await getJson(`${this.apiBase}/rate_limit`, { headers: this.headers() });
|
await getJson(`${this.apiBase}/rate_limit`, {
|
||||||
|
headers: this.headers(),
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
message: "Erreichbar (ohne Token – nur oeffentliche Repos, striktes Rate-Limit).",
|
message:
|
||||||
|
"Erreichbar (ohne Token – nur oeffentliche Repos, striktes Rate-Limit).",
|
||||||
user: null,
|
user: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const user = await getJson<{ login: string; name?: string }>(`${this.apiBase}/user`, {
|
const user = await getJson<{ login: string; name?: string }>(
|
||||||
headers: this.headers(),
|
`${this.apiBase}/user`,
|
||||||
});
|
{
|
||||||
return { ok: true, message: `Verbunden als ${user.login}`, user: user.login };
|
headers: this.headers(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: `Verbunden als ${user.login}`,
|
||||||
|
user: user.login,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async listRepos(query?: string): Promise<RepoListItem[]> {
|
async listRepos(query?: string): Promise<RepoListItem[]> {
|
||||||
if (!this.token) {
|
if (!this.token) {
|
||||||
throw new GitApiError("Fuer das Auflisten von Repos wird ein Token benoetigt.", 401, this.apiBase);
|
throw new GitApiError(
|
||||||
|
"Fuer das Auflisten von Repos wird ein Token benoetigt.",
|
||||||
|
401,
|
||||||
|
this.apiBase,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const repos = await getJson<GitHubRepo[]>(
|
const repos = await getJson<GitHubRepo[]>(
|
||||||
`${this.apiBase}/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member`,
|
`${this.apiBase}/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member`,
|
||||||
@@ -108,7 +142,9 @@ export class GitHubClient implements GitClient {
|
|||||||
);
|
);
|
||||||
const needle = query?.toLowerCase().trim();
|
const needle = query?.toLowerCase().trim();
|
||||||
return (repos ?? [])
|
return (repos ?? [])
|
||||||
.filter((repo) => !needle || repo.full_name.toLowerCase().includes(needle))
|
.filter(
|
||||||
|
(repo) => !needle || repo.full_name.toLowerCase().includes(needle),
|
||||||
|
)
|
||||||
.map((repo) => ({
|
.map((repo) => ({
|
||||||
fullPath: repo.full_name,
|
fullPath: repo.full_name,
|
||||||
owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "",
|
owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "",
|
||||||
@@ -129,23 +165,61 @@ export class GitHubClient implements GitClient {
|
|||||||
|
|
||||||
const defaultBranch = repo.default_branch;
|
const defaultBranch = repo.default_branch;
|
||||||
|
|
||||||
const [branches, tags, commits, releases, languages, pulls, issues, ciRuns] =
|
// Sammelt, welche Liste nicht geholt werden konnte und warum.
|
||||||
await Promise.all([
|
const teil = new Teilabrufe();
|
||||||
getJsonSafe<GitHubBranch[]>(`${base}/branches?per_page=50`, [], { headers }),
|
|
||||||
getJsonSafe<GitHubTag[]>(`${base}/tags?per_page=25`, [], { headers }),
|
const [
|
||||||
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
branches,
|
||||||
getJsonSafe<GitHubCommit[]>(`${base}/commits?per_page=100`, [], { headers }),
|
tags,
|
||||||
getJsonSafe<GitHubRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
|
commits,
|
||||||
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
releases,
|
||||||
getJsonSafe<GitHubPull[]>(`${base}/pulls?state=open&per_page=50`, [], { headers }),
|
languages,
|
||||||
// /issues liefert bei GitHub auch Pull Requests – die filtern wir raus.
|
pulls,
|
||||||
getJsonSafe<GitHubIssue[]>(`${base}/issues?state=open&per_page=50`, [], { headers }),
|
issues,
|
||||||
getJsonSafe<{ workflow_runs?: GitHubRun[] }>(
|
ciRuns,
|
||||||
`${base}/actions/runs?per_page=1&branch=${encodeURIComponent(defaultBranch)}`,
|
] = await Promise.all([
|
||||||
{},
|
teil.hol<GitHubBranch[]>("branches", `${base}/branches?per_page=50`, [], {
|
||||||
{ headers },
|
headers,
|
||||||
),
|
}),
|
||||||
]);
|
teil.hol<GitHubTag[]>("tags", `${base}/tags?per_page=25`, [], {
|
||||||
|
headers,
|
||||||
|
}),
|
||||||
|
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||||||
|
teil.hol<GitHubCommit[]>("commits", `${base}/commits?per_page=100`, [], {
|
||||||
|
headers,
|
||||||
|
}),
|
||||||
|
teil.hol<GitHubRelease[]>(
|
||||||
|
"releases",
|
||||||
|
`${base}/releases?per_page=10`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
teil.hol<Record<string, number>>(
|
||||||
|
"languages",
|
||||||
|
`${base}/languages`,
|
||||||
|
{},
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
teil.hol<GitHubPull[]>(
|
||||||
|
"pullRequests",
|
||||||
|
`${base}/pulls?state=open&per_page=50`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
// /issues liefert bei GitHub auch Pull Requests – die filtern wir raus.
|
||||||
|
teil.hol<GitHubIssue[]>(
|
||||||
|
"issues",
|
||||||
|
`${base}/issues?state=open&per_page=50`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
teil.hol<{ workflow_runs?: GitHubRun[] }>(
|
||||||
|
"ci",
|
||||||
|
`${base}/actions/runs?per_page=1&branch=${encodeURIComponent(defaultBranch)}`,
|
||||||
|
{},
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
||||||
name: branch.name,
|
name: branch.name,
|
||||||
@@ -208,7 +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[]>(
|
||||||
headers: this.headers(),
|
`${base}/stats/commit_activity`,
|
||||||
});
|
{
|
||||||
|
headers: this.headers(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!Array.isArray(wochen) || wochen.length === 0) {
|
if (!Array.isArray(wochen) || wochen.length === 0) {
|
||||||
// Entweder rechnet GitHub noch, oder das Repository hat keine Commits.
|
// Entweder rechnet GitHub noch, oder das Repository hat keine Commits.
|
||||||
@@ -300,7 +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":
|
||||||
|
|||||||
+133
-38
@@ -1,4 +1,12 @@
|
|||||||
import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http";
|
import {
|
||||||
|
Teilabrufe,
|
||||||
|
getJson,
|
||||||
|
getJsonSafe,
|
||||||
|
normalizeBaseUrl,
|
||||||
|
sendJson,
|
||||||
|
shortSha,
|
||||||
|
toIso,
|
||||||
|
} from "./http";
|
||||||
import {
|
import {
|
||||||
CALENDAR_PAGE_SIZE,
|
CALENDAR_PAGE_SIZE,
|
||||||
calendarByPaging,
|
calendarByPaging,
|
||||||
@@ -61,7 +69,11 @@ export class GitLabClient implements GitClient {
|
|||||||
return { number: angelegt.iid, url: angelegt.web_url ?? null };
|
return { number: angelegt.iid, url: angelegt.web_url ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> {
|
async setIssueState(
|
||||||
|
ref: RepoRef,
|
||||||
|
number: number,
|
||||||
|
open: boolean,
|
||||||
|
): Promise<void> {
|
||||||
await sendJson(
|
await sendJson(
|
||||||
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
|
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
|
||||||
"PUT",
|
"PUT",
|
||||||
@@ -70,7 +82,10 @@ export class GitLabClient implements GitClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
|
async getIssueState(
|
||||||
|
ref: RepoRef,
|
||||||
|
number: number,
|
||||||
|
): Promise<"open" | "closed" | null> {
|
||||||
const issue = await getJson<{ state?: string } | null>(
|
const issue = await getJson<{ state?: string } | null>(
|
||||||
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
|
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
|
||||||
{ headers: this.headers(), allow404: true },
|
{ headers: this.headers(), allow404: true },
|
||||||
@@ -83,15 +98,31 @@ export class GitLabClient implements GitClient {
|
|||||||
async check(): Promise<ConnectionCheck> {
|
async check(): Promise<ConnectionCheck> {
|
||||||
try {
|
try {
|
||||||
if (!this.token) {
|
if (!this.token) {
|
||||||
await getJson(`${this.apiBase}/projects?per_page=1`, { headers: this.headers() });
|
await getJson(`${this.apiBase}/projects?per_page=1`, {
|
||||||
return { ok: true, message: "Erreichbar (ohne Token – nur oeffentliche Projekte).", user: null };
|
headers: this.headers(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: "Erreichbar (ohne Token – nur oeffentliche Projekte).",
|
||||||
|
user: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const user = await getJson<{ username: string; name?: string }>(`${this.apiBase}/user`, {
|
const user = await getJson<{ username: string; name?: string }>(
|
||||||
headers: this.headers(),
|
`${this.apiBase}/user`,
|
||||||
});
|
{
|
||||||
return { ok: true, message: `Verbunden als ${user.username}`, user: user.username };
|
headers: this.headers(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: `Verbunden als ${user.username}`,
|
||||||
|
user: user.username,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +132,9 @@ export class GitLabClient implements GitClient {
|
|||||||
? `${this.apiBase}/projects?membership=true&per_page=100&order_by=last_activity_at${search}`
|
? `${this.apiBase}/projects?membership=true&per_page=100&order_by=last_activity_at${search}`
|
||||||
: `${this.apiBase}/projects?per_page=50&order_by=last_activity_at${search}`;
|
: `${this.apiBase}/projects?per_page=50&order_by=last_activity_at${search}`;
|
||||||
|
|
||||||
const projects = await getJson<GitLabProject[]>(url, { headers: this.headers() });
|
const projects = await getJson<GitLabProject[]>(url, {
|
||||||
|
headers: this.headers(),
|
||||||
|
});
|
||||||
return (projects ?? []).map((project) => {
|
return (projects ?? []).map((project) => {
|
||||||
const path = project.path_with_namespace;
|
const path = project.path_with_namespace;
|
||||||
const segments = path.split("/");
|
const segments = path.split("/");
|
||||||
@@ -127,35 +160,83 @@ export class GitLabClient implements GitClient {
|
|||||||
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
|
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
|
||||||
const headers = this.headers();
|
const headers = this.headers();
|
||||||
|
|
||||||
const project = await getJson<GitLabProject>(`${base}?statistics=true`, { headers });
|
const project = await getJson<GitLabProject>(`${base}?statistics=true`, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
const defaultBranch = project.default_branch ?? "";
|
const defaultBranch = project.default_branch ?? "";
|
||||||
|
|
||||||
const [branches, tags, commits, releases, languages, mergeRequests, issues, pipelines] =
|
// Sammelt, welche Liste nicht geholt werden konnte und warum.
|
||||||
await Promise.all([
|
const teil = new Teilabrufe();
|
||||||
getJsonSafe<GitLabBranch[]>(`${base}/repository/branches?per_page=50`, [], { headers }),
|
|
||||||
getJsonSafe<GitLabTag[]>(`${base}/repository/tags?per_page=25`, [], { headers }),
|
const [
|
||||||
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
branches,
|
||||||
getJsonSafe<GitLabCommit[]>(`${base}/repository/commits?per_page=100`, [], { headers }),
|
tags,
|
||||||
getJsonSafe<GitLabRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
|
commits,
|
||||||
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
releases,
|
||||||
getJsonSafe<GitLabMergeRequest[]>(`${base}/merge_requests?state=opened&per_page=50`, [], {
|
languages,
|
||||||
|
mergeRequests,
|
||||||
|
issues,
|
||||||
|
pipelines,
|
||||||
|
] = await Promise.all([
|
||||||
|
teil.hol<GitLabBranch[]>(
|
||||||
|
"branches",
|
||||||
|
`${base}/repository/branches?per_page=50`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
teil.hol<GitLabTag[]>("tags", `${base}/repository/tags?per_page=25`, [], {
|
||||||
|
headers,
|
||||||
|
}),
|
||||||
|
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||||||
|
teil.hol<GitLabCommit[]>(
|
||||||
|
"commits",
|
||||||
|
`${base}/repository/commits?per_page=100`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
teil.hol<GitLabRelease[]>(
|
||||||
|
"releases",
|
||||||
|
`${base}/releases?per_page=10`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
teil.hol<Record<string, number>>(
|
||||||
|
"languages",
|
||||||
|
`${base}/languages`,
|
||||||
|
{},
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
teil.hol<GitLabMergeRequest[]>(
|
||||||
|
"pullRequests",
|
||||||
|
`${base}/merge_requests?state=opened&per_page=50`,
|
||||||
|
[],
|
||||||
|
{
|
||||||
headers,
|
headers,
|
||||||
}),
|
},
|
||||||
getJsonSafe<GitLabIssue[]>(`${base}/issues?state=opened&per_page=50`, [], { headers }),
|
),
|
||||||
getJsonSafe<GitLabPipeline[]>(
|
teil.hol<GitLabIssue[]>(
|
||||||
`${base}/pipelines?per_page=1${defaultBranch ? `&ref=${encodeURIComponent(defaultBranch)}` : ""}`,
|
"issues",
|
||||||
[],
|
`${base}/issues?state=opened&per_page=50`,
|
||||||
{ headers },
|
[],
|
||||||
),
|
{ headers },
|
||||||
]);
|
),
|
||||||
|
teil.hol<GitLabPipeline[]>(
|
||||||
|
"ci",
|
||||||
|
`${base}/pipelines?per_page=1${defaultBranch ? `&ref=${encodeURIComponent(defaultBranch)}` : ""}`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
||||||
name: branch.name,
|
name: branch.name,
|
||||||
sha: branch.commit?.id ?? null,
|
sha: branch.commit?.id ?? null,
|
||||||
isDefault: Boolean(branch.default),
|
isDefault: Boolean(branch.default),
|
||||||
protected: Boolean(branch.protected),
|
protected: Boolean(branch.protected),
|
||||||
lastCommitAt: toIso(branch.commit?.committed_date ?? branch.commit?.created_at),
|
lastCommitAt: toIso(
|
||||||
|
branch.commit?.committed_date ?? branch.commit?.created_at,
|
||||||
|
),
|
||||||
lastCommitMessage: branch.commit?.title ?? null,
|
lastCommitMessage: branch.commit?.title ?? null,
|
||||||
lastCommitAuthor: branch.commit?.author_name ?? null,
|
lastCommitAuthor: branch.commit?.author_name ?? null,
|
||||||
url: branch.web_url ?? null,
|
url: branch.web_url ?? null,
|
||||||
@@ -240,7 +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