feat(git): offene PRs und Issues, CI-Status und Commit-Aktivitaet
Bisher standen nur Zahlen da. Jetzt kommen die Inhalte mit. Pull Requests und Issues: bis zu 50 offene je Liste, mit Nummer, Titel, Autor, Alter, Labels und Link. Bei GitHub und Gitea liefert der Issue-Endpunkt auch Pull Requests mit - die werden herausgefiltert. CI-Status des letzten Laufs auf dem Default-Branch: - GitHub ueber actions/runs; dort sind Lauf-Status und Ergebnis getrennt und werden auf einen Wert zusammengefuehrt - GitLab ueber pipelines - Gitea ueber den kombinierten Commit-Status. Das deckt Gitea Actions und externe Systeme wie Woodpecker gleichermassen ab - ein reiner Actions-Endpunkt existiert auf aelteren Instanzen nicht. Commit-Aktivitaet als Balkenreihe. Berechnet aus den ohnehin geholten Commits (jetzt 100 statt 15, angezeigt bleiben 15) statt ueber eigene Statistik-Endpunkte: die gibt es nicht bei allen drei Systemen, und bei GitHub antworten sie beim ersten Aufruf mit 202 und ohne Daten. Beim Testen gegen gitlab-runner fiel auf, dass alle 100 Commits in die letzten 30 Tage fielen - die frueheren Tage haetten faelschlich Null gezeigt, obwohl wir ueber sie nichts wissen. Die Kurve wird deshalb auf den tatsaechlich belegten Zeitraum gekuerzt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+111
-10
@@ -1,6 +1,8 @@
|
||||
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
||||
import type {
|
||||
BranchInfo,
|
||||
CiInfo,
|
||||
CiState,
|
||||
CommitInfo,
|
||||
ConnectionCheck,
|
||||
GitClient,
|
||||
@@ -10,7 +12,9 @@ import type {
|
||||
RepoRef,
|
||||
RepoSnapshotData,
|
||||
TagInfo,
|
||||
TicketInfo,
|
||||
} from "./types";
|
||||
import { bucketCommitActivity } from "./types";
|
||||
|
||||
/**
|
||||
* Gitea (und kompatible Forks wie Forgejo) – API v1.
|
||||
@@ -95,14 +99,33 @@ export class GiteaClient implements GitClient {
|
||||
|
||||
const repo = await getJson<GiteaRepo>(base, { headers });
|
||||
|
||||
const [branches, tags, commits, releases, languages, pulls] = await Promise.all([
|
||||
getJsonSafe<GiteaBranch[]>(`${base}/branches?limit=50`, [], { headers }),
|
||||
getJsonSafe<GiteaTag[]>(`${base}/tags?limit=25`, [], { headers }),
|
||||
getJsonSafe<GiteaCommit[]>(`${base}/commits?limit=15&stat=false`, [], { headers }),
|
||||
getJsonSafe<GiteaRelease[]>(`${base}/releases?limit=10`, [], { headers }),
|
||||
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
||||
getJsonSafe<GiteaPull[]>(`${base}/pulls?state=open&limit=100`, [], { headers }),
|
||||
]);
|
||||
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`, [], {
|
||||
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),
|
||||
]);
|
||||
|
||||
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
||||
name: branch.name,
|
||||
@@ -141,6 +164,38 @@ export class GiteaClient implements GitClient {
|
||||
draft: Boolean(release.draft),
|
||||
}));
|
||||
|
||||
const mappedPulls: TicketInfo[] = pulls.map((pull) => ({
|
||||
number: pull.number,
|
||||
title: pull.title,
|
||||
author: pull.user?.login ?? null,
|
||||
createdAt: toIso(pull.created_at),
|
||||
url: pull.html_url ?? null,
|
||||
draft: Boolean(pull.draft),
|
||||
labels: (pull.labels ?? []).map((label) => label.name),
|
||||
}));
|
||||
|
||||
const mappedIssues: TicketInfo[] = issues.map((issue) => ({
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
author: issue.user?.login ?? null,
|
||||
createdAt: toIso(issue.created_at),
|
||||
url: issue.html_url ?? null,
|
||||
draft: false,
|
||||
labels: (issue.labels ?? []).map((label) => label.name),
|
||||
}));
|
||||
|
||||
const latestStatus = status?.statuses?.[0];
|
||||
const ci: CiInfo | null =
|
||||
status?.state && status.state !== "" && status.statuses?.length
|
||||
? {
|
||||
state: mapCommitStatus(status.state),
|
||||
name: latestStatus?.context ?? null,
|
||||
branch: defaultBranch || null,
|
||||
url: latestStatus?.target_url ?? null,
|
||||
finishedAt: toIso(latestStatus?.updated_at),
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
fullName: repo.full_name,
|
||||
description: repo.description ?? null,
|
||||
@@ -155,13 +210,31 @@ export class GiteaClient implements GitClient {
|
||||
pushedAt: toIso(repo.updated_at),
|
||||
branches: mappedBranches,
|
||||
tags: mappedTags,
|
||||
commits: mappedCommits,
|
||||
commits: mappedCommits.slice(0, 15),
|
||||
releases: mappedReleases,
|
||||
languages: languages ?? {},
|
||||
pullRequests: mappedPulls,
|
||||
issues: mappedIssues,
|
||||
ci,
|
||||
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function mapCommitStatus(state: string): CiState {
|
||||
switch (state) {
|
||||
case "success":
|
||||
return "success";
|
||||
case "pending":
|
||||
return "running";
|
||||
case "failure":
|
||||
case "error":
|
||||
return "failure";
|
||||
default:
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
// ── API-Typen ──────────────────────────────────────────────────
|
||||
|
||||
interface GiteaRepo {
|
||||
@@ -220,6 +293,34 @@ interface GiteaRelease {
|
||||
draft: boolean;
|
||||
}
|
||||
|
||||
interface GiteaLabel {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface GiteaPull {
|
||||
id: number;
|
||||
number: number;
|
||||
title: string;
|
||||
html_url: string | null;
|
||||
created_at: string | null;
|
||||
draft?: boolean;
|
||||
user?: { login: string } | null;
|
||||
labels?: GiteaLabel[];
|
||||
}
|
||||
|
||||
interface GiteaIssue {
|
||||
number: number;
|
||||
title: string;
|
||||
html_url: string | null;
|
||||
created_at: string | null;
|
||||
user?: { login: string } | null;
|
||||
labels?: GiteaLabel[];
|
||||
}
|
||||
|
||||
interface GiteaCombinedStatus {
|
||||
state?: string;
|
||||
statuses?: Array<{
|
||||
context?: string;
|
||||
target_url?: string;
|
||||
updated_at?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
+111
-11
@@ -1,6 +1,8 @@
|
||||
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
||||
import type {
|
||||
BranchInfo,
|
||||
CiInfo,
|
||||
CiState,
|
||||
CommitInfo,
|
||||
ConnectionCheck,
|
||||
GitClient,
|
||||
@@ -10,8 +12,9 @@ import type {
|
||||
RepoRef,
|
||||
RepoSnapshotData,
|
||||
TagInfo,
|
||||
TicketInfo,
|
||||
} from "./types";
|
||||
import { GitApiError } from "./types";
|
||||
import { GitApiError, bucketCommitActivity } from "./types";
|
||||
|
||||
/**
|
||||
* GitHub – funktioniert mit github.com und GitHub Enterprise.
|
||||
@@ -85,14 +88,25 @@ export class GitHubClient implements GitClient {
|
||||
|
||||
const repo = await getJson<GitHubRepo>(base, { headers });
|
||||
|
||||
const [branches, tags, commits, releases, languages, pulls] = await Promise.all([
|
||||
getJsonSafe<GitHubBranch[]>(`${base}/branches?per_page=50`, [], { headers }),
|
||||
getJsonSafe<GitHubTag[]>(`${base}/tags?per_page=25`, [], { headers }),
|
||||
getJsonSafe<GitHubCommit[]>(`${base}/commits?per_page=15`, [], { headers }),
|
||||
getJsonSafe<GitHubRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
|
||||
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
||||
getJsonSafe<GitHubPull[]>(`${base}/pulls?state=open&per_page=100`, [], { headers }),
|
||||
]);
|
||||
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 },
|
||||
),
|
||||
]);
|
||||
|
||||
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
||||
name: branch.name,
|
||||
@@ -131,10 +145,43 @@ export class GitHubClient implements GitClient {
|
||||
draft: Boolean(release.draft),
|
||||
}));
|
||||
|
||||
const mappedPulls: TicketInfo[] = pulls.map((pull) => ({
|
||||
number: pull.number,
|
||||
title: pull.title,
|
||||
author: pull.user?.login ?? null,
|
||||
createdAt: toIso(pull.created_at),
|
||||
url: pull.html_url ?? null,
|
||||
draft: Boolean(pull.draft),
|
||||
labels: (pull.labels ?? []).map((label) => label.name),
|
||||
}));
|
||||
|
||||
const mappedIssues: TicketInfo[] = issues
|
||||
.filter((issue) => !issue.pull_request)
|
||||
.map((issue) => ({
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
author: issue.user?.login ?? null,
|
||||
createdAt: toIso(issue.created_at),
|
||||
url: issue.html_url ?? null,
|
||||
draft: false,
|
||||
labels: (issue.labels ?? []).map((label) => label.name),
|
||||
}));
|
||||
|
||||
// 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 run = ciRuns?.workflow_runs?.[0];
|
||||
const ci: CiInfo | null = run
|
||||
? {
|
||||
state: mapRunState(run.status, run.conclusion),
|
||||
name: run.name ?? null,
|
||||
branch: run.head_branch ?? null,
|
||||
url: run.html_url ?? null,
|
||||
finishedAt: toIso(run.updated_at),
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
fullName: repo.full_name,
|
||||
description: repo.description ?? null,
|
||||
@@ -149,13 +196,36 @@ export class GitHubClient implements GitClient {
|
||||
pushedAt: toIso(repo.pushed_at),
|
||||
branches: mappedBranches,
|
||||
tags: mappedTags,
|
||||
commits: mappedCommits,
|
||||
// Angezeigt werden die letzten 15; die uebrigen dienten der Kurve.
|
||||
commits: mappedCommits.slice(0, 15),
|
||||
releases: mappedReleases,
|
||||
languages: languages ?? {},
|
||||
pullRequests: mappedPulls,
|
||||
issues: mappedIssues,
|
||||
ci,
|
||||
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 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";
|
||||
if (status === "in_progress") return "running";
|
||||
switch (conclusion) {
|
||||
case "success":
|
||||
return "success";
|
||||
case "cancelled":
|
||||
return "cancelled";
|
||||
case "failure":
|
||||
case "timed_out":
|
||||
case "startup_failure":
|
||||
return "failure";
|
||||
default:
|
||||
return conclusion ? "failure" : "pending";
|
||||
}
|
||||
}
|
||||
|
||||
// ── API-Typen (nur die tatsaechlich genutzten Felder) ──────────
|
||||
|
||||
interface GitHubRepo {
|
||||
@@ -206,6 +276,36 @@ interface GitHubRelease {
|
||||
draft: boolean;
|
||||
}
|
||||
|
||||
interface GitHubLabel {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface GitHubPull {
|
||||
id: number;
|
||||
number: number;
|
||||
title: string;
|
||||
html_url: string | null;
|
||||
created_at: string | null;
|
||||
draft?: boolean;
|
||||
user?: { login: string } | null;
|
||||
labels?: GitHubLabel[];
|
||||
}
|
||||
|
||||
interface GitHubIssue {
|
||||
number: number;
|
||||
title: string;
|
||||
html_url: string | null;
|
||||
created_at: string | null;
|
||||
user?: { login: string } | null;
|
||||
labels?: GitHubLabel[];
|
||||
/** Gesetzt, wenn der Eintrag in Wahrheit ein Pull Request ist. */
|
||||
pull_request?: unknown;
|
||||
}
|
||||
|
||||
interface GitHubRun {
|
||||
name: string | null;
|
||||
status: string | null;
|
||||
conclusion: string | null;
|
||||
head_branch: string | null;
|
||||
html_url: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
+100
-11
@@ -1,6 +1,8 @@
|
||||
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
||||
import type {
|
||||
BranchInfo,
|
||||
CiInfo,
|
||||
CiState,
|
||||
CommitInfo,
|
||||
ConnectionCheck,
|
||||
GitClient,
|
||||
@@ -10,7 +12,9 @@ import type {
|
||||
RepoRef,
|
||||
RepoSnapshotData,
|
||||
TagInfo,
|
||||
TicketInfo,
|
||||
} from "./types";
|
||||
import { bucketCommitActivity } from "./types";
|
||||
|
||||
/**
|
||||
* GitLab – gitlab.com und selbst gehostete Instanzen (API v4).
|
||||
@@ -80,16 +84,26 @@ export class GitLabClient implements GitClient {
|
||||
|
||||
const project = await getJson<GitLabProject>(`${base}?statistics=true`, { headers });
|
||||
|
||||
const [branches, tags, commits, releases, languages, mergeRequests] = await Promise.all([
|
||||
getJsonSafe<GitLabBranch[]>(`${base}/repository/branches?per_page=50`, [], { headers }),
|
||||
getJsonSafe<GitLabTag[]>(`${base}/repository/tags?per_page=25`, [], { headers }),
|
||||
getJsonSafe<GitLabCommit[]>(`${base}/repository/commits?per_page=15`, [], { 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=100`, [], {
|
||||
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`, [], {
|
||||
headers,
|
||||
}),
|
||||
getJsonSafe<GitLabIssue[]>(`${base}/issues?state=opened&per_page=50`, [], { headers }),
|
||||
getJsonSafe<GitLabPipeline[]>(
|
||||
`${base}/pipelines?per_page=1${defaultBranch ? `&ref=${encodeURIComponent(defaultBranch)}` : ""}`,
|
||||
[],
|
||||
{ headers },
|
||||
),
|
||||
]);
|
||||
|
||||
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
||||
name: branch.name,
|
||||
@@ -128,6 +142,37 @@ export class GitLabClient implements GitClient {
|
||||
draft: false,
|
||||
}));
|
||||
|
||||
const mappedMergeRequests: TicketInfo[] = mergeRequests.map((entry) => ({
|
||||
number: entry.iid,
|
||||
title: entry.title,
|
||||
author: entry.author?.username ?? null,
|
||||
createdAt: toIso(entry.created_at),
|
||||
url: entry.web_url ?? null,
|
||||
draft: Boolean(entry.draft),
|
||||
labels: entry.labels ?? [],
|
||||
}));
|
||||
|
||||
const mappedIssues: TicketInfo[] = issues.map((entry) => ({
|
||||
number: entry.iid,
|
||||
title: entry.title,
|
||||
author: entry.author?.username ?? null,
|
||||
createdAt: toIso(entry.created_at),
|
||||
url: entry.web_url ?? null,
|
||||
draft: false,
|
||||
labels: entry.labels ?? [],
|
||||
}));
|
||||
|
||||
const pipeline = pipelines[0];
|
||||
const ci: CiInfo | null = pipeline
|
||||
? {
|
||||
state: mapPipelineState(pipeline.status),
|
||||
name: pipeline.name ?? `Pipeline #${pipeline.id}`,
|
||||
branch: pipeline.ref ?? null,
|
||||
url: pipeline.web_url ?? null,
|
||||
finishedAt: toIso(pipeline.updated_at),
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
fullName: project.path_with_namespace,
|
||||
description: project.description ?? null,
|
||||
@@ -144,13 +189,33 @@ export class GitLabClient implements GitClient {
|
||||
pushedAt: toIso(project.last_activity_at),
|
||||
branches: mappedBranches,
|
||||
tags: mappedTags,
|
||||
commits: mappedCommits,
|
||||
commits: mappedCommits.slice(0, 15),
|
||||
releases: mappedReleases,
|
||||
languages: languages ?? {},
|
||||
pullRequests: mappedMergeRequests,
|
||||
issues: mappedIssues,
|
||||
ci,
|
||||
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function mapPipelineState(status: string | null): CiState {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return "success";
|
||||
case "running":
|
||||
return "running";
|
||||
case "failed":
|
||||
return "failure";
|
||||
case "canceled":
|
||||
case "skipped":
|
||||
return "cancelled";
|
||||
default:
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
// ── API-Typen ──────────────────────────────────────────────────
|
||||
|
||||
interface GitLabProject {
|
||||
@@ -205,5 +270,29 @@ interface GitLabRelease {
|
||||
}
|
||||
|
||||
interface GitLabMergeRequest {
|
||||
iid: number;
|
||||
title: string;
|
||||
created_at: string | null;
|
||||
web_url: string | null;
|
||||
draft?: boolean;
|
||||
labels?: string[];
|
||||
author?: { username: string } | null;
|
||||
}
|
||||
|
||||
interface GitLabIssue {
|
||||
iid: number;
|
||||
title: string;
|
||||
created_at: string | null;
|
||||
web_url: string | null;
|
||||
labels?: string[];
|
||||
author?: { username: string } | null;
|
||||
}
|
||||
|
||||
interface GitLabPipeline {
|
||||
id: number;
|
||||
name?: string | null;
|
||||
status: string | null;
|
||||
ref: string | null;
|
||||
web_url: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { guessProvider } from "./meta";
|
||||
import { parseRepoUrl } from "./index";
|
||||
import { ACTIVITY_DAYS, bucketCommitActivity } from "./types";
|
||||
|
||||
describe("parseRepoUrl", () => {
|
||||
it("zerlegt eine gewoehnliche https-URL", () => {
|
||||
expect(parseRepoUrl("https://github.com/vercel/swr")).toMatchObject({
|
||||
host: "github.com",
|
||||
owner: "vercel",
|
||||
name: "swr",
|
||||
guessedProvider: "GITHUB",
|
||||
});
|
||||
});
|
||||
|
||||
it("entfernt die Endung .git", () => {
|
||||
expect(parseRepoUrl("https://github.com/vercel/swr.git")?.name).toBe("swr");
|
||||
});
|
||||
|
||||
it("versteht SSH-Adressen", () => {
|
||||
expect(parseRepoUrl("git@gitlab.com:gruppe/projekt.git")).toMatchObject({
|
||||
host: "gitlab.com",
|
||||
owner: "gruppe",
|
||||
name: "projekt",
|
||||
guessedProvider: "GITLAB",
|
||||
});
|
||||
});
|
||||
|
||||
it("behaelt verschachtelte GitLab-Gruppen", () => {
|
||||
expect(parseRepoUrl("https://gitlab.com/gruppe/untergruppe/projekt")).toMatchObject({
|
||||
owner: "gruppe/untergruppe",
|
||||
name: "projekt",
|
||||
});
|
||||
});
|
||||
|
||||
it("schneidet GitLab-Oberflaechenpfade ab dem /-/ ab", () => {
|
||||
expect(parseRepoUrl("https://gitlab.com/gruppe/projekt/-/tree/main")).toMatchObject({
|
||||
owner: "gruppe",
|
||||
name: "projekt",
|
||||
});
|
||||
});
|
||||
|
||||
it("kommt mit einer eigenen Gitea-Instanz zurecht", () => {
|
||||
expect(parseRepoUrl("https://git.meine-firma.de/team/repo")).toMatchObject({
|
||||
baseUrl: "https://git.meine-firma.de",
|
||||
owner: "team",
|
||||
name: "repo",
|
||||
guessedProvider: "GITEA",
|
||||
});
|
||||
});
|
||||
|
||||
it("gibt null zurueck, wenn kein Repository erkennbar ist", () => {
|
||||
expect(parseRepoUrl("https://github.com/nurbenutzer")).toBeNull();
|
||||
expect(parseRepoUrl("")).toBeNull();
|
||||
expect(parseRepoUrl("kein-url-artiger-text")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("guessProvider", () => {
|
||||
it("erkennt die grossen beiden am Hostnamen", () => {
|
||||
expect(guessProvider("github.com")).toBe("GITHUB");
|
||||
expect(guessProvider("gitlab.example.org")).toBe("GITLAB");
|
||||
});
|
||||
|
||||
it("nimmt bei allem anderen Gitea an", () => {
|
||||
expect(guessProvider("git.meine-firma.de")).toBe("GITEA");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bucketCommitActivity", () => {
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const daysAgo = (days: number) => new Date(Date.now() - days * dayMs).toISOString();
|
||||
|
||||
it("liefert ohne Commits den vollen Zeitraum", () => {
|
||||
expect(bucketCommitActivity([])).toHaveLength(ACTIVITY_DAYS);
|
||||
});
|
||||
|
||||
it("sortiert den juengsten Commit ans Ende", () => {
|
||||
const buckets = bucketCommitActivity([daysAgo(29), daysAgo(0)]);
|
||||
expect(buckets).toHaveLength(ACTIVITY_DAYS);
|
||||
expect(buckets[ACTIVITY_DAYS - 1]).toBe(1);
|
||||
});
|
||||
|
||||
it("zaehlt mehrere Commits desselben Tages zusammen", () => {
|
||||
const buckets = bucketCommitActivity([daysAgo(29), daysAgo(2), daysAgo(2), daysAgo(2)]);
|
||||
expect(buckets[buckets.length - 3]).toBe(3);
|
||||
});
|
||||
|
||||
it("kuerzt auf den belegten Zeitraum, statt Unbekanntes als Null zu zeigen", () => {
|
||||
// Reicht die geholte Historie nur fuenf Tage zurueck, duerfen die
|
||||
// 25 Tage davor nicht als "keine Commits" erscheinen.
|
||||
const buckets = bucketCommitActivity([daysAgo(4), daysAgo(0)]);
|
||||
expect(buckets).toHaveLength(5);
|
||||
expect(buckets[0]).toBe(1);
|
||||
expect(buckets[4]).toBe(1);
|
||||
});
|
||||
|
||||
it("ignoriert unlesbare Werte", () => {
|
||||
const buckets = bucketCommitActivity([null, "voelliger Unsinn"]);
|
||||
expect(buckets).toHaveLength(ACTIVITY_DAYS);
|
||||
expect(buckets.every((value) => value === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("laesst aeltere Commits den vollen Zeitraum aufspannen", () => {
|
||||
const buckets = bucketCommitActivity([daysAgo(100), daysAgo(1)]);
|
||||
expect(buckets).toHaveLength(ACTIVITY_DAYS);
|
||||
expect(buckets[ACTIVITY_DAYS - 2]).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,29 @@ export interface ReleaseInfo {
|
||||
draft: boolean;
|
||||
}
|
||||
|
||||
/** Ein offener Pull Request, Merge Request oder ein Issue. */
|
||||
export interface TicketInfo {
|
||||
number: number;
|
||||
title: string;
|
||||
author: string | null;
|
||||
createdAt: string | null;
|
||||
url: string | null;
|
||||
draft: boolean;
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
/** Ergebnis des letzten CI-Laufs auf dem Default-Branch. */
|
||||
export type CiState = "success" | "failure" | "running" | "pending" | "cancelled";
|
||||
|
||||
export interface CiInfo {
|
||||
state: CiState;
|
||||
/** Name des Workflows bzw. der Pipeline, falls die API ihn liefert. */
|
||||
name: string | null;
|
||||
branch: string | null;
|
||||
url: string | null;
|
||||
finishedAt: string | null;
|
||||
}
|
||||
|
||||
export interface RepoOverview {
|
||||
fullName: string;
|
||||
description: string | null;
|
||||
@@ -67,6 +90,53 @@ export interface RepoSnapshotData extends RepoOverview {
|
||||
commits: CommitInfo[];
|
||||
releases: ReleaseInfo[];
|
||||
languages: Record<string, number>;
|
||||
pullRequests: TicketInfo[];
|
||||
issues: TicketInfo[];
|
||||
ci: CiInfo | null;
|
||||
/** Commits je Tag der letzten 30 Tage, aeltester zuerst. */
|
||||
commitActivity: number[];
|
||||
}
|
||||
|
||||
/** Tage, ueber die die Commit-Aktivitaet gebildet wird. */
|
||||
export const ACTIVITY_DAYS = 30;
|
||||
|
||||
/**
|
||||
* Verteilt Commit-Zeitpunkte auf Tagesbuckets. Bewusst aus den ohnehin
|
||||
* geholten Commits berechnet statt ueber eigene Statistik-Endpunkte: die
|
||||
* gibt es nicht bei allen drei Systemen, und bei GitHub antworten sie beim
|
||||
* ersten Aufruf mit 202 und ohne Daten.
|
||||
*
|
||||
* Wichtig fuer die Ehrlichkeit der Kurve: geholt wird nur eine Seite
|
||||
* Commits. Liegt der aelteste davon noch innerhalb des Zeitraums, wissen wir
|
||||
* ueber die Tage davor schlicht nichts – dort eine Null zu zeichnen waere
|
||||
* falsch. Deshalb wird die Kurve auf den tatsaechlich belegten Zeitraum
|
||||
* gekuerzt.
|
||||
*/
|
||||
export function bucketCommitActivity(dates: Array<string | null>): number[] {
|
||||
const buckets = new Array<number>(ACTIVITY_DAYS).fill(0);
|
||||
const now = Date.now();
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
|
||||
let oldestDaysAgo = -1;
|
||||
|
||||
for (const value of dates) {
|
||||
if (!value) continue;
|
||||
const time = Date.parse(value);
|
||||
if (Number.isNaN(time)) continue;
|
||||
const daysAgo = Math.floor((now - time) / dayMs);
|
||||
if (daysAgo < 0) continue;
|
||||
if (daysAgo > oldestDaysAgo) oldestDaysAgo = daysAgo;
|
||||
if (daysAgo >= ACTIVITY_DAYS) continue;
|
||||
buckets[ACTIVITY_DAYS - 1 - daysAgo] += 1;
|
||||
}
|
||||
|
||||
// Reicht die Historie nicht bis an den Rand des Zeitraums, nur den
|
||||
// belegten Ausschnitt zurueckgeben.
|
||||
if (oldestDaysAgo >= 0 && oldestDaysAgo < ACTIVITY_DAYS - 1) {
|
||||
return buckets.slice(ACTIVITY_DAYS - 1 - oldestDaysAgo);
|
||||
}
|
||||
|
||||
return buckets;
|
||||
}
|
||||
|
||||
export interface RepoListItem {
|
||||
|
||||
@@ -117,6 +117,14 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
|
||||
commits: data.commits as unknown as object,
|
||||
releases: data.releases as unknown as object,
|
||||
languages: data.languages as unknown as object,
|
||||
pullRequests: data.pullRequests as unknown as object,
|
||||
issues: data.issues as unknown as object,
|
||||
commitActivity: data.commitActivity as unknown as object,
|
||||
ciState: data.ci?.state ?? null,
|
||||
ciName: data.ci?.name ?? null,
|
||||
ciBranch: data.ci?.branch ?? null,
|
||||
ciUrl: data.ci?.url ?? null,
|
||||
ciFinishedAt: data.ci?.finishedAt ? new Date(data.ci.finishedAt) : null,
|
||||
pushedAt: data.pushedAt ? new Date(data.pushedAt) : null,
|
||||
fetchedAt: new Date(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user