diff --git a/src/App.svelte b/src/App.svelte index 6bfa1da..d91a7b8 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -10,6 +10,9 @@ import TitleBar from "./lib/TitleBar.svelte"; import RepoToolbar from "./lib/RepoToolbar.svelte"; + import PullRequestStatusLoader from "./lib/components/PullRequestStatusLoader.svelte"; + import type { PullRequestBadgeState } from "./lib/pullRequestBadges"; + let pullRequestBadges: Record = {}; import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte"; import ReviewCenter from "./lib/components/ReviewCenter.svelte"; import RepoTabs from "./lib/RepoTabs.svelte"; @@ -5548,10 +5551,15 @@ {/if} + {#if activeView === "management"} + import { onMount, onDestroy } from "svelte"; + import { listIntegrationReviewRequests, listRemotes } from "../git"; + import { configuredIntegrationSources, integrationCredentialKey } from "../integrations"; + import type { GitIntegrationSettings, GitIntegrationSource, IntegrationReviewRequest, StoredCredential } from "../types"; + import type { PullRequestBadgeState } from "../pullRequestBadges"; + interface DashboardRepository { path: string; } + interface RemoteIdentity { host: string; repository: string; organization: string; } + export let repos: DashboardRepository[] = []; + export let language: "de" | "en" = "en"; + export let integrations: GitIntegrationSettings; + export let loadCredential: (key: string) => Promise; + export let pullRequestBadges: Record = {}; + const STORAGE_KEY = "gitty.pull-request-badges.v1"; + let pullRequestLoadGeneration = 0; + let lastPullRequestLoadKey = ""; + let mounted = false; + let loading = false; + $: de = language === "de"; + $: integrationSources = configuredIntegrationSources(integrations); + $: sourceKey = JSON.stringify(integrationSources.map(source => [source.id, source.provider, source.accountId, source.baseUrl])); + $: loadKey = JSON.stringify([repos.map(repo => repo.path).sort(), sourceKey]); + $: if (mounted && loadKey !== lastPullRequestLoadKey) { + lastPullRequestLoadKey = loadKey; + restoreCache(); + void refresh(); + } + onMount(() => { + mounted = true; + const timer = window.setInterval(() => { if (!loading) void refresh(); }, 3 * 60_000); + return () => window.clearInterval(timer); + }); + onDestroy(() => { mounted = false; ++pullRequestLoadGeneration; }); + + function restoreCache() { + pullRequestBadges = {}; + try { + const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null"); + if (saved?.sourceKey !== sourceKey || !saved?.badges) return; + for (const repo of repos) { + const badge = saved.badges[repo.path]; + if (badge?.status === "ready" && Number.isInteger(badge.count) && badge.count >= 0 + && typeof badge.repository === "string" && typeof badge.sourceId === "string" + && integrationSources.some(source => source.id === badge.sourceId)) { + pullRequestBadges[repo.path] = { ...badge, message: "" }; + } + } + } catch { /* Cache is optional. */ } + } + + async function refresh() { + loading = true; + const pending = loadPullRequestBadges(); + const generation = pullRequestLoadGeneration; + try { await pending; } + finally { + if (generation === pullRequestLoadGeneration) { + loading = false; + try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ sourceKey, badges: pullRequestBadges })); } + catch { /* Keep in-memory values if storage is unavailable. */ } + } + } + } + + function remoteIdentity(value: string): RemoteIdentity | null { + const trimmed = value.trim(); + if (!trimmed) return null; + let host = ""; + let path = ""; + const scp = trimmed.match(/^[^@\s]+@([^:\s]+):(.+)$/); + if (scp) { + host = scp[1]; + path = scp[2]; + } else { + try { + const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`); + host = url.hostname; + path = url.pathname; + } catch { + return null; + } + } + const parts = path.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").split("/").filter(Boolean); + const gitIndex = parts.findIndex((part) => part.toLocaleLowerCase() === "_git"); + const normalizedHost = host.toLocaleLowerCase(); + if (gitIndex > 0 && parts[gitIndex + 1]) return { host: normalizedHost, repository: `${parts[gitIndex - 1]}/${parts[gitIndex + 1]}`.toLocaleLowerCase(), organization: normalizedHost === "dev.azure.com" ? (parts[0] ?? "").toLocaleLowerCase() : "" }; + if (parts[0]?.toLocaleLowerCase() === "v3" && parts.length >= 4) return { host: normalizedHost, repository: `${parts[2]}/${parts[3]}`.toLocaleLowerCase(), organization: parts[1].toLocaleLowerCase() }; + return { host: normalizedHost, repository: parts.join("/").toLocaleLowerCase(), organization: "" }; + } + + function sourceMatchesRemote(source: GitIntegrationSource, identity: RemoteIdentity): boolean { + try { + const sourceUrl = new URL(source.baseUrl); + const sourceHost = sourceUrl.hostname.toLocaleLowerCase(); + const remoteHost = identity.host; + if (source.provider === "azure-devops" ? sourceHost.replace(/^ssh\./, "") !== remoteHost.replace(/^ssh\./, "") : sourceHost !== remoteHost) return false; + if (source.provider !== "azure-devops") return true; + const organization = sourceUrl.hostname.toLocaleLowerCase() === "dev.azure.com" + ? sourceUrl.pathname.split("/").filter(Boolean)[0]?.toLocaleLowerCase() ?? "" + : sourceUrl.hostname.split(".")[0]?.toLocaleLowerCase() ?? ""; + return !organization || !identity.organization || organization === identity.organization; + } catch { + return false; + } + } + + function requestMatchesRepository(request: IntegrationReviewRequest, repository: string): boolean { + const requestRepository = request.repositoryName.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLocaleLowerCase(); + if (!requestRepository) return false; + if (requestRepository === repository || repository.endsWith(`/${requestRepository}`) || requestRepository.endsWith(`/${repository}`)) return true; + const requestParts = requestRepository.split("/"); + const remoteParts = repository.split("/"); + return requestParts[requestParts.length - 1] === remoteParts[remoteParts.length - 1]; + } + + function updatePullRequestBadge(path: string, state: PullRequestBadgeState, generation: number) { + if (generation !== pullRequestLoadGeneration) return; + if (state.status === "error" && pullRequestBadges[path]?.status === "ready") { + state = { ...pullRequestBadges[path], message: state.message }; + } + pullRequestBadges = { ...pullRequestBadges, [path]: state }; + } + + function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs); + promise.then((value) => { window.clearTimeout(timer); resolve(value); }, (error) => { window.clearTimeout(timer); reject(error); }); + }); + } + + async function loadPullRequestBadges() { + const generation = ++pullRequestLoadGeneration; + const sources = integrationSources; + pullRequestBadges = Object.fromEntries(repos.map((repo) => [repo.path, pullRequestBadges[repo.path] ?? { + status: sources.length ? "loading" : "unavailable", + count: 0, + sourceId: "", + repository: "", + message: sources.length ? "" : (de ? "Keine passende Integration eingerichtet." : "No matching integration configured."), + } satisfies PullRequestBadgeState])); + if (!repos.length || !sources.length) return; + + const grouped = new Map>(); + await Promise.all(repos.map(async (repo) => { + try { + const remotes = await withTimeout(listRemotes(repo.path), 15_000, "Remote lookup timed out."); + const identities = remotes + .sort((left, right) => Number(right.name === "origin") - Number(left.name === "origin")) + .map((remote) => remoteIdentity(remote.fetch_url)) + .filter((identity): identity is RemoteIdentity => Boolean(identity)); + const match = identities.map((identity) => ({ identity, source: sources.find((source) => sourceMatchesRemote(source, identity)) })).find((item) => item.source); + if (!match?.source) { + updatePullRequestBadge(repo.path, { status: "unavailable", count: 0, sourceId: "", repository: "", message: de ? "Keine passende Integration für das Remote gefunden." : "No matching integration for this remote." }, generation); + return; + } + grouped.set(match.source.id, [...(grouped.get(match.source.id) ?? []), { repo, identity: match.identity }]); + } catch (error) { + updatePullRequestBadge(repo.path, { status: "error", count: 0, sourceId: "", repository: "", message: error instanceof Error ? error.message : String(error) }, generation); + } + })); + + if (generation !== pullRequestLoadGeneration) return; + await Promise.all([...grouped.entries()].map(async ([sourceId, entries]) => { + const source = sources.find((candidate) => candidate.id === sourceId); + if (!source) return; + try { + const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000, de ? "Der System-Schlüsselbund hat nicht geantwortet." : "The system keychain did not respond."); + if (!credential?.password) throw new Error(de ? "Kein Integrationstoken gespeichert." : "No integration token stored."); + const requests = await withTimeout(listIntegrationReviewRequests(source.provider, source.baseUrl, credential.username, credential.password, "open"), 46_000, de ? `${source.label} hat nicht geantwortet.` : `${source.label} did not respond.`); + for (const { repo, identity } of entries) { + updatePullRequestBadge(repo.path, { + status: "ready", + count: requests.filter((request) => requestMatchesRepository(request, identity.repository)).length, + sourceId, + repository: identity.repository, + message: "", + }, generation); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + for (const { repo, identity } of entries) updatePullRequestBadge(repo.path, { status: "error", count: 0, sourceId, repository: identity.repository, message }, generation); + } + })); + } + + diff --git a/src/lib/components/RepositoryDashboard.svelte b/src/lib/components/RepositoryDashboard.svelte index bdb2092..05057bb 100644 --- a/src/lib/components/RepositoryDashboard.svelte +++ b/src/lib/components/RepositoryDashboard.svelte @@ -1,8 +1,6 @@