mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 15:34:39 +00:00
* refactor(forge): forge-neutral foundation (GitHub-only) Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged. - Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry. - GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved. - forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client). - createPullRequest reports typed CLI/auth errors instead of a generic message. - forge-resolver host/remote caches are LRU-bounded. - Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com. - Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology. - Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs. * feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals. - threadIsResolved is part of the neutral timeline item. - Pipeline load failures show an error instead of an empty section. - Manual pipeline jobs render as pending. - Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request). * feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg) One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts. - CI status aggregates commit statuses and Actions runs together. - Gitea's terminal "warning" state maps to failure on server and client. - Gitea Actions check details are reachable from the PR pane by workflowRunId. * refactor(forge): localize compatibility handling * test(forge): expect normalized GitLab facts --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
377 lines
15 KiB
TypeScript
377 lines
15 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { createForgeService } from "./forge-registry.js";
|
|
import { createForgeResolver, forgeForHost, parseRemoteHost } from "./forge-resolver.js";
|
|
|
|
function createSshHostnameResolver(hostnameByAlias: Record<string, string | null>) {
|
|
return vi.fn(async (host: string): Promise<string | null> => {
|
|
return hostnameByAlias[host] ?? null;
|
|
});
|
|
}
|
|
|
|
async function resolveSshHostnameAsLiteralHost(host: string): Promise<string | null> {
|
|
return host;
|
|
}
|
|
|
|
describe("parseRemoteHost", () => {
|
|
it("parses ssh and https remotes", () => {
|
|
expect(parseRemoteHost("git@github.com:owner/repo.git")).toBe("github.com");
|
|
expect(parseRemoteHost("git@gitlab.example.com:group/sub/repo.git")).toBe("gitlab.example.com");
|
|
expect(parseRemoteHost("https://GitLab.Example.Com./group/repo.git")).toBe(
|
|
"gitlab.example.com",
|
|
);
|
|
expect(parseRemoteHost("not a url")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("forgeForHost", () => {
|
|
it("maps public registered forge hosts without resolver-specific branches", () => {
|
|
expect(forgeForHost("github.com")).toBe("github");
|
|
expect(forgeForHost("gitlab.com")).toBe("gitlab");
|
|
expect(forgeForHost("gitea.com")).toBe("gitea");
|
|
expect(forgeForHost("codeberg.org")).toBe("codeberg");
|
|
});
|
|
|
|
it("returns null for hosts with no known adapter", () => {
|
|
expect(forgeForHost("example.com")).toBeNull();
|
|
expect(forgeForHost("bitbucket.org")).toBeNull();
|
|
expect(forgeForHost("gitlab.example.com")).toBeNull();
|
|
expect(forgeForHost("forgejo.example.org")).toBeNull();
|
|
expect(forgeForHost("notgitlab.example.org")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("createForgeResolver", () => {
|
|
it("resolves a github.com remote to the github forge", async () => {
|
|
const resolver = createForgeResolver({
|
|
resolveRemoteUrl: async () => "git@github.com:owner/repo.git",
|
|
});
|
|
const resolution = await resolver.resolve("/repo");
|
|
expect(resolution).toMatchObject({ forge: "github", host: "github.com" });
|
|
expect(resolution?.service.getCurrentPullRequestStatus).toBeTypeOf("function");
|
|
});
|
|
|
|
it("resolves a self-managed GitLab remote through the per-host probe", async () => {
|
|
const probeForge = vi.fn(async () => "gitlab");
|
|
const resolver = createForgeResolver({
|
|
resolveRemoteUrl: async () => "git@gitlab.example.com:example-group/example-project.git",
|
|
probeForge,
|
|
resolveSshHostname: resolveSshHostnameAsLiteralHost,
|
|
});
|
|
const resolution = await resolver.resolve("/repo");
|
|
expect(resolution).toMatchObject({ forge: "gitlab", host: "gitlab.example.com" });
|
|
expect(probeForge).toHaveBeenCalledWith("gitlab.example.com");
|
|
});
|
|
|
|
it("resolves an SSH alias that maps to github.com", async () => {
|
|
const probeForge = vi.fn(async () => {
|
|
throw new Error("github cloud alias should not probe CLI auth");
|
|
});
|
|
const resolveSshHostname = createSshHostnameResolver({ "github-work": "github.com" });
|
|
const resolver = createForgeResolver({
|
|
resolveRemoteUrl: async () => "git@github-work:acme/repo.git",
|
|
probeForge,
|
|
resolveSshHostname,
|
|
});
|
|
|
|
await expect(resolver.resolve("/repo")).resolves.toMatchObject({
|
|
forge: "github",
|
|
host: "github.com",
|
|
});
|
|
expect(resolveSshHostname).toHaveBeenCalledWith("github-work");
|
|
expect(probeForge).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("probes the resolved host for an SSH alias to a self-managed forge", async () => {
|
|
const probeForge = vi.fn(async () => "gitlab");
|
|
const resolveSshHostname = createSshHostnameResolver({
|
|
"gitlab-work": "gitlab.example.com",
|
|
});
|
|
const resolver = createForgeResolver({
|
|
resolveRemoteUrl: async () => "git@gitlab-work:example-group/example-project.git",
|
|
probeForge,
|
|
resolveSshHostname,
|
|
});
|
|
|
|
await expect(resolver.resolve("/repo")).resolves.toMatchObject({
|
|
forge: "gitlab",
|
|
host: "gitlab.example.com",
|
|
});
|
|
expect(probeForge).toHaveBeenCalledWith("gitlab.example.com");
|
|
});
|
|
|
|
it("lets the sync resolver reuse an SSH-alias forge discovered by an async resolve", async () => {
|
|
const remoteUrl = "git@github-work:acme/repo.git";
|
|
const resolver = createForgeResolver({
|
|
resolveRemoteUrl: async () => remoteUrl,
|
|
probeForge: async () => null,
|
|
resolveSshHostname: createSshHostnameResolver({ "github-work": "github.com" }),
|
|
});
|
|
|
|
expect(resolver.resolveFromRemoteUrl(remoteUrl)).toBeNull();
|
|
await resolver.resolve("/repo");
|
|
expect(resolver.resolveFromRemoteUrl(remoteUrl)).toMatchObject({ forge: "github" });
|
|
});
|
|
|
|
it("lets the sync resolver reuse an SSH-alias forge discovered through the probe", async () => {
|
|
const remoteUrl = "git@gitlab-work:example-group/example-project.git";
|
|
const resolver = createForgeResolver({
|
|
resolveRemoteUrl: async () => remoteUrl,
|
|
probeForge: async () => "gitlab",
|
|
resolveSshHostname: createSshHostnameResolver({ "gitlab-work": "gitlab.example.com" }),
|
|
});
|
|
|
|
expect(resolver.resolveFromRemoteUrl(remoteUrl)).toBeNull();
|
|
await resolver.resolve("/repo");
|
|
expect(resolver.resolveFromRemoteUrl(remoteUrl)).toMatchObject({ forge: "gitlab" });
|
|
});
|
|
|
|
it("returns null when an SSH alias cannot be resolved to a forge host", async () => {
|
|
const probeForge = vi.fn(async () => null);
|
|
const resolveSshHostname = createSshHostnameResolver({ "github-work": null });
|
|
const resolver = createForgeResolver({
|
|
resolveRemoteUrl: async () => "git@github-work:acme/repo.git",
|
|
probeForge,
|
|
resolveSshHostname,
|
|
});
|
|
|
|
await expect(resolver.resolve("/repo")).resolves.toBeNull();
|
|
});
|
|
|
|
it("does not use SSH hostname resolution for non-SSH remotes", async () => {
|
|
const probeForge = vi.fn(async () => null);
|
|
const resolveSshHostname = createSshHostnameResolver({ "github-work": "github.com" });
|
|
const resolver = createForgeResolver({
|
|
resolveRemoteUrl: async () => "https://github-work/acme/repo.git",
|
|
probeForge,
|
|
resolveSshHostname,
|
|
});
|
|
|
|
await expect(resolver.resolve("/repo")).resolves.toBeNull();
|
|
expect(resolveSshHostname).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("resolves Gitea and Codeberg remotes to their registered top-level forges", async () => {
|
|
const gitea = createForgeResolver({
|
|
resolveRemoteUrl: async () => "https://gitea.com/example/repo.git",
|
|
});
|
|
const codeberg = createForgeResolver({
|
|
resolveRemoteUrl: async () => "git@codeberg.org:example/repo.git",
|
|
});
|
|
|
|
await expect(gitea.resolve("/gitea")).resolves.toMatchObject({
|
|
forge: "gitea",
|
|
host: "gitea.com",
|
|
});
|
|
await expect(codeberg.resolve("/codeberg")).resolves.toMatchObject({
|
|
forge: "codeberg",
|
|
host: "codeberg.org",
|
|
});
|
|
});
|
|
|
|
it("does not classify an overlapping gitea-forgejo hostname without a probe", async () => {
|
|
const probeForge = vi.fn(async () => null);
|
|
const resolver = createForgeResolver({
|
|
resolveRemoteUrl: async () => "git@gitea-forgejo.example.org:example/repo.git",
|
|
probeForge,
|
|
resolveSshHostname: resolveSshHostnameAsLiteralHost,
|
|
});
|
|
|
|
await expect(resolver.resolve("/repo")).resolves.toBeNull();
|
|
expect(probeForge).toHaveBeenCalledWith("gitea-forgejo.example.org");
|
|
});
|
|
|
|
it("returns null when the cwd has no origin remote", async () => {
|
|
const resolver = createForgeResolver({ resolveRemoteUrl: async () => null });
|
|
expect(await resolver.resolve("/repo")).toBeNull();
|
|
});
|
|
|
|
it("degrades to no forge when the host probe throws instead of crashing resolution", async () => {
|
|
const probeForge = vi.fn(async () => {
|
|
throw new Error("registry probe blew up");
|
|
});
|
|
const resolver = createForgeResolver({
|
|
resolveRemoteUrl: async () => "git@git.acme.internal:team/repo.git",
|
|
probeForge,
|
|
resolveSshHostname: resolveSshHostnameAsLiteralHost,
|
|
});
|
|
|
|
await expect(resolver.resolve("/repo")).resolves.toBeNull();
|
|
expect(probeForge).toHaveBeenCalledWith("git.acme.internal");
|
|
});
|
|
|
|
it("reuses one adapter instance per forge across resolutions", async () => {
|
|
let built = 0;
|
|
const probeForge = vi.fn(async () => "gitlab");
|
|
const resolver = createForgeResolver({
|
|
resolveRemoteUrl: async () => "git@gitlab.example.com:group/repo.git",
|
|
probeForge,
|
|
resolveSshHostname: resolveSshHostnameAsLiteralHost,
|
|
createService: (forge) => {
|
|
built += 1;
|
|
return createForgeService(forge);
|
|
},
|
|
});
|
|
const first = await resolver.resolve("/a");
|
|
const second = await resolver.resolve("/b");
|
|
expect(built).toBe(1);
|
|
expect(probeForge).toHaveBeenCalledTimes(1);
|
|
expect(first?.service).toBe(second?.service);
|
|
});
|
|
|
|
it("memoizes the remote-url resolution per cwd", async () => {
|
|
const resolveRemoteUrl = vi.fn(async () => "git@github.com:owner/repo.git");
|
|
const resolver = createForgeResolver({ resolveRemoteUrl });
|
|
|
|
await resolver.resolve("/repo");
|
|
await resolver.resolve("/repo");
|
|
expect(resolveRemoteUrl).toHaveBeenCalledTimes(1);
|
|
|
|
await resolver.resolve("/other-repo");
|
|
expect(resolveRemoteUrl).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("re-resolves the remote url for a cwd after invalidate()", async () => {
|
|
const resolveRemoteUrl = vi.fn(async () => "git@github.com:owner/repo.git");
|
|
const resolver = createForgeResolver({ resolveRemoteUrl });
|
|
|
|
await resolver.resolve("/repo");
|
|
resolver.invalidate("/repo");
|
|
await resolver.resolve("/repo");
|
|
|
|
expect(resolveRemoteUrl).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("keeps serving the cached remote url until the TTL expires", async () => {
|
|
let currentTime = 0;
|
|
let remoteUrl = "git@github.com:owner/repo.git";
|
|
const resolver = createForgeResolver({
|
|
resolveRemoteUrl: async () => remoteUrl,
|
|
now: () => currentTime,
|
|
});
|
|
|
|
await expect(resolver.resolve("/repo")).resolves.toMatchObject({ forge: "github" });
|
|
|
|
// A remote changed outside Paseo (`git remote set-url` from a terminal)
|
|
// does not go through invalidate(), so the cache must still serve the
|
|
// stale value until its TTL expires.
|
|
remoteUrl = "git@gitlab.com:owner/repo.git";
|
|
currentTime += 59_000;
|
|
await expect(resolver.resolve("/repo")).resolves.toMatchObject({ forge: "github" });
|
|
|
|
currentTime += 2_000;
|
|
await expect(resolver.resolve("/repo")).resolves.toMatchObject({ forge: "gitlab" });
|
|
});
|
|
|
|
it("re-resolves the remote url immediately after invalidate(), before the TTL expires", async () => {
|
|
let currentTime = 0;
|
|
let remoteUrl = "git@github.com:owner/repo.git";
|
|
const resolver = createForgeResolver({
|
|
resolveRemoteUrl: async () => remoteUrl,
|
|
now: () => currentTime,
|
|
});
|
|
|
|
await expect(resolver.resolve("/repo")).resolves.toMatchObject({ forge: "github" });
|
|
|
|
remoteUrl = "git@gitlab.com:owner/repo.git";
|
|
currentTime += 1_000;
|
|
resolver.invalidate("/repo");
|
|
|
|
await expect(resolver.resolve("/repo")).resolves.toMatchObject({ forge: "gitlab" });
|
|
});
|
|
|
|
it("detects a self-managed GitLab host with no name hint via the per-host probe", async () => {
|
|
const probeForge = vi.fn(async () => "gitlab");
|
|
const resolver = createForgeResolver({
|
|
resolveRemoteUrl: async () => "git@git.acme.internal:team/repo.git",
|
|
probeForge,
|
|
resolveSshHostname: resolveSshHostnameAsLiteralHost,
|
|
});
|
|
const resolution = await resolver.resolve("/repo");
|
|
expect(resolution).toMatchObject({ forge: "gitlab", host: "git.acme.internal" });
|
|
expect(probeForge).toHaveBeenCalledWith("git.acme.internal");
|
|
});
|
|
|
|
it("skips the probe when the name heuristic already resolves the host", async () => {
|
|
const probeForge = vi.fn(async () => null);
|
|
const resolver = createForgeResolver({
|
|
resolveRemoteUrl: async () => "git@github.com:owner/repo.git",
|
|
probeForge,
|
|
});
|
|
const resolution = await resolver.resolve("/repo");
|
|
expect(resolution).toMatchObject({ forge: "github", host: "github.com" });
|
|
expect(probeForge).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
["github", "git@github.com:owner/repo.git", "github.com"],
|
|
["gitlab", "git@gitlab.com:group/repo.git", "gitlab.com"],
|
|
["gitea", "git@gitea.com:owner/repo.git", "gitea.com"],
|
|
["codeberg", "git@codeberg.org:owner/repo.git", "codeberg.org"],
|
|
])("resolves the %s cloud host without probing CLI auth", async (forge, remoteUrl, host) => {
|
|
const probeForge = vi.fn(async () => {
|
|
throw new Error("cloud host should not probe");
|
|
});
|
|
const resolver = createForgeResolver({
|
|
resolveRemoteUrl: async () => remoteUrl,
|
|
probeForge,
|
|
});
|
|
|
|
await expect(resolver.resolve("/repo")).resolves.toMatchObject({ forge, host });
|
|
expect(probeForge).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns null and probes a foreign host only once", async () => {
|
|
const probeForge = vi.fn(async () => null);
|
|
const resolver = createForgeResolver({
|
|
resolveRemoteUrl: async () => "git@bitbucket.org:owner/repo.git",
|
|
probeForge,
|
|
resolveSshHostname: resolveSshHostnameAsLiteralHost,
|
|
});
|
|
expect(await resolver.resolve("/a")).toBeNull();
|
|
expect(await resolver.resolve("/b")).toBeNull();
|
|
expect(probeForge).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("lets the synchronous resolveFromRemoteUrl reuse a probed forge", async () => {
|
|
const url = "git@git.acme.internal:team/repo.git";
|
|
const probeForge = vi.fn(async () => "gitlab");
|
|
const resolver = createForgeResolver({
|
|
resolveRemoteUrl: async () => url,
|
|
probeForge,
|
|
resolveSshHostname: resolveSshHostnameAsLiteralHost,
|
|
});
|
|
expect(resolver.resolveFromRemoteUrl(url)).toBeNull();
|
|
await resolver.resolve("/repo");
|
|
expect(resolver.resolveFromRemoteUrl(url)).toMatchObject({
|
|
forge: "gitlab",
|
|
host: "git.acme.internal",
|
|
});
|
|
expect(probeForge).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("coalesces concurrent probes of the same host into a single probe", async () => {
|
|
let resolveProbe: ((forge: string | null) => void) | undefined;
|
|
const probeForge = vi.fn(
|
|
() =>
|
|
new Promise<string | null>((resolve) => {
|
|
resolveProbe = resolve;
|
|
}),
|
|
);
|
|
const url = "git@git.acme.internal:team/repo.git";
|
|
const resolver = createForgeResolver({
|
|
probeForge,
|
|
resolveSshHostname: resolveSshHostnameAsLiteralHost,
|
|
});
|
|
const first = resolver.resolveFromRemoteUrlAsync(url);
|
|
const second = resolver.resolveFromRemoteUrlAsync(url);
|
|
await vi.waitFor(() => expect(probeForge).toHaveBeenCalledTimes(1));
|
|
expect(resolveProbe).toBeTypeOf("function");
|
|
resolveProbe?.("gitlab");
|
|
const [a, b] = await Promise.all([first, second]);
|
|
expect(a).toMatchObject({ forge: "gitlab", host: "git.acme.internal" });
|
|
expect(b).toMatchObject({ forge: "gitlab", host: "git.acme.internal" });
|
|
expect(probeForge).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|