fix(sync): Teil-Abrufe verschlucken ihre Fehler nicht mehr
CI / pruefen (pull_request) Canceled after 0s
CI / pruefen (pull_request) Canceled after 0s
Behebt florian.wilde/nebula#19 und florian.wilde/nebula#20.
getJsonSafe machte aus jedem Fehlschlag eine leere Liste. Damit war ein
403 nicht mehr von "das Repository hat keine Branches" zu unterscheiden:
Der Abgleich lief durch, meldete "Snapshot aktualisiert: 0 Branches,
0 Tags", setzte kein syncError - und ueberschrieb dabei den letzten guten
Stand mit Nullen. Ein Repository mit 65 Commits sah danach aus wie ein
leeres, und die Ursache war nicht zu ermitteln, weil die Auskunft, die
sie benannt haette, gerade weggeworfen worden war.
Teilabrufe sammelt jetzt je Feld, was schiefging - mit HTTP-Status, denn
daran haengt die Ursache: 403 heisst Scopes, 429 Rate-Limit, 408
Zeitueberschreitung, 0 nicht erreicht. Der Fallback bleibt, ein einzelner
Fehlschlag darf den ganzen Abgleich nicht abbrechen.
Daraus folgt der zweite Teil: persistSnapshot laesst stehen, was nicht
geholt werden konnte. Der Snapshot ist ein Zwischenspeicher - ein alter
Stand mit sichtbarem Datum ist immer brauchbarer als eine leere Liste,
die aussieht wie eine Aussage. Die abgeleiteten Felder gehen mit ihrer
Liste: lastCommit* haengt an commits, latestTag an tags UND releases,
sonst waeren sie in sich widerspruechlich.
Bewusst kein Fehlschlag: syncError bleibt leer und der Zaehler fuers
Zurueckhalten steigt nicht. Der Rest des Standes ist frisch, und ein
Repository deswegen stundenlang auszusetzen waere die falsche Antwort.
GIT_API_TIMEOUT_MS wird geprueft. Number("") ist 0 und Number("abc") ist
NaN; setTimeout loest bei beidem sofort aus, und dann bricht jeder Aufruf
ab, bevor er beginnt. Eine verrutschte Zeile in der .env legte damit den
ganzen Abgleich still - lautlos, versteht sich.
NACHGEWIESEN, NICHT BEHAUPTET
Gegen einen nachgebauten Gitea-Server, der genau den beobachteten Fall
erzeugt - Basis-Aufruf antwortet, Teillisten geben 403 - und eine
Wegwerf-Datenbank:
gesund branchCount=2 tagCount=3 commits=2 releases=1 missing={}
kaputt branchCount=2 tagCount=3 commits=2 releases=1
missing={8 Felder mit HTTP 403}, completeAt unveraendert
wieder gut missing={} , completeAt fortgeschrieben
Nichts verloren, die Luecke steht in der Meldung und am Repository, und
nach dem naechsten gesunden Durchlauf raeumt sie sich von selbst weg.
axe-core 0 Verstoesse auf 1280 und 375 px, kein seitlicher Ueberlauf.
Der eigentliche Grund der Ausfaelle auf der Instanz ist damit noch nicht
gefunden - aber ab jetzt steht er auf der Projektseite.
Refs #19
Refs #20
This commit is contained in:
+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 {
|
||||
CALENDAR_PAGE_SIZE,
|
||||
calendarByPaging,
|
||||
@@ -62,7 +70,11 @@ export class GiteaClient implements GitClient {
|
||||
return { number: angelegt.number, url: angelegt.html_url ?? null };
|
||||
}
|
||||
|
||||
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> {
|
||||
async setIssueState(
|
||||
ref: RepoRef,
|
||||
number: number,
|
||||
open: boolean,
|
||||
): Promise<void> {
|
||||
await sendJson(
|
||||
`${this.repoBase(ref)}/issues/${number}`,
|
||||
"PATCH",
|
||||
@@ -71,7 +83,10 @@ export class GiteaClient implements GitClient {
|
||||
);
|
||||
}
|
||||
|
||||
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
|
||||
async getIssueState(
|
||||
ref: RepoRef,
|
||||
number: number,
|
||||
): Promise<"open" | "closed" | null> {
|
||||
const issue = await getJson<{ state?: string } | null>(
|
||||
`${this.repoBase(ref)}/issues/${number}`,
|
||||
{ headers: this.headers(), allow404: true },
|
||||
@@ -97,9 +112,12 @@ export class GiteaClient implements GitClient {
|
||||
};
|
||||
}
|
||||
|
||||
const user = await getJson<{ login: string; full_name?: string }>(`${this.apiBase}/user`, {
|
||||
headers: this.headers(),
|
||||
});
|
||||
const user = await getJson<{ login: string; full_name?: string }>(
|
||||
`${this.apiBase}/user`,
|
||||
{
|
||||
headers: this.headers(),
|
||||
},
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
message: `Verbunden als ${user.login}${version.version ? ` (Gitea ${version.version})` : ""}`,
|
||||
@@ -107,7 +125,10 @@ export class GiteaClient implements GitClient {
|
||||
version: version.version ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
||||
return {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +144,9 @@ export class GiteaClient implements GitClient {
|
||||
const needle = query?.toLowerCase().trim();
|
||||
|
||||
return repos
|
||||
.filter((repo) => !needle || repo.full_name.toLowerCase().includes(needle))
|
||||
.filter(
|
||||
(repo) => !needle || repo.full_name.toLowerCase().includes(needle),
|
||||
)
|
||||
.map((repo) => ({
|
||||
fullPath: repo.full_name,
|
||||
owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "",
|
||||
@@ -144,31 +167,71 @@ export class GiteaClient implements GitClient {
|
||||
|
||||
const defaultBranch = repo.default_branch ?? "";
|
||||
|
||||
const [branches, tags, commits, releases, languages, pulls, issues, status] =
|
||||
await Promise.all([
|
||||
getJsonSafe<GiteaBranch[]>(`${base}/branches?limit=50`, [], { headers }),
|
||||
getJsonSafe<GiteaTag[]>(`${base}/tags?limit=25`, [], { headers }),
|
||||
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||||
getJsonSafe<GiteaCommit[]>(`${base}/commits?limit=100&stat=false`, [], { headers }),
|
||||
getJsonSafe<GiteaRelease[]>(`${base}/releases?limit=10`, [], { headers }),
|
||||
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
||||
getJsonSafe<GiteaPull[]>(`${base}/pulls?state=open&limit=50`, [], { headers }),
|
||||
// type=issues, sonst kommen bei Gitea die Pull Requests mit.
|
||||
getJsonSafe<GiteaIssue[]>(`${base}/issues?state=open&type=issues&limit=50`, [], {
|
||||
// Sammelt, welche Liste nicht geholt werden konnte und warum. Frueher
|
||||
// stand hier getJsonSafe, das jeden Fehler in eine leere Liste verwandelt
|
||||
// hat – siehe Teilabrufe.
|
||||
const teil = new Teilabrufe();
|
||||
|
||||
const [
|
||||
branches,
|
||||
tags,
|
||||
commits,
|
||||
releases,
|
||||
languages,
|
||||
pulls,
|
||||
issues,
|
||||
status,
|
||||
] = await Promise.all([
|
||||
teil.hol<GiteaBranch[]>("branches", `${base}/branches?limit=50`, [], {
|
||||
headers,
|
||||
}),
|
||||
teil.hol<GiteaTag[]>("tags", `${base}/tags?limit=25`, [], { headers }),
|
||||
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||||
teil.hol<GiteaCommit[]>(
|
||||
"commits",
|
||||
`${base}/commits?limit=100&stat=false`,
|
||||
[],
|
||||
{
|
||||
headers,
|
||||
}),
|
||||
// Gitea meldet CI-Ergebnisse als Commit-Status. Das deckt Gitea
|
||||
// Actions und externe Systeme wie Woodpecker gleichermassen ab –
|
||||
// anders als ein reiner Actions-Endpunkt, den aeltere Instanzen
|
||||
// ohnehin nicht kennen.
|
||||
defaultBranch
|
||||
? getJsonSafe<GiteaCombinedStatus>(
|
||||
`${base}/commits/${encodeURIComponent(defaultBranch)}/status`,
|
||||
{} as GiteaCombinedStatus,
|
||||
{ headers },
|
||||
)
|
||||
: Promise.resolve({} as GiteaCombinedStatus),
|
||||
]);
|
||||
},
|
||||
),
|
||||
teil.hol<GiteaRelease[]>("releases", `${base}/releases?limit=10`, [], {
|
||||
headers,
|
||||
}),
|
||||
teil.hol<Record<string, number>>(
|
||||
"languages",
|
||||
`${base}/languages`,
|
||||
{},
|
||||
{ headers },
|
||||
),
|
||||
teil.hol<GiteaPull[]>(
|
||||
"pullRequests",
|
||||
`${base}/pulls?state=open&limit=50`,
|
||||
[],
|
||||
{
|
||||
headers,
|
||||
},
|
||||
),
|
||||
// type=issues, sonst kommen bei Gitea die Pull Requests mit.
|
||||
teil.hol<GiteaIssue[]>(
|
||||
"issues",
|
||||
`${base}/issues?state=open&type=issues&limit=50`,
|
||||
[],
|
||||
{ headers },
|
||||
),
|
||||
// Gitea meldet CI-Ergebnisse als Commit-Status. Das deckt Gitea
|
||||
// Actions und externe Systeme wie Woodpecker gleichermassen ab –
|
||||
// anders als ein reiner Actions-Endpunkt, den aeltere Instanzen
|
||||
// ohnehin nicht kennen.
|
||||
defaultBranch
|
||||
? teil.hol<GiteaCombinedStatus>(
|
||||
"ci",
|
||||
`${base}/commits/${encodeURIComponent(defaultBranch)}/status`,
|
||||
{} as GiteaCombinedStatus,
|
||||
{ headers },
|
||||
)
|
||||
: Promise.resolve({} as GiteaCombinedStatus),
|
||||
]);
|
||||
|
||||
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
||||
name: branch.name,
|
||||
@@ -186,7 +249,9 @@ export class GiteaClient implements GitClient {
|
||||
sha: tag.commit?.sha ?? tag.id ?? null,
|
||||
message: tag.message ?? null,
|
||||
date: toIso(tag.commit?.created),
|
||||
url: tag.zipball_url ? `${repo.html_url}/releases/tag/${encodeURIComponent(tag.name)}` : null,
|
||||
url: tag.zipball_url
|
||||
? `${repo.html_url}/releases/tag/${encodeURIComponent(tag.name)}`
|
||||
: null,
|
||||
}));
|
||||
|
||||
const mappedCommits: CommitInfo[] = commits.map((commit) => ({
|
||||
@@ -259,7 +324,10 @@ export class GiteaClient implements GitClient {
|
||||
pullRequests: mappedPulls,
|
||||
issues: mappedIssues,
|
||||
ci,
|
||||
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
|
||||
commitActivity: bucketCommitActivity(
|
||||
mappedCommits.map((commit) => commit.date),
|
||||
),
|
||||
missing: teil.fehler,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -298,7 +366,9 @@ export class GiteaClient implements GitClient {
|
||||
{ headers },
|
||||
);
|
||||
const name = pickReadme(
|
||||
eintraege.filter((eintrag) => eintrag.type === "file").map((eintrag) => eintrag.name),
|
||||
eintraege
|
||||
.filter((eintrag) => eintrag.type === "file")
|
||||
.map((eintrag) => eintrag.name),
|
||||
);
|
||||
if (!name) return null;
|
||||
|
||||
|
||||
+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 {
|
||||
BranchInfo,
|
||||
CiInfo,
|
||||
@@ -31,7 +39,9 @@ export class GitHubClient implements GitClient {
|
||||
|
||||
constructor(config: ProviderConfig) {
|
||||
const base = normalizeBaseUrl(config.baseUrl || "https://github.com");
|
||||
this.apiBase = /github\.com$/i.test(base) ? "https://api.github.com" : `${base}/api/v3`;
|
||||
this.apiBase = /github\.com$/i.test(base)
|
||||
? "https://api.github.com"
|
||||
: `${base}/api/v3`;
|
||||
this.token = config.token;
|
||||
}
|
||||
|
||||
@@ -61,7 +71,11 @@ export class GitHubClient implements GitClient {
|
||||
return { number: angelegt.number, url: angelegt.html_url ?? null };
|
||||
}
|
||||
|
||||
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> {
|
||||
async setIssueState(
|
||||
ref: RepoRef,
|
||||
number: number,
|
||||
open: boolean,
|
||||
): Promise<void> {
|
||||
await sendJson(
|
||||
`${this.repoBase(ref)}/issues/${number}`,
|
||||
"PATCH",
|
||||
@@ -70,7 +84,10 @@ export class GitHubClient implements GitClient {
|
||||
);
|
||||
}
|
||||
|
||||
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
|
||||
async getIssueState(
|
||||
ref: RepoRef,
|
||||
number: number,
|
||||
): Promise<"open" | "closed" | null> {
|
||||
const issue = await getJson<{ state?: string } | null>(
|
||||
`${this.repoBase(ref)}/issues/${number}`,
|
||||
{ headers: this.headers(), allow404: true },
|
||||
@@ -82,25 +99,42 @@ export class GitHubClient implements GitClient {
|
||||
async check(): Promise<ConnectionCheck> {
|
||||
try {
|
||||
if (!this.token) {
|
||||
await getJson(`${this.apiBase}/rate_limit`, { headers: this.headers() });
|
||||
await getJson(`${this.apiBase}/rate_limit`, {
|
||||
headers: this.headers(),
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
message: "Erreichbar (ohne Token – nur oeffentliche Repos, striktes Rate-Limit).",
|
||||
message:
|
||||
"Erreichbar (ohne Token – nur oeffentliche Repos, striktes Rate-Limit).",
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
const user = await getJson<{ login: string; name?: string }>(`${this.apiBase}/user`, {
|
||||
headers: this.headers(),
|
||||
});
|
||||
return { ok: true, message: `Verbunden als ${user.login}`, user: user.login };
|
||||
const user = await getJson<{ login: string; name?: string }>(
|
||||
`${this.apiBase}/user`,
|
||||
{
|
||||
headers: this.headers(),
|
||||
},
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
message: `Verbunden als ${user.login}`,
|
||||
user: user.login,
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
||||
return {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async listRepos(query?: string): Promise<RepoListItem[]> {
|
||||
if (!this.token) {
|
||||
throw new GitApiError("Fuer das Auflisten von Repos wird ein Token benoetigt.", 401, this.apiBase);
|
||||
throw new GitApiError(
|
||||
"Fuer das Auflisten von Repos wird ein Token benoetigt.",
|
||||
401,
|
||||
this.apiBase,
|
||||
);
|
||||
}
|
||||
const repos = await getJson<GitHubRepo[]>(
|
||||
`${this.apiBase}/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member`,
|
||||
@@ -108,7 +142,9 @@ export class GitHubClient implements GitClient {
|
||||
);
|
||||
const needle = query?.toLowerCase().trim();
|
||||
return (repos ?? [])
|
||||
.filter((repo) => !needle || repo.full_name.toLowerCase().includes(needle))
|
||||
.filter(
|
||||
(repo) => !needle || repo.full_name.toLowerCase().includes(needle),
|
||||
)
|
||||
.map((repo) => ({
|
||||
fullPath: repo.full_name,
|
||||
owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "",
|
||||
@@ -129,23 +165,61 @@ export class GitHubClient implements GitClient {
|
||||
|
||||
const defaultBranch = repo.default_branch;
|
||||
|
||||
const [branches, tags, commits, releases, languages, pulls, issues, ciRuns] =
|
||||
await Promise.all([
|
||||
getJsonSafe<GitHubBranch[]>(`${base}/branches?per_page=50`, [], { headers }),
|
||||
getJsonSafe<GitHubTag[]>(`${base}/tags?per_page=25`, [], { headers }),
|
||||
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||||
getJsonSafe<GitHubCommit[]>(`${base}/commits?per_page=100`, [], { headers }),
|
||||
getJsonSafe<GitHubRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
|
||||
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
||||
getJsonSafe<GitHubPull[]>(`${base}/pulls?state=open&per_page=50`, [], { headers }),
|
||||
// /issues liefert bei GitHub auch Pull Requests – die filtern wir raus.
|
||||
getJsonSafe<GitHubIssue[]>(`${base}/issues?state=open&per_page=50`, [], { headers }),
|
||||
getJsonSafe<{ workflow_runs?: GitHubRun[] }>(
|
||||
`${base}/actions/runs?per_page=1&branch=${encodeURIComponent(defaultBranch)}`,
|
||||
{},
|
||||
{ headers },
|
||||
),
|
||||
]);
|
||||
// Sammelt, welche Liste nicht geholt werden konnte und warum.
|
||||
const teil = new Teilabrufe();
|
||||
|
||||
const [
|
||||
branches,
|
||||
tags,
|
||||
commits,
|
||||
releases,
|
||||
languages,
|
||||
pulls,
|
||||
issues,
|
||||
ciRuns,
|
||||
] = await Promise.all([
|
||||
teil.hol<GitHubBranch[]>("branches", `${base}/branches?per_page=50`, [], {
|
||||
headers,
|
||||
}),
|
||||
teil.hol<GitHubTag[]>("tags", `${base}/tags?per_page=25`, [], {
|
||||
headers,
|
||||
}),
|
||||
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||||
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) => ({
|
||||
name: branch.name,
|
||||
@@ -208,7 +282,10 @@ export class GitHubClient implements GitClient {
|
||||
|
||||
// open_issues_count zaehlt bei GitHub PRs mit – hier wieder herausrechnen.
|
||||
const openPullRequests = pulls.length;
|
||||
const openIssues = Math.max(0, (repo.open_issues_count ?? 0) - openPullRequests);
|
||||
const openIssues = Math.max(
|
||||
0,
|
||||
(repo.open_issues_count ?? 0) - openPullRequests,
|
||||
);
|
||||
|
||||
const run = ciRuns?.workflow_runs?.[0];
|
||||
const ci: CiInfo | null = run
|
||||
@@ -242,7 +319,10 @@ export class GitHubClient implements GitClient {
|
||||
pullRequests: mappedPulls,
|
||||
issues: mappedIssues,
|
||||
ci,
|
||||
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
|
||||
commitActivity: bucketCommitActivity(
|
||||
mappedCommits.map((commit) => commit.date),
|
||||
),
|
||||
missing: teil.fehler,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -260,9 +340,12 @@ export class GitHubClient implements GitClient {
|
||||
*/
|
||||
async fetchCommitCalendar(ref: RepoRef): Promise<CommitCalendar | null> {
|
||||
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
|
||||
const wochen = await getJson<GitHubWeek[]>(`${base}/stats/commit_activity`, {
|
||||
headers: this.headers(),
|
||||
});
|
||||
const wochen = await getJson<GitHubWeek[]>(
|
||||
`${base}/stats/commit_activity`,
|
||||
{
|
||||
headers: this.headers(),
|
||||
},
|
||||
);
|
||||
|
||||
if (!Array.isArray(wochen) || wochen.length === 0) {
|
||||
// Entweder rechnet GitHub noch, oder das Repository hat keine Commits.
|
||||
@@ -300,7 +383,9 @@ export class GitHubClient implements GitClient {
|
||||
|
||||
let datei: GitHubContent;
|
||||
try {
|
||||
datei = await getJson<GitHubContent>(`${base}/readme`, { headers: this.headers() });
|
||||
datei = await getJson<GitHubContent>(`${base}/readme`, {
|
||||
headers: this.headers(),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof GitApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
@@ -337,8 +422,12 @@ interface GitHubWeek {
|
||||
}
|
||||
|
||||
/** GitHub trennt Lauf-Status und Ergebnis; hier auf einen Wert zusammenfuehren. */
|
||||
function mapRunState(status: string | null, conclusion: string | null): CiState {
|
||||
if (status === "queued" || status === "waiting" || status === "pending") return "pending";
|
||||
function mapRunState(
|
||||
status: string | null,
|
||||
conclusion: string | null,
|
||||
): CiState {
|
||||
if (status === "queued" || status === "waiting" || status === "pending")
|
||||
return "pending";
|
||||
if (status === "in_progress") return "running";
|
||||
switch (conclusion) {
|
||||
case "success":
|
||||
|
||||
+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 {
|
||||
CALENDAR_PAGE_SIZE,
|
||||
calendarByPaging,
|
||||
@@ -61,7 +69,11 @@ export class GitLabClient implements GitClient {
|
||||
return { number: angelegt.iid, url: angelegt.web_url ?? null };
|
||||
}
|
||||
|
||||
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> {
|
||||
async setIssueState(
|
||||
ref: RepoRef,
|
||||
number: number,
|
||||
open: boolean,
|
||||
): Promise<void> {
|
||||
await sendJson(
|
||||
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
|
||||
"PUT",
|
||||
@@ -70,7 +82,10 @@ export class GitLabClient implements GitClient {
|
||||
);
|
||||
}
|
||||
|
||||
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
|
||||
async getIssueState(
|
||||
ref: RepoRef,
|
||||
number: number,
|
||||
): Promise<"open" | "closed" | null> {
|
||||
const issue = await getJson<{ state?: string } | null>(
|
||||
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
|
||||
{ headers: this.headers(), allow404: true },
|
||||
@@ -83,15 +98,31 @@ export class GitLabClient implements GitClient {
|
||||
async check(): Promise<ConnectionCheck> {
|
||||
try {
|
||||
if (!this.token) {
|
||||
await getJson(`${this.apiBase}/projects?per_page=1`, { headers: this.headers() });
|
||||
return { ok: true, message: "Erreichbar (ohne Token – nur oeffentliche Projekte).", user: null };
|
||||
await getJson(`${this.apiBase}/projects?per_page=1`, {
|
||||
headers: this.headers(),
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
message: "Erreichbar (ohne Token – nur oeffentliche Projekte).",
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
const user = await getJson<{ username: string; name?: string }>(`${this.apiBase}/user`, {
|
||||
headers: this.headers(),
|
||||
});
|
||||
return { ok: true, message: `Verbunden als ${user.username}`, user: user.username };
|
||||
const user = await getJson<{ username: string; name?: string }>(
|
||||
`${this.apiBase}/user`,
|
||||
{
|
||||
headers: this.headers(),
|
||||
},
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
message: `Verbunden als ${user.username}`,
|
||||
user: user.username,
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
||||
return {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +132,9 @@ export class GitLabClient implements GitClient {
|
||||
? `${this.apiBase}/projects?membership=true&per_page=100&order_by=last_activity_at${search}`
|
||||
: `${this.apiBase}/projects?per_page=50&order_by=last_activity_at${search}`;
|
||||
|
||||
const projects = await getJson<GitLabProject[]>(url, { headers: this.headers() });
|
||||
const projects = await getJson<GitLabProject[]>(url, {
|
||||
headers: this.headers(),
|
||||
});
|
||||
return (projects ?? []).map((project) => {
|
||||
const path = project.path_with_namespace;
|
||||
const segments = path.split("/");
|
||||
@@ -127,35 +160,83 @@ export class GitLabClient implements GitClient {
|
||||
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
|
||||
const headers = this.headers();
|
||||
|
||||
const project = await getJson<GitLabProject>(`${base}?statistics=true`, { headers });
|
||||
const project = await getJson<GitLabProject>(`${base}?statistics=true`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
const defaultBranch = project.default_branch ?? "";
|
||||
|
||||
const [branches, tags, commits, releases, languages, mergeRequests, issues, pipelines] =
|
||||
await Promise.all([
|
||||
getJsonSafe<GitLabBranch[]>(`${base}/repository/branches?per_page=50`, [], { headers }),
|
||||
getJsonSafe<GitLabTag[]>(`${base}/repository/tags?per_page=25`, [], { headers }),
|
||||
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||||
getJsonSafe<GitLabCommit[]>(`${base}/repository/commits?per_page=100`, [], { headers }),
|
||||
getJsonSafe<GitLabRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
|
||||
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
||||
getJsonSafe<GitLabMergeRequest[]>(`${base}/merge_requests?state=opened&per_page=50`, [], {
|
||||
// Sammelt, welche Liste nicht geholt werden konnte und warum.
|
||||
const teil = new Teilabrufe();
|
||||
|
||||
const [
|
||||
branches,
|
||||
tags,
|
||||
commits,
|
||||
releases,
|
||||
languages,
|
||||
mergeRequests,
|
||||
issues,
|
||||
pipelines,
|
||||
] = await Promise.all([
|
||||
teil.hol<GitLabBranch[]>(
|
||||
"branches",
|
||||
`${base}/repository/branches?per_page=50`,
|
||||
[],
|
||||
{ headers },
|
||||
),
|
||||
teil.hol<GitLabTag[]>("tags", `${base}/repository/tags?per_page=25`, [], {
|
||||
headers,
|
||||
}),
|
||||
// 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,
|
||||
}),
|
||||
getJsonSafe<GitLabIssue[]>(`${base}/issues?state=opened&per_page=50`, [], { headers }),
|
||||
getJsonSafe<GitLabPipeline[]>(
|
||||
`${base}/pipelines?per_page=1${defaultBranch ? `&ref=${encodeURIComponent(defaultBranch)}` : ""}`,
|
||||
[],
|
||||
{ headers },
|
||||
),
|
||||
]);
|
||||
},
|
||||
),
|
||||
teil.hol<GitLabIssue[]>(
|
||||
"issues",
|
||||
`${base}/issues?state=opened&per_page=50`,
|
||||
[],
|
||||
{ headers },
|
||||
),
|
||||
teil.hol<GitLabPipeline[]>(
|
||||
"ci",
|
||||
`${base}/pipelines?per_page=1${defaultBranch ? `&ref=${encodeURIComponent(defaultBranch)}` : ""}`,
|
||||
[],
|
||||
{ headers },
|
||||
),
|
||||
]);
|
||||
|
||||
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
||||
name: branch.name,
|
||||
sha: branch.commit?.id ?? null,
|
||||
isDefault: Boolean(branch.default),
|
||||
protected: Boolean(branch.protected),
|
||||
lastCommitAt: toIso(branch.commit?.committed_date ?? branch.commit?.created_at),
|
||||
lastCommitAt: toIso(
|
||||
branch.commit?.committed_date ?? branch.commit?.created_at,
|
||||
),
|
||||
lastCommitMessage: branch.commit?.title ?? null,
|
||||
lastCommitAuthor: branch.commit?.author_name ?? null,
|
||||
url: branch.web_url ?? null,
|
||||
@@ -240,7 +321,10 @@ export class GitLabClient implements GitClient {
|
||||
pullRequests: mappedMergeRequests,
|
||||
issues: mappedIssues,
|
||||
ci,
|
||||
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
|
||||
commitActivity: bucketCommitActivity(
|
||||
mappedCommits.map((commit) => commit.date),
|
||||
),
|
||||
missing: teil.fehler,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -260,7 +344,9 @@ export class GitLabClient implements GitClient {
|
||||
`&since=${encodeURIComponent(seit)}`,
|
||||
{ headers: this.headers() },
|
||||
);
|
||||
return commits.map((commit) => toIso(commit.committed_date ?? commit.created_at));
|
||||
return commits.map((commit) =>
|
||||
toIso(commit.committed_date ?? commit.created_at),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -278,18 +364,27 @@ export class GitLabClient implements GitClient {
|
||||
{ headers },
|
||||
);
|
||||
const name = pickReadme(
|
||||
eintraege.filter((eintrag) => eintrag.type === "blob").map((eintrag) => eintrag.name),
|
||||
eintraege
|
||||
.filter((eintrag) => eintrag.type === "blob")
|
||||
.map((eintrag) => eintrag.name),
|
||||
);
|
||||
if (!name) return null;
|
||||
|
||||
const datei = await getJson<{ content?: string; encoding?: string; file_path?: string }>(
|
||||
`${base}/repository/files/${encodeURIComponent(name)}?ref=HEAD`,
|
||||
{ headers },
|
||||
);
|
||||
const datei = await getJson<{
|
||||
content?: string;
|
||||
encoding?: string;
|
||||
file_path?: string;
|
||||
}>(`${base}/repository/files/${encodeURIComponent(name)}?ref=HEAD`, {
|
||||
headers,
|
||||
});
|
||||
if (!datei?.content || datei.encoding !== "base64") return null;
|
||||
|
||||
const { content, truncated } = capReadme(decodeBase64(datei.content));
|
||||
const projekt = await getJsonSafe<GitLabProject>(base, {} as GitLabProject, { headers });
|
||||
const projekt = await getJsonSafe<GitLabProject>(
|
||||
base,
|
||||
{} as GitLabProject,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
return {
|
||||
content,
|
||||
|
||||
@@ -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 { GitApiError } from "./types";
|
||||
|
||||
const DEFAULT_TIMEOUT = Number(process.env.GIT_API_TIMEOUT_MS ?? 15000);
|
||||
const TIMEOUT_VORGABE = 15000;
|
||||
|
||||
/**
|
||||
* Zeitlimit je Aufruf.
|
||||
*
|
||||
* Bewusst mit Pruefung: Number("") ist 0 und Number("abc") ist NaN – beides
|
||||
* laesst setTimeout sofort ausloesen, und dann bricht jeder ausgehende
|
||||
* Aufruf ab, bevor er begonnen hat. Eine verrutschte Zeile in der .env
|
||||
* legte damit den ganzen Abgleich still, ohne dass irgendwo etwas stand.
|
||||
*/
|
||||
export function zeitlimit(
|
||||
roh: string | undefined,
|
||||
warnen: (text: string) => void = console.warn,
|
||||
): number {
|
||||
const wert = roh?.trim();
|
||||
if (!wert) return TIMEOUT_VORGABE;
|
||||
|
||||
const zahl = Number(wert);
|
||||
// Untergrenze 1000 ms: darunter ist kein Aufruf ueber das Netz zu
|
||||
// schaffen, und wer das eintraegt, hat sich vertan.
|
||||
if (!Number.isFinite(zahl) || zahl < 1000) {
|
||||
warnen(
|
||||
`[nebula] GIT_API_TIMEOUT_MS="${wert}" ist unbrauchbar (Zahl ab 1000 erwartet). ` +
|
||||
`Es gelten ${TIMEOUT_VORGABE} ms.`,
|
||||
);
|
||||
return TIMEOUT_VORGABE;
|
||||
}
|
||||
return zahl;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT = zeitlimit(process.env.GIT_API_TIMEOUT_MS);
|
||||
|
||||
export interface FetchOptions {
|
||||
headers?: Record<string, string>;
|
||||
@@ -73,7 +103,11 @@ export async function getJson<T>(
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => "");
|
||||
throw new GitApiError(friendlyMessage(response.status, url, body), response.status, url);
|
||||
throw new GitApiError(
|
||||
friendlyMessage(response.status, url, body),
|
||||
response.status,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
@@ -150,7 +184,11 @@ export async function sendJson<T>(
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new GitApiError(friendlyMessage(response.status, url, text), response.status, url);
|
||||
throw new GitApiError(
|
||||
friendlyMessage(response.status, url, text),
|
||||
response.status,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
@@ -175,7 +213,14 @@ export async function sendJson<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/** Wie getJson, gibt aber bei jedem Fehler den Fallback zurueck. */
|
||||
/**
|
||||
* Wie getJson, gibt aber bei jedem Fehler den Fallback zurueck.
|
||||
*
|
||||
* Nur fuer Abrufe benutzen, bei denen ein Fehlschlag wirklich nichts
|
||||
* bedeutet. Fuer die Listen eines Snapshots ist das falsch – dort ist
|
||||
* "nicht geholt" etwas anderes als "nichts vorhanden". Dafuer gibt es
|
||||
* Teilabrufe.
|
||||
*/
|
||||
export async function getJsonSafe<T>(
|
||||
url: string,
|
||||
fallback: T,
|
||||
@@ -189,6 +234,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 {
|
||||
if (!value) return null;
|
||||
const date = new Date(String(value));
|
||||
|
||||
@@ -99,6 +99,15 @@ export interface RepoSnapshotData extends RepoOverview {
|
||||
ci: CiInfo | null;
|
||||
/** Commits je Tag der letzten 30 Tage, aeltester zuerst. */
|
||||
commitActivity: number[];
|
||||
/**
|
||||
* Teil-Abrufe, die nicht geklappt haben: Feldname -> Grund mit HTTP-Status.
|
||||
* Leer heisst, dass alle Listen wirklich geholt wurden.
|
||||
*
|
||||
* Der Unterschied ist wesentlich: eine leere Liste in `tags` bedeutet mit
|
||||
* einem Eintrag hier "nicht geholt" und ohne ihn "es gibt keine Tags".
|
||||
* Nur im ersten Fall darf der gespeicherte Stand stehen bleiben.
|
||||
*/
|
||||
missing: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Tage, ueber die die Commit-Aktivitaet gebildet wird. */
|
||||
|
||||
Reference in New Issue
Block a user