fix(plugins): normalize marketplace release url per host

This commit is contained in:
lxk955
2026-10-17 13:48:44 +08:00
committed by GitHub
parent f382082c76
commit abe260010c
4 changed files with 181 additions and 5 deletions
@@ -63,6 +63,7 @@ import {
formatMarketplaceReleasedDate,
marketplaceHomepageUrl,
pluginSourceChange,
resolveMarketplaceReleaseUrl,
sortMarketplacePluginListings,
type InstalledPluginUpdateEntry,
type MarketplacePluginListing,
@@ -333,10 +334,7 @@ function marketplaceVersionPublishedLabel(version: PluginMarketplaceVersion): st
// The expanded entry links to the release page for that exact version in the
// plugin's source repository, mirroring the 更新管理 panel's GitHub Release button.
function marketplaceVersionReleaseUrl(version: PluginMarketplaceVersion): string {
const source = selectedMarketplaceListing.value?.plugin.source?.trim();
if (!source) return "";
const tag = version.version.startsWith("v") ? version.version : `v${version.version}`;
return `${source.replace(/\/+$/, "")}/releases/tag/${encodeURIComponent(tag)}`;
return resolveMarketplaceReleaseUrl(selectedMarketplaceListing.value?.plugin.source, version);
}
const batchUpdatableListings = computed(() => filteredMarketplaceListings.value.filter((listing) => listing.status === "update"));
const batchSelectedListings = computed(() => filteredMarketplaceListings.value.filter((listing) => isBatchSelectableListing(listing.status) && selectedListingKeys.value.has(listing.key)));
@@ -1700,6 +1700,23 @@ describe("marketplace detail update history", () => {
expect(openSpy).toHaveBeenCalledWith("https://github.com/jinpy/dbx-ssh-plugin/releases/tag/v3.0.0", "_blank", "noopener,noreferrer");
openSpy.mockRestore();
});
it("links expanded version to GitHub release page when source has a branch tree URL", async () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
const cat = multiVersionCatalog();
cat.catalog!.plugins[0].source = "https://github.com/Ezreal-byte/dbx-plugin-xxl-job-console/tree/v0.3.3";
state.catalogResults = [cat];
await flushUi();
state.openMarketplaceDetails(state.marketplaceListings[0]);
await flushUi();
const dialog = document.querySelector('[role="dialog"]')!;
const releaseButton = [...dialog.querySelectorAll("button")].find((element) => element.textContent?.includes("pluginPlatform.pluginDetailsOpenRelease"))!;
expect(releaseButton).toBeDefined();
releaseButton.click();
await flushUi();
expect(openSpy).toHaveBeenCalledWith("https://github.com/Ezreal-byte/dbx-plugin-xxl-job-console/releases/tag/v3.0.0", "_blank", "noopener,noreferrer");
openSpy.mockRestore();
});
});
describe("marketplace author filtering", () => {
+68
View File
@@ -3,10 +3,12 @@ import {
buildInstalledUpdateIndex,
buildMarketplacePluginListings,
compareVersions,
extractRepositoryBaseUrl,
filterMarketplacePluginListings,
formatMarketplaceReleasedDate,
marketplaceHomepageUrl,
pluginSourceChange,
resolveMarketplaceReleaseUrl,
selectMarketplaceArtifact,
sortMarketplacePluginListings,
type MarketplacePluginListing,
@@ -136,6 +138,72 @@ describe("plugin marketplace listings", () => {
expect(marketplaceHomepageUrl("https://github.com/dbxio/example/", "https://github.com/dbxio/example#readme")).toBeUndefined();
expect(marketplaceHomepageUrl("https://github.com/dbxio/example", "https://dbxio.com/plugins/example")).toBe("https://dbxio.com/plugins/example");
});
describe("resolveMarketplaceReleaseUrl", () => {
it("resolves GitHub branch and tag tree URLs to root repository release tag", () => {
// Regression for #12516: source with /tree/<ref> should not result in /tree/<ref>/releases/tag/...
expect(resolveMarketplaceReleaseUrl("https://github.com/Ezreal-byte/dbx-plugin-xxl-job-console/tree/v0.3.3", "0.3.3")).toBe("https://github.com/Ezreal-byte/dbx-plugin-xxl-job-console/releases/tag/v0.3.3");
expect(resolveMarketplaceReleaseUrl("https://github.com/Ezreal-byte/dbx-plugin-xxl-job-console/tree/v0.3.3/", "0.3.3")).toBe("https://github.com/Ezreal-byte/dbx-plugin-xxl-job-console/releases/tag/v0.3.3");
expect(resolveMarketplaceReleaseUrl("https://github.com/t8y2/dbx/tree/main/plugins/examples/hello-workbench", "0.1.0")).toBe("https://github.com/t8y2/dbx/releases/tag/v0.1.0");
});
it("resolves standard repository URLs with and without trailing slash or .git", () => {
expect(resolveMarketplaceReleaseUrl("https://github.com/jinpy/dbx-ssh-plugin", "3.0.0")).toBe("https://github.com/jinpy/dbx-ssh-plugin/releases/tag/v3.0.0");
expect(resolveMarketplaceReleaseUrl("https://github.com/jinpy/dbx-ssh-plugin/", "3.0.0")).toBe("https://github.com/jinpy/dbx-ssh-plugin/releases/tag/v3.0.0");
expect(resolveMarketplaceReleaseUrl("https://github.com/jinpy/dbx-ssh-plugin.git", "3.0.0")).toBe("https://github.com/jinpy/dbx-ssh-plugin/releases/tag/v3.0.0");
expect(resolveMarketplaceReleaseUrl("https://github.com/jinpy/dbx-ssh-plugin", "v3.0.0")).toBe("https://github.com/jinpy/dbx-ssh-plugin/releases/tag/v3.0.0");
});
it("resolves blob, releases, and ssh URLs", () => {
expect(resolveMarketplaceReleaseUrl("https://github.com/owner/repo/blob/main/README.md", "1.0.0")).toBe("https://github.com/owner/repo/releases/tag/v1.0.0");
expect(resolveMarketplaceReleaseUrl("https://github.com/owner/repo/releases", "1.0.0")).toBe("https://github.com/owner/repo/releases/tag/v1.0.0");
expect(resolveMarketplaceReleaseUrl("https://github.com/owner/repo/releases/tag/v0.1.0", "1.0.0")).toBe("https://github.com/owner/repo/releases/tag/v1.0.0");
expect(resolveMarketplaceReleaseUrl("git@github.com:Ezreal-byte/dbx-plugin-xxl-job-console.git", "0.3.3")).toBe("https://github.com/Ezreal-byte/dbx-plugin-xxl-job-console/releases/tag/v0.3.3");
});
it("supports Gitee, GitLab, and CNB repository formats", () => {
expect(resolveMarketplaceReleaseUrl("https://gitee.com/someone/my-plugin/tree/master", "1.0.0")).toBe("https://gitee.com/someone/my-plugin/releases/tag/v1.0.0");
expect(resolveMarketplaceReleaseUrl("https://gitlab.com/group/subgroup/my-plugin/-/tree/main", "1.0.0")).toBe("https://gitlab.com/group/subgroup/my-plugin/-/releases/v1.0.0");
expect(resolveMarketplaceReleaseUrl("https://cnb.cool/dbxio.com/my-plugin/-/tree/main", "1.0.0")).toBe("https://cnb.cool/dbxio.com/my-plugin/-/releases/tag/v1.0.0");
});
it("accepts PluginMarketplaceVersion objects and handles empty inputs gracefully", () => {
expect(
resolveMarketplaceReleaseUrl("https://github.com/owner/repo", {
version: "2.1.0",
artifacts: [],
}),
).toBe("https://github.com/owner/repo/releases/tag/v2.1.0");
expect(resolveMarketplaceReleaseUrl("", "1.0.0")).toBe("");
expect(resolveMarketplaceReleaseUrl(" ", "1.0.0")).toBe("");
expect(resolveMarketplaceReleaseUrl(undefined, "1.0.0")).toBe("");
expect(resolveMarketplaceReleaseUrl(null, "1.0.0")).toBe("");
expect(resolveMarketplaceReleaseUrl("https://github.com/owner/repo", "")).toBe("");
expect(resolveMarketplaceReleaseUrl("https://github.com/owner/repo", " ")).toBe("");
expect(resolveMarketplaceReleaseUrl("https://github.com/owner/repo", undefined)).toBe("");
expect(resolveMarketplaceReleaseUrl("https://github.com/owner/repo", { version: "", artifacts: [] })).toBe("");
});
it("extracts base repository URL from various git URL formats", () => {
expect(extractRepositoryBaseUrl("https://github.com/Ezreal-byte/dbx-plugin-xxl-job-console/tree/v0.3.3")).toEqual({
baseUrl: "https://github.com/Ezreal-byte/dbx-plugin-xxl-job-console",
host: "github.com",
});
expect(extractRepositoryBaseUrl("https://github.com/owner/repo.git")).toEqual({
baseUrl: "https://github.com/owner/repo",
host: "github.com",
});
expect(extractRepositoryBaseUrl("git@github.com:owner/repo.git")).toEqual({
baseUrl: "https://github.com/owner/repo",
host: "github.com",
});
expect(extractRepositoryBaseUrl("")).toBeNull();
expect(extractRepositoryBaseUrl(" ")).toBeNull();
});
});
});
describe("buildInstalledUpdateIndex", () => {
@@ -1,4 +1,4 @@
import type { InstalledPlugin, PluginMarketplaceArtifact, PluginMarketplacePlugin, PluginRepository, PluginRepositoryCatalogResult } from "@/types/database";
import type { InstalledPlugin, PluginMarketplaceArtifact, PluginMarketplacePlugin, PluginMarketplaceVersion, PluginRepository, PluginRepositoryCatalogResult } from "@/types/database";
import { uuid } from "@/lib/common/utils";
export type MarketplacePluginStatus = "install" | "installed" | "update" | "unsupported";
@@ -36,6 +36,99 @@ export function marketplaceHomepageUrl(source?: string, homepage?: string): stri
return homepage?.trim() || undefined;
}
/**
* Resolves the external release URL for a specific plugin version.
*
* Plugin catalogs often provide repository URLs pointing to a specific branch,
* tag, or subfolder (e.g. `https://github.com/owner/repo/tree/v0.3.3` or
* `https://github.com/owner/repo/tree/main/packages/plugin`), or git clone URLs
* (`.git`). This extracts the base repository root and appends the appropriate
* release path for the given version tag.
*/
export function resolveMarketplaceReleaseUrl(source?: string | null, version?: string | PluginMarketplaceVersion | null): string {
const trimmedSource = source?.trim();
const rawVersion = typeof version === "string" ? version.trim() : (version?.version?.trim() ?? "");
if (!trimmedSource || !rawVersion) return "";
const tag = rawVersion.startsWith("v") || rawVersion.startsWith("V") ? rawVersion : `v${rawVersion}`;
const repo = extractRepositoryBaseUrl(trimmedSource);
if (!repo) return "";
if (repo.host === "cnb.cool" || repo.host.endsWith(".cnb.cool")) {
return `${repo.baseUrl}/-/releases/tag/${encodeURIComponent(tag)}`;
}
if (repo.host === "gitlab.com" || repo.host.endsWith(".gitlab.com")) {
return `${repo.baseUrl}/-/releases/${encodeURIComponent(tag)}`;
}
return `${repo.baseUrl}/releases/tag/${encodeURIComponent(tag)}`;
}
export function extractRepositoryBaseUrl(source: string): { baseUrl: string; host: string } | null {
const trimmed = source.trim();
if (!trimmed) return null;
// Handle SSH URLs: git@github.com:owner/repo.git or ssh://git@github.com/owner/repo.git
const sshMatch = /^(?:ssh:\/\/)?git@([^/:]+)[:/](.+)$/i.exec(trimmed);
let normalized = trimmed;
if (sshMatch) {
const host = sshMatch[1];
const path = sshMatch[2].replace(/\.git$/i, "").replace(/^\/+/, "");
normalized = `https://${host}/${path}`;
} else if (!/^https?:\/\//i.test(normalized)) {
normalized = `https://${normalized}`;
}
let parsed: URL;
try {
parsed = new URL(normalized);
} catch {
const cleaned = trimmed
.replace(/\/(?:-?\/)?(?:tree|blob|releases|src)(?:\/.*)?$/i, "")
.replace(/\.git$/i, "")
.replace(/\/+$/, "");
return cleaned ? { baseUrl: cleaned, host: "" } : null;
}
const host = parsed.hostname.toLowerCase();
const origin = parsed.origin;
const segments = parsed.pathname.split("/").filter(Boolean);
if (segments.length === 0) return null;
// On GitHub and Gitee, repositories are strictly <owner>/<repo>.
// Any trailing segment (e.g. /tree/<ref>, /blob/<ref>, /releases) is not part of the repository.
if (host === "github.com" || host.endsWith(".github.com") || host === "gitee.com" || host.endsWith(".gitee.com")) {
if (segments.length >= 2) {
const owner = segments[0];
const repoName = segments[1].replace(/\.git$/i, "");
return { baseUrl: `${origin}/${owner}/${repoName}`, host };
}
return { baseUrl: `${origin}/${segments[0].replace(/\.git$/i, "")}`, host };
}
// On GitLab, CNB, etc., UI routes (tree, blob, releases, etc.) follow a "/-/" segment:
// e.g. https://gitlab.com/group/subgroup/project/-/tree/main
// e.g. https://cnb.cool/org/project/-/releases/tag/v1.0.0
const dashIndex = segments.indexOf("-");
if (dashIndex > 0) {
const repoSegments = segments.slice(0, dashIndex);
repoSegments[repoSegments.length - 1] = repoSegments[repoSegments.length - 1].replace(/\.git$/i, "");
return { baseUrl: `${origin}/${repoSegments.join("/")}`, host };
}
// Generic git hosts (Gitea, Bitbucket, self-hosted, etc.):
// Strip trailing branches/trees/blobs/releases/src if present
const cutIndex = segments.findIndex((seg) => ["tree", "blob", "releases", "src"].includes(seg.toLowerCase()));
if (cutIndex > 0) {
const repoSegments = segments.slice(0, cutIndex);
repoSegments[repoSegments.length - 1] = repoSegments[repoSegments.length - 1].replace(/\.git$/i, "");
return { baseUrl: `${origin}/${repoSegments.join("/")}`, host };
}
// Default: strip .git from the last segment
segments[segments.length - 1] = segments[segments.length - 1].replace(/\.git$/i, "");
return { baseUrl: `${origin}/${segments.join("/")}`, host };
}
export function buildMarketplacePluginListings(results: readonly PluginRepositoryCatalogResult[], installedPlugins: readonly InstalledPlugin[], locale: string): MarketplacePluginListing[] {
const installedById = new Map(installedPlugins.map((plugin) => [plugin.manifest.id, plugin]));
return results