* 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>
179 lines
5.3 KiB
TypeScript
179 lines
5.3 KiB
TypeScript
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import {
|
|
createCachedCliPathResolver,
|
|
createForgeCliRunner,
|
|
probeHostViaCliAuthStatus,
|
|
} from "./forge-cli-command.js";
|
|
import { isPlatform } from "../test-utils/platform.js";
|
|
|
|
describe.skipIf(isPlatform("win32"))("probeHostViaCliAuthStatus", () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), "forge-cli-command-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function writeFakeCli(script: string): string {
|
|
const cliPath = join(tempDir, "forge-cli");
|
|
writeFileSync(cliPath, script);
|
|
chmodSync(cliPath, 0o755);
|
|
return cliPath;
|
|
}
|
|
|
|
it("recognizes a host when the CLI auth status succeeds", async () => {
|
|
const cli = writeFakeCli(`#!/bin/sh
|
|
if [ "$1" = "--version" ]; then
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "auth" ] && [ "$2" = "status" ] && [ "$3" = "--hostname" ] && [ "$4" = "git.acme.internal" ]; then
|
|
exit 0
|
|
fi
|
|
exit 1
|
|
`);
|
|
|
|
await expect(
|
|
probeHostViaCliAuthStatus({
|
|
cli,
|
|
host: "git.acme.internal",
|
|
}),
|
|
).resolves.toBe(true);
|
|
});
|
|
|
|
it("rejects a host when the CLI auth status fails", async () => {
|
|
const cli = writeFakeCli(`#!/bin/sh
|
|
if [ "$1" = "--version" ]; then
|
|
exit 0
|
|
fi
|
|
exit 1
|
|
`);
|
|
|
|
await expect(
|
|
probeHostViaCliAuthStatus({
|
|
cli,
|
|
host: "git.acme.internal",
|
|
}),
|
|
).resolves.toBe(false);
|
|
});
|
|
|
|
it("rejects a host when the CLI is missing", async () => {
|
|
await expect(
|
|
probeHostViaCliAuthStatus({
|
|
cli: join(tempDir, "missing-cli"),
|
|
host: "git.acme.internal",
|
|
}),
|
|
).resolves.toBe(false);
|
|
});
|
|
});
|
|
|
|
describe.skipIf(isPlatform("win32"))("createForgeCliRunner", () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), "forge-cli-runner-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function writeSlowCli(): string {
|
|
const cliPath = join(tempDir, "slow-cli");
|
|
writeFileSync(cliPath, "#!/bin/sh\nsleep 5\n");
|
|
chmodSync(cliPath, 0o755);
|
|
return cliPath;
|
|
}
|
|
|
|
class FakeCommandError extends Error {
|
|
readonly stderr: string;
|
|
constructor(params: { stderr: string }) {
|
|
super("command failed");
|
|
this.stderr = params.stderr;
|
|
}
|
|
}
|
|
|
|
it("kills a hung CLI invocation after the configured timeout", async () => {
|
|
const cli = writeSlowCli();
|
|
const runner = createForgeCliRunner({
|
|
binary: cli,
|
|
envOverlay: {},
|
|
timeoutMs: 100,
|
|
isAuthFailureText: () => false,
|
|
errorClasses: {
|
|
isAlreadyClassified: () => false,
|
|
isCommandError: (error): error is FakeCommandError => error instanceof FakeCommandError,
|
|
createAuthError: (stderr) => new Error(`auth: ${stderr}`),
|
|
createMissingError: () => new Error("missing"),
|
|
createCommandError: (params) => new FakeCommandError(params),
|
|
},
|
|
});
|
|
|
|
await expect(runner.run(["arg"], { cwd: tempDir })).rejects.toThrow();
|
|
});
|
|
|
|
it("classifies a timed-out invocation through normalizeError", async () => {
|
|
const cli = writeSlowCli();
|
|
const runner = createForgeCliRunner({
|
|
binary: cli,
|
|
envOverlay: {},
|
|
timeoutMs: 100,
|
|
isAuthFailureText: () => false,
|
|
errorClasses: {
|
|
isAlreadyClassified: () => false,
|
|
isCommandError: (error): error is FakeCommandError => error instanceof FakeCommandError,
|
|
createAuthError: (stderr) => new Error(`auth: ${stderr}`),
|
|
createMissingError: () => new Error("missing"),
|
|
createCommandError: (params) => new FakeCommandError(params),
|
|
},
|
|
});
|
|
|
|
const rawError = await runner.run(["arg"], { cwd: tempDir }).catch((error: unknown) => error);
|
|
const normalized = runner.normalizeError(rawError, { args: ["arg"], cwd: tempDir });
|
|
expect(normalized).toBeInstanceOf(FakeCommandError);
|
|
expect((normalized as FakeCommandError).stderr).toMatch(/timed out after 100ms/);
|
|
});
|
|
});
|
|
|
|
describe("createCachedCliPathResolver", () => {
|
|
it("resolves once and reuses the cached path on later calls", async () => {
|
|
let calls = 0;
|
|
const resolveCliPath = createCachedCliPathResolver(async () => {
|
|
calls += 1;
|
|
return "/usr/bin/forge-cli";
|
|
});
|
|
|
|
await expect(resolveCliPath()).resolves.toBe("/usr/bin/forge-cli");
|
|
await expect(resolveCliPath()).resolves.toBe("/usr/bin/forge-cli");
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it("retries on the next call after a miss instead of caching null forever", async () => {
|
|
let calls = 0;
|
|
const resolveCliPath = createCachedCliPathResolver(async () => {
|
|
calls += 1;
|
|
return calls === 1 ? null : "/usr/bin/forge-cli";
|
|
});
|
|
|
|
await expect(resolveCliPath()).resolves.toBeNull();
|
|
await expect(resolveCliPath()).resolves.toBe("/usr/bin/forge-cli");
|
|
expect(calls).toBe(2);
|
|
});
|
|
|
|
it("coalesces concurrent callers onto a single in-flight resolution", async () => {
|
|
let calls = 0;
|
|
const resolveCliPath = createCachedCliPathResolver(async () => {
|
|
calls += 1;
|
|
return "/usr/bin/forge-cli";
|
|
});
|
|
|
|
await Promise.all([resolveCliPath(), resolveCliPath(), resolveCliPath()]);
|
|
expect(calls).toBe(1);
|
|
});
|
|
});
|