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:
co-authored by
Mohamed Boudra
parent
04d1ebdce0
commit
a8ebd390fa
@@ -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
Reference in New Issue
Block a user