feat(dashboard): extract PR status loader and surface badges
Move pull-request discovery and caching into a dedicated background loader component. The repository dashboard no longer performs the network/keychain work itself and instead receives a shared badge state that is refreshed and persisted independently. The app wires the loader into top-level state and binds badge data into the dashboard view. - Add a background loader to fetch, group and cache open PR counts. - Simplify dashboard to accept and render pull-request badge state. - Wire loader into app-level state and bind badges to the dashboard.
This commit is contained in:
+9
-1
@@ -10,6 +10,9 @@
|
|||||||
|
|
||||||
import TitleBar from "./lib/TitleBar.svelte";
|
import TitleBar from "./lib/TitleBar.svelte";
|
||||||
import RepoToolbar from "./lib/RepoToolbar.svelte";
|
import RepoToolbar from "./lib/RepoToolbar.svelte";
|
||||||
|
import PullRequestStatusLoader from "./lib/components/PullRequestStatusLoader.svelte";
|
||||||
|
import type { PullRequestBadgeState } from "./lib/pullRequestBadges";
|
||||||
|
let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
|
||||||
import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte";
|
import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte";
|
||||||
import ReviewCenter from "./lib/components/ReviewCenter.svelte";
|
import ReviewCenter from "./lib/components/ReviewCenter.svelte";
|
||||||
import RepoTabs from "./lib/RepoTabs.svelte";
|
import RepoTabs from "./lib/RepoTabs.svelte";
|
||||||
@@ -5548,10 +5551,15 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<PullRequestStatusLoader
|
||||||
|
repos={dashboardRepos} language={appLanguage}
|
||||||
|
integrations={gitIntegrationSettings} loadCredential={loadStoredCredential}
|
||||||
|
bind:pullRequestBadges
|
||||||
|
/>
|
||||||
{#if activeView === "management"}
|
{#if activeView === "management"}
|
||||||
<RepositoryDashboard
|
<RepositoryDashboard
|
||||||
repos={dashboardRepos} language={appLanguage} {isBusy}
|
repos={dashboardRepos} language={appLanguage} {isBusy}
|
||||||
integrations={gitIntegrationSettings} loadCredential={loadStoredCredential}
|
{pullRequestBadges}
|
||||||
onOpen={selectRepoTab} onAdd={chooseRepositoryFolder} onClone={openCloneDialog}
|
onOpen={selectRepoTab} onAdd={chooseRepositoryFolder} onClone={openCloneDialog}
|
||||||
onInit={initializeRepository}
|
onInit={initializeRepository}
|
||||||
onFavorite={toggleFavoriteRepo} onClose={closeDashboardRepository}
|
onFavorite={toggleFavoriteRepo} onClose={closeDashboardRepository}
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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<StoredCredential | null>;
|
||||||
|
export let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
|
||||||
|
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<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||||
|
return new Promise<T>((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<string, Array<{ repo: DashboardRepository; identity: RemoteIdentity }>>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, Circle, Download, FolderGit2, GitBranch, GitPullRequest, LayoutGrid, List, LoaderCircle, Plus, Search, Star, TriangleAlert, Trash2, X } from "@lucide/svelte";
|
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, Circle, Download, FolderGit2, GitBranch, GitPullRequest, LayoutGrid, List, Plus, Search, Star, TriangleAlert, Trash2, X } from "@lucide/svelte";
|
||||||
import { listIntegrationReviewRequests, listRemotes } from "../git";
|
import type { PullRequestBadgeState } from "../pullRequestBadges";
|
||||||
import { configuredIntegrationSources, integrationCredentialKey } from "../integrations";
|
|
||||||
import type { GitIntegrationSettings, GitIntegrationSource, IntegrationReviewRequest, StoredCredential } from "../types";
|
|
||||||
import SelectMenu from "./SelectMenu.svelte";
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
interface DashboardRepository {
|
interface DashboardRepository {
|
||||||
@@ -20,16 +18,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Workspace { id: string; name: string; }
|
interface Workspace { id: string; name: string; }
|
||||||
interface PullRequestBadgeState {
|
|
||||||
status: "loading" | "ready" | "error" | "unavailable";
|
|
||||||
count: number;
|
|
||||||
sourceId: string;
|
|
||||||
repository: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RemoteIdentity { host: string; repository: string; organization: string; }
|
|
||||||
|
|
||||||
export let repos: DashboardRepository[] = [];
|
export let repos: DashboardRepository[] = [];
|
||||||
export let language: "de" | "en" = "en";
|
export let language: "de" | "en" = "en";
|
||||||
export let isBusy = false;
|
export let isBusy = false;
|
||||||
@@ -40,8 +28,6 @@
|
|||||||
export let onInit: () => unknown;
|
export let onInit: () => unknown;
|
||||||
export let onFavorite: (path: string) => unknown;
|
export let onFavorite: (path: string) => unknown;
|
||||||
export let onRemoveRecent: (path: string) => unknown;
|
export let onRemoveRecent: (path: string) => unknown;
|
||||||
export let integrations: GitIntegrationSettings;
|
|
||||||
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
|
|
||||||
export let onOpenPullRequests: (repository: string, sourceId: string) => unknown;
|
export let onOpenPullRequests: (repository: string, sourceId: string) => unknown;
|
||||||
|
|
||||||
const STORAGE_KEY = "gitty.dashboard.v1";
|
const STORAGE_KEY = "gitty.dashboard.v1";
|
||||||
@@ -56,9 +42,7 @@
|
|||||||
let workspaceName = "";
|
let workspaceName = "";
|
||||||
let workspaceError = "";
|
let workspaceError = "";
|
||||||
let workspaceSelection = new Set<string>();
|
let workspaceSelection = new Set<string>();
|
||||||
let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
|
export let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
|
||||||
let pullRequestLoadGeneration = 0;
|
|
||||||
let lastPullRequestLoadKey = "";
|
|
||||||
|
|
||||||
$: de = language === "de";
|
$: de = language === "de";
|
||||||
$: normalizedQuery = query.trim().toLocaleLowerCase();
|
$: normalizedQuery = query.trim().toLocaleLowerCase();
|
||||||
@@ -79,16 +63,6 @@
|
|||||||
{ value: "", label: de ? "Alle Workspaces" : "All workspaces" },
|
{ value: "", label: de ? "Alle Workspaces" : "All workspaces" },
|
||||||
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name, group: "Workspaces" })),
|
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name, group: "Workspaces" })),
|
||||||
];
|
];
|
||||||
$: integrationSources = configuredIntegrationSources(integrations);
|
|
||||||
$: pullRequestLoadKey = JSON.stringify({
|
|
||||||
repositories: repos.map((repo) => repo.path),
|
|
||||||
sources: integrationSources.map((source) => [source.id, source.baseUrl]),
|
|
||||||
});
|
|
||||||
$: if (pullRequestLoadKey !== lastPullRequestLoadKey) {
|
|
||||||
lastPullRequestLoadKey = pullRequestLoadKey;
|
|
||||||
void loadPullRequestBadges();
|
|
||||||
}
|
|
||||||
|
|
||||||
loadPreferences();
|
loadPreferences();
|
||||||
|
|
||||||
function loadPreferences() {
|
function loadPreferences() {
|
||||||
@@ -175,127 +149,11 @@
|
|||||||
if (!isBusy && badge?.status === "ready") onOpenPullRequests(badge.repository || repo.name, badge.sourceId);
|
if (!isBusy && badge?.status === "ready") onOpenPullRequests(badge.repository || repo.name, badge.sourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
pullRequestBadges = { ...pullRequestBadges, [path]: state };
|
|
||||||
}
|
|
||||||
|
|
||||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
|
||||||
return new Promise<T>((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, {
|
|
||||||
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<string, Array<{ repo: DashboardRepository; identity: RemoteIdentity }>>();
|
|
||||||
await Promise.all(repos.map(async (repo) => {
|
|
||||||
try {
|
|
||||||
const remotes = await listRemotes(repo.path);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function pullRequestTitle(badge: PullRequestBadgeState | undefined): string {
|
function pullRequestTitle(badge: PullRequestBadgeState | undefined): string {
|
||||||
if (!badge || badge.status === "loading") return de ? "Offene Pull Requests werden geladen" : "Loading open pull requests";
|
if (!badge || badge.status === "loading") return de ? "Offene Pull Requests werden geladen" : "Loading open pull requests";
|
||||||
if (badge.status === "error" || badge.status === "unavailable") return badge.message;
|
if (badge.status === "error" || badge.status === "unavailable") return badge.message;
|
||||||
return de ? `${badge.count} offene Pull Requests` : `${badge.count} open pull requests`;
|
const title = de ? `${badge.count} offene Pull Requests` : `${badge.count} open pull requests`;
|
||||||
|
return badge.message ? `${title} (${de ? "Letzter bekannter Stand" : "Last known status"}: ${badge.message})` : title;
|
||||||
}
|
}
|
||||||
function toggleCategory(id: string) {
|
function toggleCategory(id: string) {
|
||||||
const next = new Set(collapsedCategories);
|
const next = new Set(collapsedCategories);
|
||||||
@@ -368,7 +226,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<button class:has-open-prs={pullRequests?.status === "ready" && pullRequests.count > 0} class:pr-error={pullRequests?.status === "error"} class="pr-badge" type="button" onclick={(event) => openPullRequests(event, repo)} disabled={isBusy || !pullRequests || pullRequests.status !== "ready"} aria-label={`${repo.name}: ${pullRequestTitle(pullRequests)}`} title={pullRequestTitle(pullRequests)}>
|
<button class:has-open-prs={pullRequests?.status === "ready" && pullRequests.count > 0} class:pr-error={pullRequests?.status === "error"} class="pr-badge" type="button" onclick={(event) => openPullRequests(event, repo)} disabled={isBusy || !pullRequests || pullRequests.status !== "ready"} aria-label={`${repo.name}: ${pullRequestTitle(pullRequests)}`} title={pullRequestTitle(pullRequests)}>
|
||||||
{#if !pullRequests || pullRequests.status === "loading"}<LoaderCircle class="spin" size={14} />{:else if pullRequests.status === "error"}<TriangleAlert size={14} />{:else}<GitPullRequest size={14} /><span>{pullRequests.status === "ready" ? pullRequests.count : "–"}</span>{/if}
|
{#if pullRequests?.status === "error"}<TriangleAlert size={14} />{:else}<GitPullRequest size={14} /><span>{pullRequests?.status === "ready" ? pullRequests.count : "–"}</span>{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class:active={repo.favorite} type="button" onclick={(event) => favoriteCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Favorit umschalten" : "Toggle favorite"}`} aria-pressed={repo.favorite}><Star size={15} /></button>
|
<button class:active={repo.favorite} type="button" onclick={(event) => favoriteCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Favorit umschalten" : "Toggle favorite"}`} aria-pressed={repo.favorite}><Star size={15} /></button>
|
||||||
{#if repo.isOpen && category.id === "open"}<button type="button" onclick={(event) => closeCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Schließen" : "Close"}`} title={de ? "Repository schließen" : "Close repository"}><X size={15} /></button>{/if}
|
{#if repo.isOpen && category.id === "open"}<button type="button" onclick={(event) => closeCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Schließen" : "Close"}`} title={de ? "Repository schließen" : "Close repository"}><X size={15} /></button>{/if}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface PullRequestBadgeState {
|
||||||
|
status: "loading" | "ready" | "error" | "unavailable";
|
||||||
|
count: number;
|
||||||
|
sourceId: string;
|
||||||
|
repository: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user