feat(integrations): add Git hosting integrations and repo listing
Add support for integrating with external Git hosts (GitLab, Gitea, and Azure DevOps). The backend gains a client to fetch paginated repository lists, normalise base URLs, and surface provider errors. Credentials are loaded from the OS keychain and a Tauri command is exposed for the frontend to list integration repositories. - Implement integration client with pagination, deserialization, and provider-specific handling. - Centralise keychain credential loading and expose listing command. - Update UI to manage integration metadata, persist settings, and save/remove tokens to the OS keychain for cloning and operations.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import type {
|
||||
AzureDevOpsOrganization,
|
||||
GitIntegrationConfig,
|
||||
GitIntegrationProvider,
|
||||
GitIntegrationSource,
|
||||
GitIntegrationSettings,
|
||||
} from "./types";
|
||||
|
||||
export const gitIntegrationProviders: GitIntegrationProvider[] = [
|
||||
"gitlab",
|
||||
"gitlab-self-hosted",
|
||||
"azure-devops",
|
||||
"gitea",
|
||||
];
|
||||
|
||||
const defaults: Record<GitIntegrationProvider, Omit<GitIntegrationConfig, "provider">> = {
|
||||
gitlab: {
|
||||
enabled: false,
|
||||
baseUrl: "https://gitlab.com",
|
||||
username: "",
|
||||
tokenStored: false,
|
||||
},
|
||||
"gitlab-self-hosted": {
|
||||
enabled: false,
|
||||
baseUrl: "",
|
||||
username: "",
|
||||
tokenStored: false,
|
||||
},
|
||||
"azure-devops": {
|
||||
enabled: false,
|
||||
baseUrl: "https://dev.azure.com/",
|
||||
username: "",
|
||||
tokenStored: false,
|
||||
},
|
||||
gitea: {
|
||||
enabled: false,
|
||||
baseUrl: "",
|
||||
username: "",
|
||||
tokenStored: false,
|
||||
},
|
||||
};
|
||||
|
||||
export function defaultGitIntegrationSettings(): GitIntegrationSettings {
|
||||
return {
|
||||
providers: Object.fromEntries(
|
||||
gitIntegrationProviders.map((provider) => [provider, { provider, ...defaults[provider] }]),
|
||||
) as GitIntegrationSettings["providers"],
|
||||
azureDevOpsOrganizations: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseAzureOrganization(value: unknown): AzureDevOpsOrganization | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const stored = value as Partial<AzureDevOpsOrganization>;
|
||||
const id = typeof stored.id === "string" && /^[a-zA-Z0-9_-]{1,80}$/.test(stored.id) ? stored.id : "";
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
name: typeof stored.name === "string" ? stored.name : "",
|
||||
enabled: stored.enabled === true,
|
||||
baseUrl: typeof stored.baseUrl === "string" ? stored.baseUrl : "",
|
||||
username: typeof stored.username === "string" ? stored.username : "",
|
||||
tokenStored: stored.tokenStored === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function normaliseGitIntegrationSettings(value: unknown): GitIntegrationSettings {
|
||||
const fallback = defaultGitIntegrationSettings();
|
||||
if (!value || typeof value !== "object") return fallback;
|
||||
const storedProviders = (value as Partial<GitIntegrationSettings>).providers;
|
||||
if (!storedProviders || typeof storedProviders !== "object") return fallback;
|
||||
|
||||
for (const provider of gitIntegrationProviders) {
|
||||
const stored = storedProviders[provider] as Partial<GitIntegrationConfig> | undefined;
|
||||
if (!stored || typeof stored !== "object") continue;
|
||||
fallback.providers[provider] = {
|
||||
provider,
|
||||
enabled: stored.enabled === true,
|
||||
baseUrl: typeof stored.baseUrl === "string" ? stored.baseUrl : fallback.providers[provider].baseUrl,
|
||||
username: typeof stored.username === "string" ? stored.username : "",
|
||||
tokenStored: stored.tokenStored === true,
|
||||
};
|
||||
}
|
||||
|
||||
const storedOrganizations = (value as Partial<GitIntegrationSettings>).azureDevOpsOrganizations;
|
||||
if (Array.isArray(storedOrganizations)) {
|
||||
const seen = new Set<string>();
|
||||
fallback.azureDevOpsOrganizations = storedOrganizations
|
||||
.map(normaliseAzureOrganization)
|
||||
.filter((organization): organization is AzureDevOpsOrganization => {
|
||||
if (!organization || seen.has(organization.id)) return false;
|
||||
seen.add(organization.id);
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
const legacy = fallback.providers["azure-devops"];
|
||||
const hasLegacyConfiguration = legacy.enabled || legacy.tokenStored || legacy.username.trim().length > 0 || !/^https:\/\/dev\.azure\.com\/?$/i.test(legacy.baseUrl.trim());
|
||||
if (hasLegacyConfiguration) {
|
||||
fallback.azureDevOpsOrganizations = [{
|
||||
id: "default",
|
||||
name: "Azure DevOps",
|
||||
enabled: legacy.enabled,
|
||||
baseUrl: legacy.baseUrl,
|
||||
username: legacy.username,
|
||||
tokenStored: legacy.tokenStored,
|
||||
}];
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function integrationCredentialKey(provider: GitIntegrationProvider, accountId?: string): string {
|
||||
if (provider === "azure-devops" && accountId && accountId !== "default") {
|
||||
return `integration:${provider}:${accountId}`;
|
||||
}
|
||||
return `integration:${provider}`;
|
||||
}
|
||||
|
||||
export function configuredIntegrationSources(settings: GitIntegrationSettings): GitIntegrationSource[] {
|
||||
const sources: GitIntegrationSource[] = [];
|
||||
for (const provider of gitIntegrationProviders) {
|
||||
if (provider === "azure-devops") continue;
|
||||
const config = settings.providers[provider];
|
||||
if (config.enabled && config.tokenStored && config.baseUrl.trim()) {
|
||||
sources.push({ id: provider, provider, label: providerLabel(provider), baseUrl: config.baseUrl });
|
||||
}
|
||||
}
|
||||
for (const organization of settings.azureDevOpsOrganizations) {
|
||||
if (!organization.enabled || !organization.tokenStored || !organization.baseUrl.trim()) continue;
|
||||
sources.push({
|
||||
id: `azure-devops:${organization.id}`,
|
||||
provider: "azure-devops",
|
||||
accountId: organization.id,
|
||||
label: organization.name.trim() || organizationNameFromUrl(organization.baseUrl) || "Azure DevOps",
|
||||
baseUrl: organization.baseUrl,
|
||||
});
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
export function configuredIntegrationCount(settings: GitIntegrationSettings): number {
|
||||
return configuredIntegrationSources(settings).length;
|
||||
}
|
||||
|
||||
export function providerLabel(provider: GitIntegrationProvider): string {
|
||||
return {
|
||||
gitlab: "GitLab.com",
|
||||
"gitlab-self-hosted": "GitLab Self-Managed",
|
||||
"azure-devops": "Azure DevOps",
|
||||
gitea: "Gitea",
|
||||
}[provider];
|
||||
}
|
||||
|
||||
export function organizationNameFromUrl(value: string): string {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.pathname.split("/").filter(Boolean)[0] ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user