feat(integrations): add review fetching and Linux keyring support

Add a cross-provider Review Center and improve credential handling.
Backend integrations fetch and normalize PRs from GitHub, GitLab,
Gitea, and Azure DevOps with improved timeouts and parsing. Credentials
now use a global lock and support secret-tool on Linux to avoid races.

- Normalize review data across providers (GitHub/GitLab/Gitea/Azure)
- Serialize credential access with OnceLock and use secret-tool on Linux
- Add frontend ReviewCenter component and related UI updates
This commit is contained in:
2026-09-06 22:53:44 +02:00
parent 2ec493e91f
commit a55a3ca300
11 changed files with 1666 additions and 15 deletions
+52 -4
View File
@@ -6,11 +6,12 @@
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
import { AlertCircle, Cherry, CloudOff, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
import { beginFrontendShutdown } from "./lib/telemetry";
import { beginFrontendShutdown, resumeFrontend } from "./lib/telemetry";
import TitleBar from "./lib/TitleBar.svelte";
import RepoToolbar from "./lib/RepoToolbar.svelte";
import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte";
import ReviewCenter from "./lib/components/ReviewCenter.svelte";
import RepoTabs from "./lib/RepoTabs.svelte";
import AiReviewDialog from "./lib/components/AiReviewDialog.svelte";
import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte";
@@ -200,6 +201,7 @@
resolveDetectedExternalToolPrograms,
} from "./lib/externalTools";
import {
configuredIntegrationSources,
defaultGitIntegrationSettings,
integrationCredentialKey,
normaliseGitIntegrationSettings,
@@ -215,7 +217,7 @@
import { setTelemetryEnabled, tracedInvoke } from "./lib/telemetry";
type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository";
type AppView = "management" | "review-center" | "repository";
type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
type CredentialMode = "credentials" | "token";
type PendingDiscard =
@@ -468,6 +470,7 @@
let credDialogUsername = "";
let credDialogMode: CredentialMode = "credentials";
const rejectedCredentialKeys = new Set<string>();
const credentialCache = new Map<string, StoredCredential | null>();
let lastStatusFingerprint = "";
const AUTO_REFRESH_INTERVAL = 4000;
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
@@ -581,6 +584,8 @@
// ── Lifecycle ──────────────────────────────────────────────────────────────
resumeFrontend();
onMount(() => {
themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
themeMediaQuery.addEventListener("change", handleSystemThemeChange);
@@ -610,7 +615,6 @@
});
onDestroy(() => {
handleAppShutdown();
themeMediaQuery?.removeEventListener("change", handleSystemThemeChange);
window.removeEventListener("beforeunload", handleAppShutdown);
window.removeEventListener("pagehide", handleAppShutdown);
@@ -1249,12 +1253,14 @@
const providerConfig = integrationsToSave.providers[update.provider];
if (update.removeToken) {
await credDelete(key);
credentialCache.set(key, null);
if (azureOrganization) azureOrganization.tokenStored = false;
else providerConfig.tokenStored = false;
} else if (update.token) {
const fallbackUsername = update.provider === "github" ? "x-access-token" : "oauth2";
const username = (azureOrganization?.username ?? providerConfig.username).trim() || fallbackUsername;
await credSave(key, username, update.token, "token");
credentialCache.set(key, { username, password: update.token, mode: "token" });
if (azureOrganization) azureOrganization.tokenStored = true;
else providerConfig.tokenStored = true;
}
@@ -2581,6 +2587,13 @@
void backgroundRepoStatusTick(false);
}
function openReviewCenter() {
if (isBusy) return;
closeRepoTabContextMenu();
activeView = "review-center";
trackEvent("review_center_opened", { integrations: Object.values(gitIntegrationSettings.providers).filter((provider) => provider.enabled && provider.tokenStored).length });
}
async function selectRepoTab(path: string) {
if (isBusy) return;
if (activeView === "repository" && sameRepoPath(activeRepoPath, path)) return;
@@ -3653,8 +3666,34 @@
async function loadStoredCredential(key: string | null): Promise<StoredCredential | null> {
if (!key) return null;
if (credentialCache.has(key)) return credentialCache.get(key) ?? null;
const repoHost = key.startsWith("integration:") ? "" : key.split("/", 1)[0].toLowerCase();
const integrationAliases = repoHost
? configuredIntegrationSources(gitIntegrationSettings)
.filter((source) => {
try { return new URL(source.baseUrl).host.toLowerCase() === repoHost; }
catch { return false; }
})
.map((source) => integrationCredentialKey(source.provider, source.accountId))
: [];
const candidates = [...new Set([...integrationAliases, key])];
try {
return await credLoad(key);
for (const candidate of candidates) {
const cached = credentialCache.get(candidate);
if (cached) {
credentialCache.set(key, cached);
return cached;
}
if (credentialCache.has(candidate)) continue;
const credential = await credLoad(candidate);
credentialCache.set(candidate, credential);
if (credential) {
credentialCache.set(key, credential);
return credential;
}
}
credentialCache.set(key, null);
return null;
} catch {
return null;
}
@@ -3931,6 +3970,7 @@
if (save && key) {
try {
await credSave(key, username, password, mode);
credentialCache.set(key, { username, password, mode });
} catch (error) {
credDialogError = errorToMessage(error);
return;
@@ -5138,6 +5178,7 @@
{isBusy}
language={appLanguage}
onOpenManagement={openRepoManagement}
onOpenReviewCenter={openReviewCenter}
isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)}
onSelect={selectRepoTab}
onClose={closeRepoTab}
@@ -5307,6 +5348,13 @@
onFavorite={toggleFavoriteRepo} onClose={closeDashboardRepository}
onRemoveRecent={removeRepoFromRecent}
/>
{:else if activeView === "review-center"}
<ReviewCenter
language={appLanguage}
integrations={gitIntegrationSettings}
loadCredential={loadStoredCredential}
onOpenSettings={() => { appSettingsOpen = true; }}
/>
{:else}
<!-- Workspace -->
<section