feat(forge): pluggable forge abstraction + GitLab and Gitea/Forgejo/Codeberg (#1913)

* 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>
This commit is contained in:
nllptrx
2026-07-17 15:03:26 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 04d1ebdce0
commit a8ebd390fa
187 changed files with 20988 additions and 2907 deletions
@@ -0,0 +1,178 @@
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);
});
});
@@ -0,0 +1,325 @@
import { z } from "zod";
import { findExecutable } from "../executable-resolution/executable-resolution.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { execCommand } from "../utils/spawn.js";
/**
* Timeout for the per-host CLI auth probe (`gh`/`glab`/`tea` auth status) used
* to detect a self-hosted forge. This intentionally has no anonymous HTTP
* fallback: a remote-derived host is trusted only when the CLI already knows it.
*/
export const CLI_AUTH_PROBE_TIMEOUT_MS = 10_000;
interface CommandFailureLike {
code?: string | number | null;
killed?: boolean;
stderr?: string | Buffer;
stdout?: string | Buffer;
message?: string;
}
interface CliCommandErrorShape extends Error {
stderr: string;
}
interface NormalizeCliCommandErrorOptions {
error: unknown;
args: string[];
cwd: string;
commandName: string;
timeoutMs?: number;
isAlreadyClassified: (error: unknown) => boolean;
isCommandError: (error: unknown) => error is CliCommandErrorShape;
isAuthFailureText: (text: string) => boolean;
createAuthError: (stderr: string) => Error;
createMissingError: () => Error;
createCommandError: (params: {
args: string[];
cwd: string;
exitCode: number | null;
stderr: string;
}) => Error;
}
interface ParseCliJsonOutputOptions<T> {
commandName: string;
args: string[];
cwd: string;
stdout: string;
schema: z.ZodType<T>;
createCommandError: NormalizeCliCommandErrorOptions["createCommandError"];
}
interface ProbeHostViaCliAuthStatusOptions {
cli: string;
host: string;
envOverlay?: Record<string, string>;
}
export interface ForgeCliRunnerOptions {
cwd: string;
envOverlay?: Record<string, string>;
}
export interface ForgeCliRunnerResult {
stdout: string;
stderr: string;
}
export type ForgeCliRunner = (
args: string[],
options: ForgeCliRunnerOptions,
) => Promise<ForgeCliRunnerResult>;
export interface ForgeCliRunnerErrorClasses {
isAlreadyClassified: (error: unknown) => boolean;
isCommandError: (error: unknown) => error is CliCommandErrorShape;
createAuthError: (stderr: string) => Error;
createMissingError: () => Error;
createCommandError: (params: ForgeCommandFailureParams) => Error;
}
export interface CreateForgeCliRunnerOptions {
binary: string;
envOverlay: Record<string, string>;
timeoutMs: number;
isAuthFailureText: (text: string) => boolean;
errorClasses: ForgeCliRunnerErrorClasses;
}
export interface ForgeCliRunnerFactory {
run: ForgeCliRunner;
normalizeError: (
error: unknown,
context: { args: string[]; cwd: string; timeoutMs?: number },
) => Error;
}
/**
* Shared shape behind the gh/glab/tea CLI adapters: an exec wrapper (10MB
* maxBuffer, per-call env overlay merged over the binary's defaults, a
* timeout) plus error normalization through the binary's own error-class
* trio. The three adapters differ only in the params passed here.
*/
export function createForgeCliRunner(options: CreateForgeCliRunnerOptions): ForgeCliRunnerFactory {
const run: ForgeCliRunner = (args, runOptions) =>
execCommand(options.binary, args, {
cwd: runOptions.cwd,
envOverlay: { ...options.envOverlay, ...runOptions.envOverlay },
maxBuffer: 10 * 1024 * 1024,
timeout: options.timeoutMs,
});
function normalizeError(
error: unknown,
context: { args: string[]; cwd: string; timeoutMs?: number },
): Error {
return normalizeCliCommandError({
error,
args: context.args,
cwd: context.cwd,
commandName: options.binary,
timeoutMs: context.timeoutMs ?? options.timeoutMs,
isAlreadyClassified: options.errorClasses.isAlreadyClassified,
isCommandError: options.errorClasses.isCommandError,
isAuthFailureText: options.isAuthFailureText,
createAuthError: options.errorClasses.createAuthError,
createMissingError: options.errorClasses.createMissingError,
createCommandError: options.errorClasses.createCommandError,
});
}
return { run, normalizeError };
}
export class ForgeCliMissingError extends Error {
readonly kind = "missing-cli";
}
export class ForgeAuthenticationError extends Error {
readonly kind = "auth-failure";
readonly stderr: string;
constructor(message: string, params: { stderr: string }) {
super(message);
this.stderr = params.stderr;
}
}
export interface ForgeCommandFailureParams {
args: string[];
cwd: string;
exitCode: number | null;
stderr: string;
}
export class ForgeCommandError extends Error {
readonly kind = "command-error";
readonly args: string[];
readonly cwd: string;
readonly exitCode: number | null;
readonly stderr: string;
constructor(label: { brand: string; binary: string }, params: ForgeCommandFailureParams) {
super(`${label.brand} CLI command failed: ${label.binary} ${params.args.join(" ")}`);
this.args = [...params.args];
this.cwd = params.cwd;
this.exitCode = params.exitCode;
this.stderr = params.stderr;
}
}
/**
* Memoize a CLI path resolver (`findExecutable`) for the lifetime of a service
* instance. A found path is cached permanently; a miss (or a rejected probe)
* evicts the cache so a CLI installed after the daemon started is picked up
* on the next call instead of requiring a restart. Concurrent callers during
* resolution share the same in-flight promise.
*/
export function createCachedCliPathResolver(
resolve: () => Promise<string | null>,
): () => Promise<string | null> {
let pending: Promise<string | null> | null = null;
return function resolveCliPath(): Promise<string | null> {
if (pending) {
return pending;
}
const current: Promise<string | null> = resolve()
.then((path) => {
if (path === null && pending === current) {
pending = null;
}
return path;
})
.catch((error: unknown) => {
if (pending === current) {
pending = null;
}
throw error;
});
pending = current;
return current;
};
}
export function bufferOrStringToString(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (Buffer.isBuffer(value)) {
return value.toString("utf8");
}
return "";
}
export function normalizeCliCommandError(options: NormalizeCliCommandErrorOptions): Error {
if (options.isAlreadyClassified(options.error)) {
return options.error as Error;
}
if (options.isCommandError(options.error)) {
if (options.isAuthFailureText(options.error.stderr)) {
return options.createAuthError(options.error.stderr);
}
return options.error as Error;
}
const failure = toCommandFailureLike(options.error);
if (failure.code === "ENOENT") {
return options.createMissingError();
}
const stderr = bufferOrStringToString(failure.stderr);
const message = failure.message ?? "";
if (options.isAuthFailureText(stderr) || options.isAuthFailureText(message)) {
return options.createAuthError(stderr);
}
if (failure.killed === true && options.timeoutMs !== undefined) {
return options.createCommandError({
args: options.args,
cwd: options.cwd,
exitCode: null,
stderr:
stderr ||
`${options.commandName} was terminated before completing (timed out after ${options.timeoutMs}ms or exceeded the output limit)`,
});
}
return options.createCommandError({
args: options.args,
cwd: options.cwd,
exitCode: typeof failure.code === "number" ? failure.code : null,
stderr: stderr || message,
});
}
export function parseCliJsonOutput<T>(options: ParseCliJsonOutputOptions<T>): T {
let data: unknown;
try {
data = JSON.parse(options.stdout);
} catch {
throw options.createCommandError({
args: options.args,
cwd: options.cwd,
exitCode: null,
stderr: `${options.commandName} did not return valid JSON (${options.stdout.length} bytes)`,
});
}
const parsed = options.schema.safeParse(data);
if (!parsed.success) {
throw options.createCommandError({
args: options.args,
cwd: options.cwd,
exitCode: null,
stderr: `${options.commandName} JSON did not match the expected schema: ${parsed.error.message}`,
});
}
return parsed.data;
}
export async function defaultResolveRemoteUrl(cwd: string): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], { cwd });
const url = stdout.trim();
return url.length > 0 ? url : null;
} catch {
return null;
}
}
export async function probeHostViaCliAuthStatus(
options: ProbeHostViaCliAuthStatusOptions,
): Promise<boolean> {
const cliPath = await findExecutable(options.cli);
if (!cliPath) {
return false;
}
try {
await execCommand(options.cli, ["auth", "status", "--hostname", options.host], {
envOverlay: options.envOverlay,
timeout: CLI_AUTH_PROBE_TIMEOUT_MS,
});
return true;
} catch {
return false;
}
}
function toCommandFailureLike(error: unknown): CommandFailureLike {
if (!error || typeof error !== "object") {
return { message: String(error) };
}
const record = error as Record<string, unknown>;
return {
code:
typeof record.code === "string" || typeof record.code === "number" || record.code === null
? record.code
: undefined,
killed: typeof record.killed === "boolean" ? record.killed : undefined,
stderr:
typeof record.stderr === "string" || Buffer.isBuffer(record.stderr)
? record.stderr
: undefined,
stdout:
typeof record.stdout === "string" || Buffer.isBuffer(record.stdout)
? record.stdout
: undefined,
message: typeof record.message === "string" ? record.message : undefined,
};
}
@@ -0,0 +1,132 @@
import { FORGE_IDS } from "@getpaseo/protocol/forge-manifest";
import { describe, expect, it, vi } from "vitest";
import { createForgeService, defaultForgeRegistry, ForgeRegistry } from "./forge-registry.js";
import { createGitHubService } from "./github-service.js";
describe("forge registry", () => {
it("builds the registered adapters", () => {
const github = createForgeService("github");
const gitlab = createForgeService("gitlab");
const gitea = createForgeService("gitea");
const forgejo = createForgeService("forgejo");
expect(github?.getCurrentPullRequestStatus).toBeTypeOf("function");
expect(gitlab?.getCurrentPullRequestStatus).toBeTypeOf("function");
expect(gitea?.getCurrentPullRequestStatus).toBeTypeOf("function");
expect(forgejo?.getCurrentPullRequestStatus).toBeTypeOf("function");
});
it("returns null for an unregistered forge", () => {
expect(createForgeService("bitbucket")).toBeNull();
});
it("keeps the built-in registry in sync with the forge manifest", () => {
expect([...defaultForgeRegistry.ids()].sort()).toEqual([...FORGE_IDS].sort());
});
it("knows which forges are registered", () => {
expect(defaultForgeRegistry.has("github")).toBe(true);
expect(defaultForgeRegistry.has("gitlab")).toBe(true);
expect(defaultForgeRegistry.has("gitea")).toBe(true);
expect(defaultForgeRegistry.has("forgejo")).toBe(true);
expect(defaultForgeRegistry.has("bitbucket")).toBe(false);
expect(defaultForgeRegistry.ids()).toEqual(
expect.arrayContaining(["github", "gitlab", "gitea", "forgejo"]),
);
});
it("registers a third-party adapter without changing the registry implementation", () => {
const unregister = defaultForgeRegistry.register("bitbucket", {
createService: createGitHubService,
matchesHost: (host) => host === "bitbucket.org",
});
try {
expect(defaultForgeRegistry.has("bitbucket")).toBe(true);
expect(createForgeService("bitbucket")?.getCurrentPullRequestStatus).toBeTypeOf("function");
} finally {
unregister();
}
expect(defaultForgeRegistry.has("bitbucket")).toBe(false);
});
it("lets adapters own heuristic and asynchronous host detection", async () => {
const registry = new ForgeRegistry([
[
"bitbucket",
{
createService: createGitHubService,
matchesHost: (host) => host === "bitbucket.org",
probeHost: async (host) => host === "git.acme.internal",
},
],
]);
expect(registry.matchHost("bitbucket.org")).toBe("bitbucket");
await expect(registry.probeHost("git.acme.internal")).resolves.toBe("bitbucket");
});
it("does not infer self-managed forges from host substrings", () => {
expect(defaultForgeRegistry.matchHost("gitea-forgejo.example.org")).toBeNull();
expect(defaultForgeRegistry.matchHost("gitlab.example.org")).toBeNull();
expect(defaultForgeRegistry.matchHost("notgitlab.example.org")).toBeNull();
});
it("treats a probe that throws as 'not this forge' rather than crashing detection", async () => {
const registry = new ForgeRegistry([
[
"flaky",
{
createService: createGitHubService,
probeHost: async () => {
throw new Error("CLI not installed");
},
},
],
[
"reachable",
{
createService: createGitHubService,
probeHost: async (host) => host === "git.acme.internal",
},
],
]);
await expect(registry.probeHost("git.acme.internal")).resolves.toBe("reachable");
await expect(registry.probeHost("git.unknown.internal")).resolves.toBeNull();
});
it("degrades ambiguous host detection to no forge instead of depending on registration order", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const registry = new ForgeRegistry([
[
"first",
{
createService: createGitHubService,
matchesHost: () => true,
probeHost: async () => true,
},
],
[
"second",
{
createService: createGitHubService,
matchesHost: () => true,
probeHost: async () => true,
},
],
]);
expect(registry.matchHost("git.acme.internal")).toBeNull();
await expect(registry.probeHost("git.acme.internal")).resolves.toBeNull();
expect(warn).toHaveBeenCalledWith(
expect.stringMatching(/Multiple forge adapters matched host/),
);
expect(warn).toHaveBeenCalledWith(
expect.stringMatching(/Multiple forge adapters recognized host/),
);
} finally {
warn.mockRestore();
}
});
});
@@ -0,0 +1,178 @@
import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest";
import { normalizeHost } from "@getpaseo/protocol/git-remote";
import { createGitHubService, probeGitHubHost } from "./github-service.js";
import type { ForgeService } from "./forge-service.js";
import { createGiteaService, resolveGiteaFamilyForge } from "./gitea-service.js";
import { createGitLabService, probeGitLabHost } from "./gitlab-service.js";
export type ForgeServiceFactory = () => ForgeService;
export interface ForgeAdapterRegistration {
createService: ForgeServiceFactory;
matchesHost?: (host: string) => boolean;
probeHost?: (host: string) => Promise<boolean>;
}
/**
* Open composition boundary for forge adapters. Resolver code depends only on
* these registration hooks, so a new adapter does not require another branch.
*/
export class ForgeRegistry {
readonly #adapters = new Map<string, ForgeAdapterRegistration>();
readonly #warnedAmbiguousHosts = new Set<string>();
constructor(entries: Iterable<readonly [string, ForgeAdapterRegistration]> = []) {
for (const [forge, adapter] of entries) {
this.register(forge, adapter);
}
}
register(forge: string, adapter: ForgeAdapterRegistration): () => void {
const normalizedForge = parseForgeId(forge);
if (!normalizedForge) {
throw new Error(`Invalid forge adapter id: ${forge}`);
}
if (this.#adapters.has(normalizedForge)) {
throw new Error(`Forge adapter already registered: ${normalizedForge}`);
}
this.#adapters.set(normalizedForge, adapter);
return () => {
if (this.#adapters.get(normalizedForge) === adapter) {
this.#adapters.delete(normalizedForge);
}
};
}
ids(): string[] {
return [...this.#adapters.keys()];
}
has(forge: string): boolean {
const normalizedForge = parseForgeId(forge);
return normalizedForge ? this.#adapters.has(normalizedForge) : false;
}
create(forge: string): ForgeService | null {
const normalizedForge = parseForgeId(forge);
if (!normalizedForge) {
return null;
}
const adapter = this.#adapters.get(normalizedForge);
return adapter ? adapter.createService() : null;
}
matchHost(host: string): string | null {
const matches: string[] = [];
for (const [forge, adapter] of this.#adapters) {
if (adapter.matchesHost?.(host)) {
matches.push(forge);
}
}
if (matches.length > 1) {
this.#warnAmbiguous("matched", host, matches);
return null;
}
return matches[0] ?? null;
}
async probeHost(host: string): Promise<string | null> {
const entries = [...this.#adapters];
// allSettled, not all: a third-party probe that throws means "not this
// forge", never a crash of the shared resolution path.
const settled = await Promise.allSettled(
entries.map(async ([, adapter]) =>
adapter.probeHost ? await adapter.probeHost(host) : false,
),
);
const matches = entries
.filter((_, index) => {
const result = settled[index];
return result.status === "fulfilled" && result.value === true;
})
.map(([forge]) => forge);
if (matches.length > 1) {
this.#warnAmbiguous("recognized", host, matches);
return null;
}
return matches[0] ?? null;
}
// Genuine ambiguity (two adapters both claiming a host) degrades to "no
// forge" rather than crashing the shared resolution path used by every
// workspace's PR-status poll; warn once per host so the misconfiguration
// is still visible.
#warnAmbiguous(verb: string, host: string, matches: string[]): void {
const key = `${verb}:${host}`;
if (this.#warnedAmbiguousHosts.has(key)) {
return;
}
this.#warnedAmbiguousHosts.add(key);
console.warn(`Multiple forge adapters ${verb} host ${host}: ${matches.join(", ")}`);
}
}
function parseForgeId(forge: string): string | null {
const normalized = forge.trim().toLowerCase();
return /^[a-z0-9][a-z0-9._-]*$/.test(normalized) ? normalized : null;
}
/**
* Build a host matcher from a forge's declared cloud hosts in the manifest, so
* the registry never hardcodes a host list. Returns undefined for forges with
* no cloud hosts (recognized only by runtime probe, e.g. Forgejo).
*/
function matchesCloudHost(forgeId: string): ((host: string) => boolean) | undefined {
const hosts = getForgeDefinition(forgeId)?.cloudHosts;
if (!hosts || hosts.length === 0) {
return undefined;
}
const normalized = new Set(hosts.map(normalizeHost));
return (host) => normalized.has(normalizeHost(host));
}
export const defaultForgeRegistry = new ForgeRegistry([
// GitHub Enterprise Server is recognized at runtime by probeHost, exactly like
// self-hosted GitLab/Gitea: github.com short-circuits via matchHost, so the
// probe only runs on non-cloud hosts. The PR-status poll gates on the resolver
// alone (no cloud-identity check), so a probed GHES host polls normally.
[
"github",
{
createService: createGitHubService,
matchesHost: matchesCloudHost("github"),
probeHost: probeGitHubHost,
},
],
[
"gitlab",
{
createService: createGitLabService,
matchesHost: matchesCloudHost("gitlab"),
probeHost: probeGitLabHost,
},
],
[
"gitea",
{
createService: createGiteaService,
matchesHost: matchesCloudHost("gitea"),
probeHost: async (host) => (await resolveGiteaFamilyForge(host)) === "gitea",
},
],
[
"forgejo",
{
createService: createGiteaService,
probeHost: async (host) => (await resolveGiteaFamilyForge(host)) === "forgejo",
},
],
["codeberg", { createService: createGiteaService, matchesHost: matchesCloudHost("codeberg") }],
]);
export function createForgeService(forge: string): ForgeService | null {
return defaultForgeRegistry.create(forge);
}
export function probeRegisteredForgeHost(host: string): Promise<string | null> {
return defaultForgeRegistry.probeHost(host);
}
@@ -0,0 +1,376 @@
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);
});
});
@@ -0,0 +1,257 @@
import { LRUCache } from "lru-cache";
import { parseGitRemoteLocation, type GitRemoteLocation } from "@getpaseo/protocol/git-remote";
import { resolveSshHostname, type SshHostnameResolver } from "../utils/ssh-hostname.js";
import { defaultResolveRemoteUrl } from "./forge-cli-command.js";
import type { ForgeService } from "./forge-service.js";
import {
createForgeService,
defaultForgeRegistry,
probeRegisteredForgeHost,
} from "./forge-registry.js";
export interface ForgeResolution {
/** Registered forge id, e.g. "github" or "gitlab". */
forge: string;
/** Remote host the cwd resolves to, e.g. "github.com" or "gitlab.example.com". */
host: string;
/** Adapter for {@link forge}, shared across resolutions of the same forge. */
service: ForgeService;
}
/** Probe a host for a forge id when the name heuristic is inconclusive. */
export type ForgeHostProbe = (host: string) => Promise<string | null>;
export interface CreateForgeResolverOptions {
resolveRemoteUrl?: (cwd: string) => Promise<string | null>;
createService?: (forge: string) => ForgeService | null;
probeForge?: ForgeHostProbe;
resolveSshHostname?: SshHostnameResolver;
now?: () => number;
}
export interface ForgeResolver {
/** Resolve the forge for a working directory, or null when none applies. */
resolve(cwd: string): Promise<ForgeResolution | null>;
/** Resolve from a known origin remote URL using only the name heuristic + probe cache. */
resolveFromRemoteUrl(remoteUrl: string | null): ForgeResolution | null;
/** Resolve from a known origin remote URL, running the per-host probe when needed. */
resolveFromRemoteUrlAsync(remoteUrl: string | null): Promise<ForgeResolution | null>;
/**
* Invalidate every cached adapter's state for a cwd. Routed through the
* resolver so invalidation hits the same adapter instance the poller reads,
* regardless of which forge backs the workspace.
*/
invalidate(cwd: string): void;
}
// A positive probe (host IS a known forge) is cached permanently; a negative one
// expires so a CLI installed/authenticated later is picked up without a restart.
const NEGATIVE_PROBE_TTL_MS = 60_000;
// Cap the per-resolver caches: session handlers resolve request-supplied cwds,
// so unbounded Maps would grow for the daemon's lifetime. Distinct hosts/cwds
// are few in practice; LRU eviction keeps the working set without a leak.
const FORGE_RESOLVER_CACHE_MAX = 512;
export function parseRemoteHost(url: string): string | null {
return parseGitRemoteLocation(url)?.host ?? null;
}
/** Map a remote host through the matchers owned by registered adapters. */
export function forgeForHost(host: string): string | null {
return defaultForgeRegistry.matchHost(host);
}
export function createForgeResolver(options: CreateForgeResolverOptions = {}): ForgeResolver {
const resolveRemoteUrl = options.resolveRemoteUrl ?? defaultResolveRemoteUrl;
const create = options.createService ?? createForgeService;
const probeForge = options.probeForge ?? probeRegisteredForgeHost;
const resolveSsh = options.resolveSshHostname ?? resolveSshHostname;
const now = options.now ?? Date.now;
const services = new Map<string, ForgeService>();
// Cache the per-host probe result so the synchronous resolveFromRemoteUrl can
// reuse a forge discovered by an earlier async resolve. Positive results are
// permanent; negative ones expire (NEGATIVE_PROBE_TTL_MS) so a CLI installed
// or authenticated later is picked up without a daemon restart.
const probedForgeByHost = new LRUCache<
string,
{ forge: string | null; expiresAt: number | null }
>({ max: FORGE_RESOLVER_CACHE_MAX });
// Coalesce concurrent probes of the same host so "never re-probe" holds under
// concurrency: callers racing on the same host await one shared probe.
const inFlightProbes = new Map<string, Promise<string | null>>();
// resolveRemoteUrl spawns `git config` — memoize per cwd so repeated resolve()
// calls (the PR-status poll hits this every cycle) don't re-spawn it. TTL'd
// (not permanent, unlike a positive host probe) so a remote added/changed
// from outside Paseo — `git remote add`/`set-url` run in a terminal — is
// still picked up within a cycle instead of staying pinned forever; a Paseo
// git mutation still busts it immediately via invalidate(cwd).
const remoteUrlByCwd = new LRUCache<
string,
{ promise: Promise<string | null>; expiresAt: number }
>({ max: FORGE_RESOLVER_CACHE_MAX });
function resolveRemoteUrlCached(cwd: string): Promise<string | null> {
const cached = remoteUrlByCwd.get(cwd);
if (cached && cached.expiresAt > now()) {
return cached.promise;
}
const pending = resolveRemoteUrl(cwd).catch((error: unknown) => {
if (remoteUrlByCwd.get(cwd)?.promise === pending) {
remoteUrlByCwd.delete(cwd);
}
throw error;
});
remoteUrlByCwd.set(cwd, { promise: pending, expiresAt: now() + NEGATIVE_PROBE_TTL_MS });
return pending;
}
function readFreshProbe(host: string): string | null | undefined {
const entry = probedForgeByHost.get(host);
if (!entry) {
return undefined;
}
if (entry.expiresAt !== null && Date.now() >= entry.expiresAt) {
probedForgeByHost.delete(host);
return undefined;
}
return entry.forge;
}
function buildResolution(forge: string, host: string): ForgeResolution | null {
let service = services.get(forge);
if (!service) {
const created = create(forge);
if (!created) {
return null;
}
service = created;
services.set(forge, service);
}
return { forge, host, service };
}
async function probeHostForge(host: string): Promise<string | null> {
const cached = readFreshProbe(host);
if (cached !== undefined) {
return cached;
}
const existing = inFlightProbes.get(host);
if (existing) {
return existing;
}
const pending = (async () => {
try {
// A caller-injected probe that throws degrades to "no forge" and is
// cached negatively, so a transient failure is retried after the TTL.
let probed: string | null;
try {
probed = await probeForge(host);
} catch {
probed = null;
}
probedForgeByHost.set(host, {
forge: probed,
expiresAt: probed === null ? Date.now() + NEGATIVE_PROBE_TTL_MS : null,
});
return probed;
} finally {
inFlightProbes.delete(host);
}
})();
inFlightProbes.set(host, pending);
return pending;
}
function resolveFromRemoteUrl(remoteUrl: string | null): ForgeResolution | null {
if (!remoteUrl) {
return null;
}
const host = parseRemoteHost(remoteUrl);
if (!host) {
return null;
}
const forge = forgeForHost(host) ?? readFreshProbe(host) ?? null;
if (!forge) {
return null;
}
return buildResolution(forge, host);
}
async function resolveFromRemoteUrlAsync(
remoteUrl: string | null,
): Promise<ForgeResolution | null> {
if (!remoteUrl) {
return null;
}
const location = parseGitRemoteLocation(remoteUrl);
if (!location) {
return null;
}
const directForge = forgeForHost(location.host);
if (directForge) {
return buildResolution(directForge, location.host);
}
const resolved = await resolveHostForProbe(location);
if (resolved.forge) {
rememberAliasForge(location.host, resolved.host, resolved.forge);
return buildResolution(resolved.forge, resolved.host);
}
const forge = await probeHostForge(resolved.host);
rememberAliasForge(location.host, resolved.host, forge);
if (!forge) {
return null;
}
return buildResolution(forge, resolved.host);
}
// Seed the probe cache under the raw SSH-alias host too: the synchronous
// resolveFromRemoteUrl (PR-status poll gating) sees the alias, not the
// ssh -G resolved host, and would otherwise never find the forge.
function rememberAliasForge(aliasHost: string, resolvedHost: string, forge: string | null): void {
if (aliasHost === resolvedHost) {
return;
}
probedForgeByHost.set(aliasHost, {
forge,
expiresAt: forge === null ? Date.now() + NEGATIVE_PROBE_TTL_MS : null,
});
}
async function resolveHostForProbe(
location: GitRemoteLocation,
): Promise<{ host: string; forge?: string }> {
if (location.transport !== "scp" && location.transport !== "ssh") {
return { host: location.host };
}
const resolvedHost = await resolveSsh(location.host);
if (!resolvedHost) {
return { host: location.host };
}
const resolvedForge = forgeForHost(resolvedHost);
if (resolvedForge) {
return { host: resolvedHost, forge: resolvedForge };
}
return { host: resolvedHost };
}
function invalidate(cwd: string): void {
remoteUrlByCwd.delete(cwd);
for (const service of services.values()) {
service.invalidate({ cwd });
}
}
return {
resolveFromRemoteUrl,
resolveFromRemoteUrlAsync,
invalidate,
async resolve(cwd: string): Promise<ForgeResolution | null> {
return resolveFromRemoteUrlAsync(await resolveRemoteUrlCached(cwd));
},
};
}
@@ -0,0 +1,525 @@
import type { ForgeSearchKind } from "@getpaseo/protocol/messages";
export type ForgeSearchRequestKind = ForgeSearchKind | "github-issue" | "github-pr" | "pr";
export function normalizeForgeSearchKinds(
kinds: readonly ForgeSearchRequestKind[] | undefined,
): ForgeSearchKind[] {
if (!kinds) return ["issue", "change_request"];
return kinds.map((kind) => {
// COMPAT(githubSearchKind): added in v0.1.106, remove with the legacy
// github_search_request RPC after 2026-12-28.
if (kind === "github-issue") return "issue";
if (kind === "github-pr" || kind === "pr") return "change_request";
return kind;
});
}
export interface PullRequestSummary {
number: number;
title: string;
url: string;
state: string;
body: string | null;
projectPath?: string;
baseRefName: string;
headRefName: string;
labels: string[];
updatedAt: string;
}
export interface PullRequestCheckoutTarget {
number: number;
baseRefName: string;
headRefName: string;
checkoutRefs?: PullRequestCheckoutRef[];
headOwnerLogin: string | null;
headRepositorySshUrl: string | null;
headRepositoryUrl: string | null;
isCrossRepository: boolean;
}
export interface PullRequestCheckoutRef {
remoteName?: string;
remoteRef: string;
}
export interface IssueSummary {
number: number;
title: string;
url: string;
state: string;
body: string | null;
projectPath?: string;
labels: string[];
updatedAt: string;
}
export type PullRequestCheckStatus = "pending" | "success" | "failure" | "cancelled" | "skipped";
export interface PullRequestCheck {
name: string;
status: PullRequestCheckStatus;
url: string | null;
workflow?: string;
duration?: string;
checkRunId?: number;
workflowRunId?: number;
}
export type PullRequestChecksStatus = "none" | "pending" | "success" | "failure";
export type PullRequestReviewDecision = "approved" | "changes_requested" | "pending" | null;
export type PullRequestMergeable = "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
export function computeChecksStatus(checks: PullRequestCheck[]): PullRequestChecksStatus {
if (checks.length === 0) {
return "none";
}
if (checks.some((check) => check.status === "failure")) {
return "failure";
}
if (checks.some((check) => check.status === "pending")) {
return "pending";
}
return "success";
}
/**
* Why a forge's PR/MR features are (un)available for a workspace. Replaces the
* lossy "authenticated yes/no" boolean so the UI can offer the precise next step
* (install the CLI vs sign in) instead of a single generic dead-end. "no_remote"
* covers anything where the feature simply does not apply (no resolvable forge
* remote, or no branch to look up). "error" is reserved for non-auth failures
* where the UI should show the actual error instead of setup guidance.
*/
export type ForgeAuthState =
| "authenticated"
| "unauthenticated"
| "cli_missing"
| "no_remote"
| "error";
/**
* Open envelope for a forge's native merge facts on the neutral PR status.
* Per-adapter modules own their typed fact interfaces and guards; shared server
* code only requires the runtime facts-family tag.
*/
export type ForgeSpecificStatusFacts = { forge: string } & Record<string, unknown>;
export interface CurrentPullRequestStatus {
number?: number;
repoOwner?: string;
repoName?: string;
/**
* The forge's full project path (e.g. nested GitLab namespaces like
* `group/subgroup/repo`). Adapters that can report it precisely set it here;
* otherwise consumers fall back to deriving it from owner/name.
*/
projectPath?: string;
url: string;
title: string;
state: string;
baseRefName: string;
headRefName: string;
isMerged: boolean;
isDraft?: boolean;
mergeable: PullRequestMergeable;
checks: PullRequestCheck[];
checksStatus: PullRequestChecksStatus;
reviewDecision: PullRequestReviewDecision;
forgeSpecific?: ForgeSpecificStatusFacts;
}
export type PullRequestTimelineReviewState = "approved" | "changes_requested" | "commented";
interface PullRequestTimelineItemBase {
id: string;
author: string;
authorUrl: string | null;
avatarUrl: string | null;
body: string;
createdAt: number;
url: string;
}
export type PullRequestTimelineItem =
| (PullRequestTimelineItemBase & {
kind: "review";
reviewState: PullRequestTimelineReviewState;
})
| (PullRequestTimelineItemBase & {
kind: "comment";
reviewId?: string;
threadId?: string;
threadIsResolved?: boolean;
location?: PullRequestTimelineCommentLocation;
});
export interface PullRequestTimelineCommentLocation {
path: string;
line?: number;
startLine?: number;
threadId?: string;
isResolved?: boolean;
isOutdated?: boolean;
}
export type PullRequestTimelineErrorKind = "not_found" | "forbidden" | "unknown";
export interface PullRequestTimelineError {
kind: PullRequestTimelineErrorKind;
message: string;
}
export interface PullRequestTimeline {
prNumber: number;
repoOwner: string;
repoName: string;
items: PullRequestTimelineItem[];
truncated: boolean;
error: PullRequestTimelineError | null;
}
export interface PullRequestCreateResult {
url: string;
number: number;
}
export type PullRequestMergeMethod = "merge" | "squash" | "rebase";
export interface PullRequestCommandStatus {
mergeable?: PullRequestMergeable;
forgeSpecific?: ForgeSpecificStatusFacts;
}
export interface MergePullRequestOptions {
cwd: string;
prNumber: number;
mergeMethod: PullRequestMergeMethod;
status?: PullRequestCommandStatus | null;
}
export interface EnablePullRequestAutoMergeOptions {
cwd: string;
prNumber: number;
mergeMethod: PullRequestMergeMethod;
status?: PullRequestCommandStatus | null;
}
export interface DisablePullRequestAutoMergeOptions {
cwd: string;
prNumber: number;
status?: PullRequestCommandStatus | null;
}
export interface PullRequestMergeResult {
success: true;
}
export interface PullRequestAutoMergeResult {
success: true;
}
export type ForgeReadOptions =
| {
force?: false;
reason?: string;
}
| {
force: true;
reason: string;
};
export type ListPullRequestsOptions = {
cwd: string;
query?: string;
limit?: number;
} & ForgeReadOptions;
export type ListIssuesOptions = {
cwd: string;
query?: string;
limit?: number;
} & ForgeReadOptions;
export type GetPullRequestOptions = {
cwd: string;
number: number;
} & ForgeReadOptions;
export type GetPullRequestTimelineOptions = {
cwd: string;
prNumber: number;
repoOwner: string;
repoName: string;
} & ForgeReadOptions;
export type GetCheckDetailsOptions = {
cwd: string;
/**
* GitHub-only: the GitHub adapter addresses the check run by owner/name. The
* GitLab adapter resolves the project from the cwd's remote and ignores these.
*/
repoOwner?: string;
repoName?: string;
/**
* Check-run id. Optional because some checks are addressed only by
* workflowRunId (Gitea Actions runs carry no check-run id). Callers pass at
* least one of checkRunId/workflowRunId.
*/
checkRunId?: number;
workflowRunId?: number;
/**
* GitLab-only: the change request iid. GitLab routes the fetch to the change
* request's head pipeline (`glab ci get --merge-request`), so a fork/detached
* MR pipeline living in the source project resolves instead of 404ing against
* the checkout's target project. GitHub ignores it.
*/
changeRequestNumber?: number;
} & ForgeReadOptions;
export interface CheckAnnotation {
path?: string;
startLine?: number;
endLine?: number;
annotationLevel?: string;
message?: string;
title?: string;
rawDetails?: string;
}
export interface CheckFailedJob {
jobId: number;
name: string;
status?: string | null;
conclusion?: string | null;
url?: string | null;
completedAt?: string;
logTail?: string;
logTruncated?: boolean;
}
/**
* Normalized lifecycle of a CI job/pipeline, neutral across forges. Adapters
* map their forge's raw status strings onto this; readers keep the raw string
* too (see {@link PipelineJob.rawStatus}) for display fidelity.
*/
export type PipelineJobStatus =
| "success"
| "failed"
| "running"
| "pending"
| "canceled"
| "skipped"
| "manual"
| "created"
| "unknown";
export interface PipelineJob {
id: number;
name: string;
stage: string;
status: PipelineJobStatus;
rawStatus: string;
url: string | null;
allowFailure: boolean;
durationSeconds: number | null;
}
export interface PipelineStage {
name: string;
status: PipelineJobStatus;
jobs: PipelineJob[];
}
/**
* A CI pipeline as a stage → job tree. Forges that model CI as a pipeline
* (GitLab) populate this; forges that model it as flat check runs (GitHub)
* leave {@link CheckDetails.pipeline} undefined and use the check-run fields.
*/
export interface PipelineDetails {
id: number;
status: PipelineJobStatus;
rawStatus: string;
url: string | null;
ref: string | null;
sha: string | null;
stages: PipelineStage[];
}
export interface CheckDetails {
checkRunId: number;
workflowRunId?: number | null;
name: string;
status?: string | null;
conclusion?: string | null;
url?: string | null;
detailsUrl?: string | null;
output?: {
title?: string | null;
summary?: string | null;
text?: string | null;
} | null;
annotations: CheckAnnotation[];
failedJobs: CheckFailedJob[];
truncated: boolean;
/**
* Structured pipeline (stages → jobs) for forges that model CI as a pipeline.
* GitLab populates it; GitHub leaves it undefined and keeps using the flat
* check-run fields above.
*/
pipeline?: PipelineDetails | null;
}
export interface SearchResult {
items: Array<{
kind: "issue" | "change_request";
forge?: string;
number: number;
title: string;
url: string;
state: string;
body: string | null;
labels: string[];
projectPath?: string;
baseRefName?: string | null;
headRefName?: string | null;
updatedAt?: string;
}>;
featuresEnabled: boolean;
authState: ForgeAuthState;
/**
* COMPAT(githubFeaturesEnabled): added in v0.1.106, remove after 2026-12-28.
*/
githubFeaturesEnabled?: boolean;
}
export function createUnavailableSearchResult(
authState: Exclude<ForgeAuthState, "authenticated">,
): SearchResult {
return {
items: [],
featuresEnabled: false,
authState,
githubFeaturesEnabled: false,
};
}
export type SearchIssuesAndPrsOptions = {
cwd: string;
query: string;
limit?: number;
kinds?: ForgeSearchRequestKind[];
} & ForgeReadOptions;
export interface CreatePullRequestOptions {
cwd: string;
title: string;
head: string;
base: string;
body?: string;
}
export interface ForgeService {
listPullRequests(options: ListPullRequestsOptions): Promise<PullRequestSummary[]>;
listIssues(options: ListIssuesOptions): Promise<IssueSummary[]>;
getPullRequest(options: GetPullRequestOptions): Promise<PullRequestSummary>;
getPullRequestHeadRef(options: GetPullRequestOptions): Promise<string>;
getPullRequestCheckoutTarget(options: GetPullRequestOptions): Promise<PullRequestCheckoutTarget>;
/**
* Refs to fetch for a change-request checkout when the resolved checkout
* target carries none. Adapters that expose a universal change-request head
* ref (GitHub's refs/pull/N/head) return it here; others let the shell fall
* back to the head branch.
*/
defaultCheckoutRefs?(params: {
changeRequestNumber: number;
headRef: string;
}): PullRequestCheckoutRef[];
/**
* Local branch name for a checked-out change request when the adapter
* disambiguates cross-repository heads (GitHub prefixes the fork owner).
* Returns undefined to keep the head ref name as-is.
*/
buildPrLocalBranchName?(params: {
headRef: string;
checkoutTarget: PullRequestCheckoutTarget;
}): string | undefined;
/**
* True when the adapter can fetch a cross-repository change-request head with
* no explicit refs on the checkout target (GitHub via refs/pull/N/head).
* Others reject a cross-repo checkout that carries no refs.
*/
supportsCrossRepoCheckoutWithoutRefs?: boolean;
getCurrentPullRequestStatus(
options: {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
} & ForgeReadOptions,
): Promise<CurrentPullRequestStatus | null>;
getPullRequestTimeline(options: GetPullRequestTimelineOptions): Promise<PullRequestTimeline>;
getCheckDetails(options: GetCheckDetailsOptions): Promise<CheckDetails>;
searchIssuesAndPrs(options: SearchIssuesAndPrsOptions): Promise<SearchResult>;
createPullRequest(options: CreatePullRequestOptions): Promise<PullRequestCreateResult>;
mergePullRequest(options: MergePullRequestOptions): Promise<PullRequestMergeResult>;
enablePullRequestAutoMerge(
options: EnablePullRequestAutoMergeOptions,
): Promise<PullRequestAutoMergeResult>;
disablePullRequestAutoMerge(
options: DisablePullRequestAutoMergeOptions,
): Promise<PullRequestAutoMergeResult>;
/**
* Check whether the adapter can authenticate against the forge for `cwd`.
* An adapter picks exactly one of two contracts:
*
* - Resolve to `false` on any auth failure (missing CLI, expired token,
* unreachable host, ...) and never reject. This is the default contract;
* most adapters should implement it this way.
* - Reject with a classified error distinguishing the failure kind (e.g.
* "CLI not installed" vs "not logged in") instead of collapsing
* everything to `false`. An adapter that does this MUST also set
* {@link authProbeCanThrow} to `true`, or callers have no way to know the
* rejection is meaningful and will treat it as an unhandled error.
*/
isAuthenticated(options: { cwd: string } & ForgeReadOptions): Promise<boolean>;
/**
* True when isAuthenticated() throws a classified error (missing CLI /
* auth failure) on failure instead of just resolving to false. Callers that
* want the precise failure kind before falling back to a PR-status lookup
* (which can't distinguish "unauthenticated" from "no PR found") should
* only probe isAuthenticated() when this is set — otherwise the call
* cannot change the outcome and is pure overhead.
*/
authProbeCanThrow?: boolean;
retainCurrentPullRequestStatusPoll?(options: {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
onStatus?: (status: CurrentPullRequestStatus | null) => void;
onError?: (error: unknown) => void;
}): { unsubscribe: () => void };
invalidate(options: { cwd: string }): void;
dispose?(): void;
}
/** Parse an optional ISO timestamp to epoch ms, 0 when absent or unparseable. */
export function parseOptionalTime(value: string | null | undefined): number {
if (!value) {
return 0;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
/** Stable timeline ordering shared by every adapter: by createdAt, then id. */
export function compareTimelineItems(
left: PullRequestTimelineItem,
right: PullRequestTimelineItem,
): number {
if (left.createdAt !== right.createdAt) {
return left.createdAt - right.createdAt;
}
return left.id.localeCompare(right.id);
}
@@ -0,0 +1,17 @@
import type { ForgeSpecificStatusFacts } from "./forge-service.js";
export interface GiteaStatusFacts {
mergeable: boolean;
hasMerged: boolean;
ciStatus: string | null;
}
export type GiteaForgeSpecificStatusFacts = ForgeSpecificStatusFacts & {
forge: "gitea";
} & GiteaStatusFacts;
export function isGiteaStatusFacts(
facts: ForgeSpecificStatusFacts | null | undefined,
): facts is GiteaForgeSpecificStatusFacts {
return facts?.forge === "gitea";
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
import type { ForgeSpecificStatusFacts } from "./forge-service.js";
export interface GitHubPullRequestStatusFacts {
mergeStateStatus: string | null;
autoMergeRequest: {
enabledAt: string | null;
mergeMethod: string | null;
enabledBy: string | null;
} | null;
viewerCanEnableAutoMerge: boolean;
viewerCanDisableAutoMerge: boolean;
viewerCanMergeAsAdmin: boolean;
viewerCanUpdateBranch: boolean;
repository: {
autoMergeAllowed: boolean;
mergeCommitAllowed: boolean;
squashMergeAllowed: boolean;
rebaseMergeAllowed: boolean;
viewerDefaultMergeMethod: string | null;
};
isMergeQueueEnabled: boolean;
isInMergeQueue: boolean;
}
export type GitHubForgeSpecificStatusFacts = ForgeSpecificStatusFacts & {
forge: "github";
} & GitHubPullRequestStatusFacts;
export function isGitHubPullRequestStatusFacts(
facts: ForgeSpecificStatusFacts | null | undefined,
): facts is GitHubForgeSpecificStatusFacts {
return facts?.forge === "github";
}
@@ -1,7 +1,7 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { delimiter, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
GitHubAuthenticationError,
@@ -9,11 +9,12 @@ import {
GitHubCommandError,
computeGithubNextInterval,
createGitHubService,
resolveGitHubRepo,
type GitHubCommandRunner,
type GitHubCommandRunnerOptions,
type GitHubCurrentPullRequestStatus,
type CurrentPullRequestStatus,
type GitHubPullRequestStatusFacts,
} from "./github-service.js";
import { isPlatform } from "../test-utils/platform.js";
import { CheckoutPrStatusResponseSchema } from "@getpaseo/protocol/messages";
const EXPECTED_GITHUB_FAST_POLL_MS = 20_000;
@@ -77,6 +78,12 @@ function createScriptedRunner(steps: RunnerStep[]): TestRunner {
};
}
async function flushMicrotasks(): Promise<void> {
for (let i = 0; i < 10; i++) {
await Promise.resolve();
}
}
function createDeferredRunner(): TestRunner {
const calls: RunnerCall[] = [];
let resolveNext: ((stdout: string) => void) | null = null;
@@ -98,6 +105,99 @@ function createDeferredRunner(): TestRunner {
};
}
interface FakeGitHubCliFixture {
cwd: string;
logPath: string;
dispose: () => void;
}
function createFakeGitHubCliFixture(input: {
remoteUrl: string;
authStatusSucceeds?: boolean;
authStatusMode?: "success" | "unauthenticated" | "transient-once";
}): FakeGitHubCliFixture {
const previousPath = process.env.PATH;
const previousLog = process.env.GH_TEST_LOG;
const tempDir = mkdtempSync(join(tmpdir(), "github-service-gh-host-"));
const repoDir = join(tempDir, "repo");
const binDir = join(tempDir, "bin");
const logPath = join(tempDir, "gh.log");
const authStatusMode =
input.authStatusMode ?? (input.authStatusSucceeds === true ? "success" : "unauthenticated");
const authStatusStatePath = join(tempDir, "auth-status-state");
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["-C", repoDir, "remote", "add", "origin", input.remoteUrl], {
stdio: "pipe",
});
mkdirSync(binDir, { recursive: true });
const ghPath = join(binDir, "gh");
writeFileSync(
ghPath,
`#!/bin/sh
AUTH_STATUS_MODE=${JSON.stringify(authStatusMode)}
AUTH_STATUS_STATE=${JSON.stringify(authStatusStatePath)}
printf '%s|%s\\n' "$*" "\${GH_HOST:-}" >> "$GH_TEST_LOG"
if [ "$1" = "--version" ]; then
echo "gh version test"
exit 0
fi
if [ "$1" = "auth" ] && [ "$2" = "status" ] && [ "$3" = "--hostname" ]; then
case "$AUTH_STATUS_MODE" in
success)
exit 0
;;
transient-once)
if [ ! -f "$AUTH_STATUS_STATE" ]; then
touch "$AUTH_STATUS_STATE"
echo "request timed out" >&2
exit 2
fi
exit 0
;;
*)
echo "gh auth login required" >&2
exit 1
;;
esac
fi
if [ "$1" = "repo" ] && [ "$2" = "view" ]; then
echo '{"owner":{"login":"acme"},"name":"repo"}'
exit 0
fi
if [ "$1" = "api" ]; then
echo '{"url":"https://github.acme.internal/acme/repo/pull/7","number":7}'
exit 0
fi
echo "unexpected gh args: $*" >&2
exit 1
`,
);
chmodSync(ghPath, 0o755);
process.env.PATH = [binDir, previousPath].filter(Boolean).join(delimiter);
process.env.GH_TEST_LOG = logPath;
return {
cwd: repoDir,
logPath,
dispose: () => {
if (previousPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = previousPath;
}
if (previousLog === undefined) {
delete process.env.GH_TEST_LOG;
} else {
process.env.GH_TEST_LOG = previousLog;
}
rmSync(tempDir, { recursive: true, force: true });
},
};
}
function readFakeGitHubCliLog(logPath: string): string[] {
return readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean);
}
function currentPullRequestJson(overrides: Record<string, unknown> = {}): string {
return JSON.stringify({
number: 42,
@@ -140,8 +240,8 @@ function currentPullRequestGithubFactsJson(overrides: Record<string, unknown> =
}
function createCurrentPullRequestStatus(
overrides: Partial<GitHubCurrentPullRequestStatus> = {},
): GitHubCurrentPullRequestStatus {
overrides: Partial<CurrentPullRequestStatus> = {},
): CurrentPullRequestStatus {
return {
number: 42,
repoOwner: "acme",
@@ -162,9 +262,10 @@ function createCurrentPullRequestStatus(
}
function githubStatusFacts(
overrides: Partial<NonNullable<GitHubCurrentPullRequestStatus["github"]>> = {},
): NonNullable<GitHubCurrentPullRequestStatus["github"]> {
overrides: Partial<GitHubPullRequestStatusFacts> = {},
): GitHubPullRequestStatusFacts & { forge: "github" } {
return {
forge: "github",
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
@@ -348,7 +449,7 @@ function pullRequestTimelineJson(overrides: Record<string, unknown> = {}): strin
});
}
describe("GitHubService", () => {
describe("ForgeService", () => {
beforeEach(() => {
vi.useFakeTimers();
});
@@ -373,7 +474,7 @@ describe("GitHubService", () => {
prNumber: 42,
mergeMethod,
status: createCurrentPullRequestStatus({
github: githubStatusFacts(),
forgeSpecific: githubStatusFacts(),
}),
}),
).resolves.toEqual({ success: true });
@@ -419,7 +520,7 @@ describe("GitHubService", () => {
prNumber: 42,
mergeMethod: "squash",
status: createCurrentPullRequestStatus({
github: githubStatusFacts({ mergeStateStatus }),
forgeSpecific: githubStatusFacts({ mergeStateStatus }),
}),
}),
).rejects.toThrow("ready for direct merge");
@@ -443,7 +544,7 @@ describe("GitHubService", () => {
prNumber: 42,
mergeMethod: "squash",
status: createCurrentPullRequestStatus({
github: githubStatusFacts(overrides),
forgeSpecific: githubStatusFacts(overrides),
}),
}),
).rejects.toThrow("merge queue");
@@ -463,7 +564,7 @@ describe("GitHubService", () => {
prNumber: 42,
mergeMethod: "squash",
status: createCurrentPullRequestStatus({
github: githubStatusFacts({
forgeSpecific: githubStatusFacts({
autoMergeRequest: {
enabledAt: "2026-05-13T12:00:00Z",
mergeMethod: "SQUASH",
@@ -489,7 +590,7 @@ describe("GitHubService", () => {
prNumber: 42,
mergeMethod: "squash",
status: createCurrentPullRequestStatus({
github: githubStatusFacts({
forgeSpecific: githubStatusFacts({
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
@@ -521,7 +622,7 @@ describe("GitHubService", () => {
prNumber: 42,
mergeMethod,
status: createCurrentPullRequestStatus({
github: githubStatusFacts({
forgeSpecific: githubStatusFacts({
mergeStateStatus: "BLOCKED",
viewerCanEnableAutoMerge: true,
repository: {
@@ -556,7 +657,7 @@ describe("GitHubService", () => {
cwd: "/tmp/repo",
prNumber: 42,
status: createCurrentPullRequestStatus({
github: githubStatusFacts({
forgeSpecific: githubStatusFacts({
autoMergeRequest: {
enabledAt: "2026-05-13T12:00:00Z",
mergeMethod: "SQUASH",
@@ -614,6 +715,7 @@ describe("GitHubService", () => {
number: 526,
baseRefName: "main",
headRefName: "main",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/526/head" }],
headOwnerLogin: "therainisme",
headRepositorySshUrl: "git@github.com:therainisme/paseo.git",
headRepositoryUrl: "https://github.com/therainisme/paseo",
@@ -632,6 +734,28 @@ describe("GitHubService", () => {
expect(runner.calls[1]?.args).toContain("number=526");
});
it("populates repoOwner/repoName from a GitHub Enterprise PR URL", async () => {
const runner = createRunner([
currentPullRequestJson({ url: "https://github.acme.internal/acme/repo/pull/42" }),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 0,
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
});
expect(status).toMatchObject({
repoOwner: "acme",
repoName: "repo",
url: "https://github.acme.internal/acme/repo/pull/42",
});
});
it("polls PR status at fast cadence while checks are pending", async () => {
let now = 0;
const runner = createRunner([
@@ -1639,6 +1763,7 @@ describe("GitHubService", () => {
ttlMs: 1_000,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
now: () => 100,
});
const request = {
@@ -1649,7 +1774,7 @@ describe("GitHubService", () => {
};
const staleRequest = service.getPullRequestTimeline(request);
await Promise.resolve();
await flushMicrotasks();
expect(runner.calls).toHaveLength(1);
service.invalidate({ cwd: "/repo" });
@@ -1673,7 +1798,7 @@ describe("GitHubService", () => {
expect(stale.items.at(-1)?.body).toBe("Stale pre-invalidation result");
const freshRequest = service.getPullRequestTimeline(request);
await Promise.resolve();
await flushMicrotasks();
expect(runner.calls).toHaveLength(2);
runner.resolveNext(
pullRequestTimelineJson({
@@ -1862,13 +1987,13 @@ describe("GitHubService", () => {
now: () => 100,
});
const first = await service.getGitHubCheckDetails({
const first = await service.getCheckDetails({
cwd: "/repo",
repoOwner: "acme",
repoName: "repo",
checkRunId: 12345,
});
const second = await service.getGitHubCheckDetails({
const second = await service.getCheckDetails({
cwd: "/repo",
repoOwner: "acme",
repoName: "repo",
@@ -1959,7 +2084,7 @@ describe("GitHubService", () => {
now: () => 100,
});
const details = await service.getGitHubCheckDetails({
const details = await service.getCheckDetails({
cwd: "/repo",
repoOwner: "acme",
repoName: "repo",
@@ -2019,7 +2144,7 @@ describe("GitHubService", () => {
now: () => 100,
});
const details = await service.getGitHubCheckDetails({
const details = await service.getCheckDetails({
cwd: "/repo",
repoOwner: "acme",
repoName: "repo",
@@ -2057,7 +2182,7 @@ describe("GitHubService", () => {
now: () => 100,
});
const details = await service.getGitHubCheckDetails({
const details = await service.getCheckDetails({
cwd: "/repo",
repoOwner: "acme",
repoName: "repo",
@@ -2167,7 +2292,8 @@ describe("GitHubService", () => {
},
],
checksStatus: "pending",
github: {
forgeSpecific: {
forge: "github",
mergeStateStatus: "BLOCKED",
autoMergeRequest: null,
viewerCanEnableAutoMerge: true,
@@ -2588,6 +2714,7 @@ describe("GitHubService", () => {
headRefName: "feature",
isMerged: false,
},
featuresEnabled: true,
githubFeaturesEnabled: true,
error: null,
requestId: "req-old",
@@ -2626,6 +2753,7 @@ describe("GitHubService", () => {
checksStatus: "success",
reviewDecision: "pending",
},
featuresEnabled: true,
githubFeaturesEnabled: true,
error: null,
requestId: "req-new",
@@ -2633,6 +2761,7 @@ describe("GitHubService", () => {
});
expect(newDaemonResponse.payload.status).toEqual({
forge: "github",
number: 42,
url: "https://github.com/acme/repo/pull/42",
title: "New daemon payload",
@@ -2701,12 +2830,13 @@ describe("GitHubService", () => {
ttlMs: 1_000,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
now: () => 100,
});
const first = service.listPullRequests({ cwd: "/repo", query: "bug", limit: 10 });
const second = service.listPullRequests({ cwd: "/repo", query: "bug", limit: 10 });
await Promise.resolve();
await flushMicrotasks();
runner.resolveNext(pullRequestJson("Shared result"));
await expect(Promise.all([first, second])).resolves.toEqual([
@@ -2818,6 +2948,32 @@ describe("GitHubService", () => {
});
});
it("throws a typed command error for malformed JSON output", async () => {
const runner = createRunner(["not-json"]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
await expect(service.listPullRequests({ cwd: "/repo" })).rejects.toMatchObject({
kind: "command-error",
args: [
"pr",
"list",
"--search",
"",
"--json",
"number,title,url,state,body,labels,baseRefName,headRefName,updatedAt",
"--limit",
"20",
],
cwd: "/repo",
exitCode: null,
stderr: "gh did not return valid JSON (8 bytes)",
});
});
it("searches GitHub issues and PRs", async () => {
const runner = createRunner([issueJson("Issue title"), searchPullRequestJson("PR title")]);
const service = createGitHubService({
@@ -2829,10 +2985,12 @@ describe("GitHubService", () => {
await expect(
service.searchIssuesAndPrs({ cwd: "/repo", query: "cache", limit: 5 }),
).resolves.toEqual({
featuresEnabled: true,
authState: "authenticated",
githubFeaturesEnabled: true,
items: [
{
kind: "pr",
kind: "change_request",
number: 123,
title: "PR title",
url: "https://github.com/acme/repo/pull/123",
@@ -2926,6 +3084,84 @@ describe("GitHubService", () => {
]);
});
it("does not treat an unrelated tracker URL as a GitHub issue/PR number", async () => {
const runner = createRunner([issueJson("Issue title"), searchPullRequestJson("PR title")]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
now: () => 100,
});
await service.searchIssuesAndPrs({
cwd: "/repo",
query: "https://gitlab.com/getpaseo/paseo/issues/793",
limit: 5,
});
expect(runner.calls.map((call) => call.args)).toEqual([
[
"issue",
"list",
"--search",
"https://gitlab.com/getpaseo/paseo/issues/793",
"--json",
"number,title,url,state,body,labels,updatedAt",
"--limit",
"5",
],
[
"pr",
"list",
"--search",
"https://gitlab.com/getpaseo/paseo/issues/793",
"--json",
"number,title,url,state,body,labels,baseRefName,headRefName,updatedAt",
"--limit",
"5",
],
]);
});
it("treats a GitHub Enterprise issue/PR URL as a search for that number", async () => {
const runner = createRunner([issueJson("Issue title"), searchPullRequestJson("PR title")]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => "github.acme.internal",
now: () => 100,
});
await service.searchIssuesAndPrs({
cwd: "/repo",
query: "https://github.acme.internal/getpaseo/paseo/pull/793",
limit: 5,
});
expect(runner.calls.map((call) => call.args)).toEqual([
[
"issue",
"list",
"--search",
"793",
"--json",
"number,title,url,state,body,labels,updatedAt",
"--limit",
"5",
],
[
"pr",
"list",
"--search",
"793",
"--json",
"number,title,url,state,body,labels,baseRefName,headRefName,updatedAt",
"--limit",
"5",
],
]);
});
it("searches only GitHub PRs when the search kinds request excludes issues", async () => {
const runner = createRunner([searchPullRequestJson("PR title")]);
const service = createGitHubService({
@@ -2942,10 +3178,12 @@ describe("GitHubService", () => {
kinds: ["github-pr"],
}),
).resolves.toEqual({
featuresEnabled: true,
authState: "authenticated",
githubFeaturesEnabled: true,
items: [
{
kind: "pr",
kind: "change_request",
number: 123,
title: "PR title",
url: "https://github.com/acme/repo/pull/123",
@@ -3030,12 +3268,13 @@ describe("GitHubService", () => {
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
now: () => 100,
});
const first = service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
const second = service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
await Promise.resolve();
await flushMicrotasks();
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
runner.resolveNext(currentPullRequestJson());
@@ -3064,32 +3303,298 @@ describe("GitHubService", () => {
headRef: "feature/fork",
force: true,
} as never),
).rejects.toThrow("GitHubService forced read requires a reason");
).rejects.toThrow("ForgeService forced read requires a reason");
});
it("type: force true requires a reason", () => {
// @ts-expect-error force: true requires reason
const invalid: GitHubReadOptions = { force: true };
const valid: GitHubReadOptions = { force: true, reason: "test" };
const invalid: ForgeReadOptions = { force: true };
const valid: ForgeReadOptions = { force: true, reason: "test" };
expect(invalid.force).toBe(true);
expect(valid.reason).toBe("test");
});
it("resolves GitHub repos from the origin remote URL", async () => {
vi.useRealTimers();
const cwd = mkdtempSync(join(tmpdir(), "github-service-repo-"));
it("resolves the repo slug from the workspace when creating a pull request", async () => {
const runner = createRunner([
JSON.stringify({ owner: { login: "acme" }, name: "repo" }),
JSON.stringify({ url: "https://github.com/acme/repo/pull/7", number: 7 }),
]);
const service = createGitHubService({
runner: runner.runner,
resolveRepoHost: async () => null,
});
try {
execFileSync("git", ["init", "-b", "main"], { cwd, stdio: "ignore" });
execFileSync("git", ["remote", "add", "origin", "git@github.com:getpaseo/paseo.git"], {
cwd,
stdio: "ignore",
await expect(
service.createPullRequest({
cwd: "/tmp/repo",
title: "Add thing",
head: "feature",
base: "main",
}),
).resolves.toEqual({ url: "https://github.com/acme/repo/pull/7", number: 7 });
expect(runner.calls[0]?.args).toEqual(["repo", "view", "--json", "owner,name,parent"]);
expect(runner.calls[1]?.args).toEqual([
"api",
"-X",
"POST",
"repos/acme/repo/pulls",
"-f",
"title=Add thing",
"-f",
"head=feature",
"-f",
"base=main",
]);
});
it("routes gh calls to the resolved GitHub Enterprise host via GH_HOST", async () => {
const runner = createRunner([
JSON.stringify({ owner: { login: "acme" }, name: "repo" }),
JSON.stringify({ url: "https://github.acme.internal/acme/repo/pull/7", number: 7 }),
]);
const service = createGitHubService({
runner: runner.runner,
resolveRepoHost: async () => "github.acme.internal",
});
await expect(
service.createPullRequest({
cwd: "/tmp/repo",
title: "Add thing",
head: "feature",
base: "main",
}),
).resolves.toEqual({ url: "https://github.acme.internal/acme/repo/pull/7", number: 7 });
expect(runner.calls[1]?.args).toEqual([
"api",
"-X",
"POST",
"repos/acme/repo/pulls",
"-f",
"title=Add thing",
"-f",
"head=feature",
"-f",
"base=main",
]);
// GH_HOST is injected on every gh call (the slug lookup and the POST alike).
expect(runner.calls[0]?.envOverlay).toMatchObject({ GH_HOST: "github.acme.internal" });
expect(runner.calls[1]?.envOverlay).toMatchObject({ GH_HOST: "github.acme.internal" });
});
it.skipIf(isPlatform("win32"))(
"routes default gh calls to GitHub Enterprise only after host auth succeeds",
async () => {
vi.useRealTimers();
const fixture = createFakeGitHubCliFixture({
remoteUrl: "https://github.acme.internal/acme/repo.git",
authStatusSucceeds: true,
});
try {
const service = createGitHubService();
await expect(resolveGitHubRepo(cwd)).resolves.toBe("getpaseo/paseo");
await expect(
service.createPullRequest({
cwd: fixture.cwd,
title: "Add thing",
head: "feature",
base: "main",
}),
).resolves.toEqual({ url: "https://github.acme.internal/acme/repo/pull/7", number: 7 });
expect(readFakeGitHubCliLog(fixture.logPath)).toEqual(
expect.arrayContaining([
"auth status --hostname github.acme.internal|",
"repo view --json owner,name,parent|github.acme.internal",
"api -X POST repos/acme/repo/pulls -f title=Add thing -f head=feature -f base=main|github.acme.internal",
]),
);
} finally {
fixture.dispose();
}
},
);
it.skipIf(isPlatform("win32"))(
"retries GitHub Enterprise host probing after a transient auth-status failure",
async () => {
vi.useRealTimers();
const fixture = createFakeGitHubCliFixture({
remoteUrl: "https://github.acme.internal/acme/repo.git",
authStatusMode: "transient-once",
});
try {
const service = createGitHubService();
await expect(
service.createPullRequest({
cwd: fixture.cwd,
title: "Add thing",
head: "feature",
base: "main",
}),
).rejects.toThrow("Unable to verify GitHub Enterprise host github.acme.internal");
await expect(
service.createPullRequest({
cwd: fixture.cwd,
title: "Add thing",
head: "feature",
base: "main",
}),
).resolves.toEqual({ url: "https://github.acme.internal/acme/repo/pull/7", number: 7 });
const log = readFakeGitHubCliLog(fixture.logPath);
expect(
log.filter((line) => line === "auth status --hostname github.acme.internal|"),
).toHaveLength(2);
expect(log).toEqual(
expect.arrayContaining([
"repo view --json owner,name,parent|github.acme.internal",
"api -X POST repos/acme/repo/pulls -f title=Add thing -f head=feature -f base=main|github.acme.internal",
]),
);
} finally {
fixture.dispose();
}
},
);
it.skipIf(isPlatform("win32"))(
"fails instead of routing gh calls to github.com for an unauthenticated Enterprise host",
async () => {
vi.useRealTimers();
const fixture = createFakeGitHubCliFixture({
remoteUrl: "https://github.acme.internal/acme/repo.git",
authStatusSucceeds: false,
});
try {
const service = createGitHubService();
// An unauthenticated Enterprise host must fail, not silently proceed
// against github.com (the default when GH_HOST is unset).
await expect(
service.createPullRequest({
cwd: fixture.cwd,
title: "Add thing",
head: "feature",
base: "main",
}),
).rejects.toThrow(/authentication failed/i);
const log = readFakeGitHubCliLog(fixture.logPath);
expect(log).toContain("auth status --hostname github.acme.internal|");
// It threw at host resolution, before running any api/graphql call.
expect(log.some((line) => line.startsWith("api ") || line.startsWith("graphql"))).toBe(
false,
);
} finally {
fixture.dispose();
}
},
);
it.skipIf(isPlatform("win32"))("does not probe or route github.com through GH_HOST", async () => {
vi.useRealTimers();
const fixture = createFakeGitHubCliFixture({
remoteUrl: "https://github.com/acme/repo.git",
authStatusSucceeds: false,
});
try {
const service = createGitHubService();
await expect(
service.createPullRequest({
cwd: fixture.cwd,
title: "Add thing",
head: "feature",
base: "main",
}),
).resolves.toEqual({ url: "https://github.acme.internal/acme/repo/pull/7", number: 7 });
const log = readFakeGitHubCliLog(fixture.logPath);
expect(log.some((line) => line.startsWith("auth status --hostname "))).toBe(false);
expect(log.every((line) => line.endsWith("|"))).toBe(true);
} finally {
rmSync(cwd, { recursive: true, force: true });
fixture.dispose();
}
});
it("caches definitive null repo host resolutions", async () => {
const runner = createRunner(["[]", "[]"]);
let hostResolutions = 0;
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => {
hostResolutions += 1;
return null;
},
});
await service.listPullRequests({ cwd: "/repo", query: "one", limit: 1 });
await service.listPullRequests({ cwd: "/repo", query: "two", limit: 1 });
expect(hostResolutions).toBe(1);
expect(runner.calls.every((call) => call.envOverlay?.GH_HOST === undefined)).toBe(true);
});
it("re-resolves a null repo host after the TTL expires", async () => {
const runner = createRunner(["[]", "[]"]);
let hostResolutions = 0;
let nowMs = 0;
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => nowMs,
resolveRepoHost: async () => {
hostResolutions += 1;
return hostResolutions === 1 ? null : "ghe.example.com";
},
});
await service.listPullRequests({ cwd: "/repo", query: "one", limit: 1 });
expect(runner.calls[0]?.envOverlay?.GH_HOST).toBeUndefined();
nowMs = 60_001;
await service.listPullRequests({ cwd: "/repo", query: "two", limit: 1 });
expect(hostResolutions).toBe(2);
expect(runner.calls[1]?.envOverlay).toMatchObject({ GH_HOST: "ghe.example.com" });
});
it("re-resolves the host after invalidation when the remote changes", async () => {
const runner = createRunner([]);
let hostCall = 0;
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => (hostCall++ === 0 ? "host-a.internal" : "host-b.internal"),
});
await service.listPullRequests({ cwd: "/repo", query: "x", limit: 1 });
expect(runner.calls[0]?.envOverlay).toMatchObject({ GH_HOST: "host-a.internal" });
service.invalidate({ cwd: "/repo" });
await service.listPullRequests({ cwd: "/repo", query: "x", limit: 1 });
expect(runner.calls[1]?.envOverlay).toMatchObject({ GH_HOST: "host-b.internal" });
});
it("throws when the workspace repository cannot be resolved for pull request creation", async () => {
const runner = createRunner([JSON.stringify({})]);
const service = createGitHubService({ runner: runner.runner });
await expect(
service.createPullRequest({
cwd: "/tmp/repo",
title: "Add thing",
head: "feature",
base: "main",
}),
).rejects.toThrow("Unable to resolve GitHub repository for pull request creation");
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
import type { ForgeSpecificStatusFacts } from "./forge-service.js";
export interface GitLabStatusFacts {
detailedMergeStatus: string | null;
/**
* Legacy `merge_status` (pre-15.6 GitLab), used as the direct-merge readiness
* signal when `detailedMergeStatus` is absent on older self-managed instances.
*/
mergeStatus: string | null;
hasConflicts: boolean;
blockingDiscussionsResolved: boolean;
approvalsRequired: number;
approvalsGiven: number;
pipelineStatus: string | null;
/**
* Id of the MR's head pipeline, used to fetch the full pipeline (stages ->
* jobs) on demand. Null when the MR has no pipeline yet.
*/
pipelineId: number | null;
pipelineUrl: string | null;
mergeWhenPipelineSucceeds: boolean;
}
// Client-side twin: packages/app/src/git/forges/gitlab.ts
// (GITLAB_ACTIVE_PIPELINE_STATUSES / isPipelineActiveStatus). Duplicated because
// the app can't depend on the server package; keep the two lists in sync by hand
// if GitLab adds a new active pipeline status.
export const GITLAB_ACTIVE_PIPELINE_STATUSES = [
"created",
"waiting_for_resource",
"preparing",
"pending",
"running",
"scheduled",
] as const;
export type GitLabActivePipelineStatus = (typeof GITLAB_ACTIVE_PIPELINE_STATUSES)[number];
export const GITLAB_ACTIVE_PIPELINE_STATUS_SET = new Set<string>(GITLAB_ACTIVE_PIPELINE_STATUSES);
export type GitLabForgeSpecificStatusFacts = ForgeSpecificStatusFacts & {
forge: "gitlab";
} & GitLabStatusFacts;
export function isGitLabStatusFacts(
facts: ForgeSpecificStatusFacts | null | undefined,
): facts is GitLabForgeSpecificStatusFacts {
return facts?.forge === "gitlab";
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff