mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-13 11:55:03 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43f5a41847 | ||
|
|
a0408321fd | ||
|
|
7fa66e2357 | ||
|
|
60b93a0688 | ||
|
|
c6bc0d0af2 | ||
|
|
6b2db04c91 | ||
|
|
9345dd2d86 | ||
|
|
d14513a2ef | ||
|
|
70daf6962d | ||
|
|
ee32792257 | ||
|
|
a8d44c3d89 |
@@ -113,6 +113,38 @@ host that is either a known cloud host or one the CLI is already authenticated
|
||||
to. Adapter probes must not make anonymous HTTP requests to remote-derived
|
||||
hosts, and adapters must not route credentials to an unauthenticated host.
|
||||
|
||||
### Web links: `forgeWeb`
|
||||
|
||||
The daemon publishes `forgeWeb` on the workspace descriptor and on
|
||||
`checkout_status_response`. The app builds every forge URL from it instead of
|
||||
parsing `origin` itself.
|
||||
|
||||
- `host` is the browsable host: an `ssh -G` alias such as `github.com-work` and
|
||||
a cloud SSH host such as `ssh.github.com` resolve to the cloud host; self-hosted
|
||||
hosts pass through.
|
||||
- `referenceRepository` is the repository that owns issue and change-request
|
||||
numbers: the parent when `origin` is a fork. `#123` links there; `owner/repo#123`
|
||||
keeps the repository you wrote.
|
||||
- Branch and file links stay on the `origin` repository. A feature branch usually
|
||||
exists only on the fork.
|
||||
|
||||
An adapter opts into `referenceRepository` with the optional
|
||||
`ForgeService.getReferenceRepository`. GitHub reads `parent` from `gh repo view`,
|
||||
GitLab reads `forked_from_project` from `glab api`, and the Gitea family reads
|
||||
`parent` from `tea api`. The lookup always names the `origin` repository
|
||||
explicitly, so it does not depend on `gh`'s default-repository choice, and it
|
||||
rejects on any CLI failure. `packages/server/src/server/forge-web.ts` keeps a
|
||||
successful answer for as long as the remote stays the same. The first snapshot
|
||||
publishes `{ host }` immediately and the reference repository follows in a later
|
||||
update, so a slow or signed-out forge CLI never holds back the checkout's git
|
||||
state or pull request status. A failed lookup (signed out, network down) leaves
|
||||
links on `origin` and is retried by the next refresh or pull request status poll
|
||||
once a minute has passed.
|
||||
|
||||
Daemons older than v0.16.0 omit the field; the app then derives the host from the
|
||||
remote and links `#N` to `origin`. That fallback is tagged `COMPAT(forgeWeb)` in
|
||||
`packages/app/src/git/forge-url.ts`.
|
||||
|
||||
## App
|
||||
|
||||
Each app forge splits into two modules so pure logic never imports the client
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ForgeWeb } from "@getpaseo/protocol/messages";
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
|
||||
import { createForgeShorthand, type ForgeShorthand } from "./shorthand";
|
||||
|
||||
@@ -9,13 +10,27 @@ const ForgeShorthandContext = createContext<ForgeShorthand | null>(null);
|
||||
export function ForgeShorthandProvider({
|
||||
forge,
|
||||
remoteUrl,
|
||||
forgeWeb,
|
||||
children,
|
||||
}: {
|
||||
forge: string | null | undefined;
|
||||
remoteUrl: string | null | undefined;
|
||||
forgeWeb: ForgeWeb | null | undefined;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const value = useMemo(() => createForgeShorthand({ forge, remoteUrl }), [forge, remoteUrl]);
|
||||
// Keyed on the strings: the descriptor hands over a new `forgeWeb` object whenever any
|
||||
// other workspace field changes, and a new context value re-renders every Markdown message.
|
||||
const webHost = forgeWeb?.host;
|
||||
const referenceRepository = forgeWeb?.referenceRepository;
|
||||
const value = useMemo(
|
||||
() =>
|
||||
createForgeShorthand({
|
||||
forge,
|
||||
remoteUrl,
|
||||
forgeWeb: webHost ? { host: webHost, referenceRepository } : null,
|
||||
}),
|
||||
[forge, remoteUrl, webHost, referenceRepository],
|
||||
);
|
||||
return <ForgeShorthandContext.Provider value={value}>{children}</ForgeShorthandContext.Provider>;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,17 @@ describe("createForgeShorthand", () => {
|
||||
expect(gitlab.resolve("g/sub/q#3")).toBe("https://gitlab.com/g/sub/q/-/issues/3");
|
||||
});
|
||||
|
||||
it("links numbers to the repository the daemon says owns them, but keeps explicit references", () => {
|
||||
const fork = createForgeShorthand({
|
||||
forge: "github",
|
||||
remoteUrl: "git@github.com-work:fork-owner/repo.git",
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
})!;
|
||||
|
||||
expect(fork.resolve("#123")).toBe("https://github.com/main-owner/repo/issues/123");
|
||||
expect(fork.resolve("acme/tools#4")).toBe("https://github.com/acme/tools/issues/4");
|
||||
});
|
||||
|
||||
it("only recognizes the sigils the forge declares", () => {
|
||||
expect(matches(github, "see #1 and !2")).toEqual(["#1"]);
|
||||
expect(github.resolve("!2")).toBeNull();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ForgeWeb } from "@getpaseo/protocol/messages";
|
||||
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
|
||||
import { forgeFromRemoteUrl } from "@/git/forge";
|
||||
import { getClientForgeLogicModule } from "@/git/forges";
|
||||
@@ -26,12 +27,15 @@ const REFERENCE = new RegExp(String.raw`^(?:(${REPO}))?([#!])(${NUMBER})$`);
|
||||
export function createForgeShorthand(input: {
|
||||
forge: string | null | undefined;
|
||||
remoteUrl: string | null | undefined;
|
||||
forgeWeb?: ForgeWeb | null;
|
||||
}): ForgeShorthand | null {
|
||||
const remoteUrl = input.remoteUrl;
|
||||
const forge = input.forge || forgeFromRemoteUrl(remoteUrl);
|
||||
if (!forge || !remoteUrl) return null;
|
||||
const sigils = forgeSigils(forge).filter(
|
||||
(sigil) => buildForgeReferenceUrl(forge, { remoteUrl, sigil, number: 1 }) !== null,
|
||||
(sigil) =>
|
||||
buildForgeReferenceUrl(forge, { remoteUrl, forgeWeb: input.forgeWeb, sigil, number: 1 }) !==
|
||||
null,
|
||||
);
|
||||
if (sigils.length === 0) return null;
|
||||
return {
|
||||
@@ -41,6 +45,7 @@ export function createForgeShorthand(input: {
|
||||
if (!match) return null;
|
||||
return buildForgeReferenceUrl(forge, {
|
||||
remoteUrl,
|
||||
forgeWeb: input.forgeWeb,
|
||||
repo: match[1] ?? null,
|
||||
sigil: match[2] as ForgeReferenceSigil,
|
||||
number: Number(match[3]),
|
||||
|
||||
@@ -326,3 +326,81 @@ describe("hasForgeWebUrls", () => {
|
||||
expect(hasForgeWebUrls("bitbucket")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("links resolved by the daemon (forgeWeb)", () => {
|
||||
const sshAliasRemote = "git@github.com-work:acme/repo.git";
|
||||
|
||||
it("links #N on the web host the daemon resolved for an SSH config alias", () => {
|
||||
expect(
|
||||
buildForgeReferenceUrl("github", {
|
||||
remoteUrl: sshAliasRemote,
|
||||
forgeWeb: { host: "github.com" },
|
||||
sigil: "#",
|
||||
number: 1,
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/issues/1");
|
||||
});
|
||||
|
||||
it("opens the branch and a file on the resolved web host for an SSH config alias", () => {
|
||||
const forgeWeb = { host: "github.com" };
|
||||
expect(
|
||||
buildForgeBranchTreeUrl("github", { remoteUrl: sshAliasRemote, forgeWeb, branch: "main" }),
|
||||
).toBe("https://github.com/acme/repo/tree/main");
|
||||
expect(
|
||||
buildForgeBlobUrl("github", {
|
||||
remoteUrl: sshAliasRemote,
|
||||
forgeWeb,
|
||||
branch: "main",
|
||||
path: "src/index.ts",
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
|
||||
});
|
||||
|
||||
it("links #N to the repository that owns the numbers, such as a fork's parent", () => {
|
||||
expect(
|
||||
buildForgeReferenceUrl("github", {
|
||||
remoteUrl: "git@github.com:fork-owner/repo.git",
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
sigil: "#",
|
||||
number: 123,
|
||||
}),
|
||||
).toBe("https://github.com/main-owner/repo/issues/123");
|
||||
});
|
||||
|
||||
it("keeps an explicit owner/repo#N reference on the named repository", () => {
|
||||
expect(
|
||||
buildForgeReferenceUrl("github", {
|
||||
remoteUrl: "git@github.com:fork-owner/repo.git",
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
repo: "other/tools",
|
||||
sigil: "#",
|
||||
number: 7,
|
||||
}),
|
||||
).toBe("https://github.com/other/tools/issues/7");
|
||||
});
|
||||
|
||||
it("keeps the branch and file links on the origin repository for a fork", () => {
|
||||
const input = {
|
||||
remoteUrl: "git@github.com:fork-owner/repo.git",
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
branch: "feature/only-on-fork",
|
||||
};
|
||||
expect(buildForgeBranchTreeUrl("github", input)).toBe(
|
||||
"https://github.com/fork-owner/repo/tree/feature/only-on-fork",
|
||||
);
|
||||
expect(buildForgeBlobUrl("github", { ...input, path: "a.ts" })).toBe(
|
||||
"https://github.com/fork-owner/repo/blob/feature/only-on-fork/a.ts",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the scheme and port of a self-hosted http remote", () => {
|
||||
expect(
|
||||
buildForgeReferenceUrl("gitea", {
|
||||
remoteUrl: "http://forge.test:3000/team/repo.git",
|
||||
forgeWeb: { host: "forge.test", referenceRepository: "upstream/repo" },
|
||||
sigil: "#",
|
||||
number: 12,
|
||||
}),
|
||||
).toBe("http://forge.test:3000/upstream/repo/issues/12");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,17 +6,23 @@
|
||||
* format); an unknown forge has no grammar and yields null, so the action is
|
||||
* simply absent rather than wrong.
|
||||
*
|
||||
* URL grammar lives on each client forge module. The repo identity and host
|
||||
* both ride the manifest's `cloudHosts` only to canonicalize a cloud SSH alias
|
||||
* (e.g. ssh.github.com -> github.com); a self-hosted host is used as-is.
|
||||
* URL grammar lives on each client forge module. The daemon resolves the web
|
||||
* host (SSH config aliases, cloud SSH hosts) and the repository that owns
|
||||
* issue and change-request numbers (a fork's parent) and publishes them as
|
||||
* `forgeWeb`; branch and file links stay on the origin repository.
|
||||
*/
|
||||
import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest";
|
||||
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import {
|
||||
forgeWebHost,
|
||||
isForgeCloudHost,
|
||||
parseGitRemoteLocation,
|
||||
} from "@getpaseo/protocol/git-remote";
|
||||
import type { ForgeWeb } from "@getpaseo/protocol/messages";
|
||||
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
|
||||
import { getClientForgeLogicModule } from "@/git/forges";
|
||||
|
||||
export interface ForgeBlobUrlInput {
|
||||
remoteUrl: string | null | undefined;
|
||||
forgeWeb?: ForgeWeb | null;
|
||||
branch: string | null | undefined;
|
||||
path: string | null | undefined;
|
||||
lineStart?: number;
|
||||
@@ -25,12 +31,17 @@ export interface ForgeBlobUrlInput {
|
||||
|
||||
export interface ForgeBranchTreeUrlInput {
|
||||
remoteUrl: string | null | undefined;
|
||||
forgeWeb?: ForgeWeb | null;
|
||||
branch: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface ForgeReferenceUrlInput {
|
||||
remoteUrl: string | null | undefined;
|
||||
/** Repository path of a cross-repository reference; the remote's repository when absent. */
|
||||
forgeWeb?: ForgeWeb | null;
|
||||
/**
|
||||
* Repository path of a cross-repository reference; the repository that owns
|
||||
* issue numbers (`forgeWeb.referenceRepository`, else the remote's) when absent.
|
||||
*/
|
||||
repo?: string | null;
|
||||
sigil: ForgeReferenceSigil;
|
||||
number: number;
|
||||
@@ -62,13 +73,6 @@ interface ForgeWebLocation {
|
||||
repo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Web host + repo path from a remote. The host is the remote's host, except a
|
||||
* cloud SSH alias (a non-first entry in the forge's `cloudHosts`, e.g.
|
||||
* ssh.github.com) is canonicalized to the forge's web host (`cloudHosts[0]`).
|
||||
* Self-hosted hosts are returned untouched. The repo path supports nested
|
||||
* groups (e.g. GitLab subgroups) since it is the full remote path.
|
||||
*/
|
||||
function isValidRepoPath(path: string): boolean {
|
||||
const segments = path.split("/").filter((segment) => segment.length > 0);
|
||||
if (segments.length === 0) {
|
||||
@@ -77,9 +81,17 @@ function isValidRepoPath(path: string): boolean {
|
||||
return !segments.includes("..");
|
||||
}
|
||||
|
||||
/**
|
||||
* Web location of the origin repository. The host is the daemon's
|
||||
* `forgeWeb.host`. A daemon without `forgeWeb` publishes none, so the remote's
|
||||
* own host is used, with a cloud SSH alias mapped to the forge's web host.
|
||||
* The repo path supports nested groups (e.g. GitLab subgroups) since it is
|
||||
* the full remote path.
|
||||
*/
|
||||
function resolveForgeWebLocation(
|
||||
forge: string,
|
||||
remoteUrl: string | null | undefined,
|
||||
forgeWeb: ForgeWeb | null | undefined,
|
||||
): ForgeWebLocation | null {
|
||||
if (!remoteUrl) {
|
||||
return null;
|
||||
@@ -88,9 +100,9 @@ function resolveForgeWebLocation(
|
||||
if (!location || !isValidRepoPath(location.path)) {
|
||||
return null;
|
||||
}
|
||||
const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost);
|
||||
const isCloudHost = cloudHosts.includes(location.host);
|
||||
const webHost = isCloudHost ? cloudHosts[0] : location.host;
|
||||
// COMPAT(forgeWeb): added in v0.16.0, remove the remote-derived host after 2027-06-01 once daemon floor >= v0.16.0.
|
||||
const webHost = forgeWeb?.host ?? forgeWebHost(forge, location.host);
|
||||
const isCloudHost = isForgeCloudHost(forge, location.host);
|
||||
// Carry a non-default port only for a self-hosted http(s) origin (e.g.
|
||||
// `:60443`): the web UI shares that origin. An SSH/scp remote's port is not the
|
||||
// web port, and a canonicalized cloud host always serves on the default port.
|
||||
@@ -139,7 +151,7 @@ export function buildForgeBranchTreeUrl(
|
||||
input: ForgeBranchTreeUrlInput,
|
||||
): string | null {
|
||||
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl);
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
|
||||
const branch = input.branch?.trim();
|
||||
if (!grammar || !location || !branch || branch === "HEAD") {
|
||||
return null;
|
||||
@@ -149,7 +161,7 @@ export function buildForgeBranchTreeUrl(
|
||||
|
||||
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
|
||||
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl);
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
|
||||
const branch = input.branch?.trim();
|
||||
const filePath = normalizeBlobPath(input.path);
|
||||
if (!grammar || !location || !branch || branch === "HEAD" || !filePath) {
|
||||
@@ -175,8 +187,8 @@ export function buildForgeReferenceUrl(
|
||||
const referencePath = getClientForgeLogicModule(forge)?.urlGrammar?.referencePaths?.find(
|
||||
(path) => path.sigil === input.sigil,
|
||||
);
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl);
|
||||
const repo = input.repo ?? location?.repo;
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
|
||||
const repo = input.repo ?? input.forgeWeb?.referenceRepository ?? location?.repo;
|
||||
if (
|
||||
!referencePath ||
|
||||
!location ||
|
||||
|
||||
@@ -354,6 +354,21 @@ describe("ReplicaCache", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves where the workspace's forge is on the web across cache reopen", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
const baseline = directory({ workspaces: { generation: "g", afterSeq: 8 } });
|
||||
const forgeWeb = { host: "github.com", referenceRepository: "main-owner/repo" };
|
||||
const workspace = normalizeWorkspaceDescriptor({ ...workspacePayload(), forgeWeb });
|
||||
baseline.workspaces.set(workspace.id, workspace);
|
||||
commitDirectory(writer, SERVER_ID, baseline);
|
||||
await writer.flush();
|
||||
|
||||
const restored = await createCache(storage).readDirectory(SERVER_ID);
|
||||
|
||||
expect(restored.workspaces.get(workspace.id)?.forgeWeb).toEqual(forgeWeb);
|
||||
});
|
||||
|
||||
it("preserves workspace sorting timestamps across cache reopen", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import {
|
||||
AgentStatusSchema,
|
||||
AgentTimelineItemPayloadSchema,
|
||||
ForgeWebSchema,
|
||||
WorkspaceGitHubRuntimePayloadSchema,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
|
||||
@@ -335,6 +336,7 @@ const StoredWorkspaceSchema = z.strictObject({
|
||||
gitRuntime: WorkspaceGitRuntimeSchema,
|
||||
githubRuntime: WorkspaceGitHubRuntimePayloadSchema,
|
||||
forge: z.string().optional(),
|
||||
forgeWeb: ForgeWebSchema.optional(),
|
||||
});
|
||||
|
||||
const StoredProjectSchema = z.strictObject({
|
||||
@@ -768,6 +770,7 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace {
|
||||
gitRuntime: workspace.gitRuntime,
|
||||
githubRuntime: workspace.githubRuntime,
|
||||
forge: workspace.forge,
|
||||
forgeWeb: workspace.forgeWeb,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3919,7 +3919,11 @@ function WorkspaceMarkdownLinks({
|
||||
}: AssistantFileLinkResolverProviderProps & { workspace: WorkspaceDescriptor | null | undefined }) {
|
||||
return (
|
||||
<AssistantFileLinkResolverProvider {...fileLinks}>
|
||||
<ForgeShorthandProvider forge={workspace?.forge} remoteUrl={workspace?.gitRuntime?.remoteUrl}>
|
||||
<ForgeShorthandProvider
|
||||
forge={workspace?.forge}
|
||||
remoteUrl={workspace?.gitRuntime?.remoteUrl}
|
||||
forgeWeb={workspace?.forgeWeb}
|
||||
>
|
||||
{children}
|
||||
</ForgeShorthandProvider>
|
||||
</AssistantFileLinkResolverProvider>
|
||||
|
||||
@@ -140,6 +140,7 @@ export interface WorkspaceDescriptor {
|
||||
gitRuntime?: WorkspaceDescriptorPayload["gitRuntime"];
|
||||
githubRuntime?: WorkspaceDescriptorPayload["githubRuntime"];
|
||||
forge?: WorkspaceDescriptorPayload["forge"];
|
||||
forgeWeb?: WorkspaceDescriptorPayload["forgeWeb"];
|
||||
project?: ProjectPlacementPayload;
|
||||
}
|
||||
|
||||
@@ -185,6 +186,7 @@ export function normalizeWorkspaceDescriptor(
|
||||
gitRuntime: payload.gitRuntime,
|
||||
githubRuntime: payload.githubRuntime,
|
||||
forge: payload.forge,
|
||||
forgeWeb: payload.forgeWeb,
|
||||
project: payload.project,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,6 +184,55 @@ describe("planWorkspaceOpenTargets", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens the branch and the active file on the web host the daemon resolved for an SSH alias", () => {
|
||||
const aliasStatus = {
|
||||
isGit: true,
|
||||
remoteUrl: "git@github.com-work:acme/repo.git",
|
||||
currentBranch: "feature/x",
|
||||
forgeWeb: { host: "github.com" },
|
||||
};
|
||||
const open = (activeFile?: { path: string }) =>
|
||||
planWorkspaceOpenTargets({
|
||||
workspaceDirectory: "/repo",
|
||||
activeFile,
|
||||
desktopTargets: [],
|
||||
canUseDesktopBridge: false,
|
||||
isLocalExecution: false,
|
||||
checkoutStatus: aliasStatus,
|
||||
forge: "github",
|
||||
}).map((target) => (target.source === "forge" ? target.url : null));
|
||||
|
||||
expect(open()).toEqual(["https://github.com/acme/repo/tree/feature/x"]);
|
||||
expect(open({ path: "src/app.ts" })).toEqual([
|
||||
"https://github.com/acme/repo/blob/feature/x/src/app.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the branch and file on the origin repository of a fork", () => {
|
||||
const forkStatus = {
|
||||
isGit: true,
|
||||
remoteUrl: "git@github.com:fork-owner/repo.git",
|
||||
currentBranch: "feature/only-on-fork",
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
};
|
||||
const forgeUrls = (activeFile: { path: string } | undefined) =>
|
||||
planWorkspaceOpenTargets({
|
||||
workspaceDirectory: "/repo",
|
||||
activeFile,
|
||||
desktopTargets: [],
|
||||
canUseDesktopBridge: false,
|
||||
isLocalExecution: false,
|
||||
checkoutStatus: forkStatus,
|
||||
forge: "github",
|
||||
}).flatMap((target) => (target.source === "forge" ? [target.url] : []));
|
||||
const urls = [...forgeUrls(undefined), ...forgeUrls({ path: "src/app.ts" })];
|
||||
|
||||
expect(urls).toEqual([
|
||||
"https://github.com/fork-owner/repo/tree/feature/only-on-fork",
|
||||
"https://github.com/fork-owner/repo/blob/feature/only-on-fork/src/app.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("infers the forge from the remote URL when the forge input is null", () => {
|
||||
const targets = planWorkspaceOpenTargets({
|
||||
workspaceDirectory: "/repo",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ForgeWeb } from "@getpaseo/protocol/messages";
|
||||
import { type Forge, forgeFromRemoteUrl, getForgePresentation } from "@/git/forge";
|
||||
import type { DesktopOpenTarget, OpenDesktopTargetInput } from "@/workspace/desktop-open-targets";
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
interface CheckoutStatusForOpenTarget {
|
||||
isGit: boolean;
|
||||
remoteUrl?: string | null;
|
||||
forgeWeb?: ForgeWeb | null;
|
||||
currentBranch?: string | null;
|
||||
}
|
||||
|
||||
@@ -118,6 +120,7 @@ function buildForgeWebUrl(
|
||||
forge: Forge,
|
||||
input: {
|
||||
remoteUrl: string | null | undefined;
|
||||
forgeWeb: ForgeWeb | null | undefined;
|
||||
branch: string | null | undefined;
|
||||
path: string | null;
|
||||
lineStart?: number;
|
||||
@@ -129,6 +132,7 @@ function buildForgeWebUrl(
|
||||
return (
|
||||
presentation.buildBlobUrl?.({
|
||||
remoteUrl: input.remoteUrl,
|
||||
forgeWeb: input.forgeWeb,
|
||||
branch: input.branch,
|
||||
path: input.path,
|
||||
lineStart: input.lineStart,
|
||||
@@ -139,6 +143,7 @@ function buildForgeWebUrl(
|
||||
return (
|
||||
presentation.buildBranchTreeUrl?.({
|
||||
remoteUrl: input.remoteUrl,
|
||||
forgeWeb: input.forgeWeb,
|
||||
branch: input.branch,
|
||||
}) ?? null
|
||||
);
|
||||
@@ -159,6 +164,7 @@ function planForgeOpenTarget(input: {
|
||||
}
|
||||
const url = buildForgeWebUrl(forge, {
|
||||
remoteUrl: input.checkoutStatus.remoteUrl,
|
||||
forgeWeb: input.checkoutStatus.forgeWeb,
|
||||
branch: input.checkoutStatus.currentBranch,
|
||||
path: input.resolvedFile?.relativePath ?? null,
|
||||
lineStart: input.activeFile?.lineStart,
|
||||
|
||||
@@ -108,6 +108,26 @@ export function normalizeHost(host: string): string {
|
||||
return host.trim().replace(/\.+$/u, "").toLowerCase();
|
||||
}
|
||||
|
||||
/** Whether `host` is one of the forge's public cloud hosts, SSH aliases included. */
|
||||
export function isForgeCloudHost(forge: string, host: string): boolean {
|
||||
const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost);
|
||||
return cloudHosts.includes(normalizeHost(host));
|
||||
}
|
||||
|
||||
/**
|
||||
* Web host for a remote host of `forge`. A cloud SSH alias (a non-first entry
|
||||
* in the forge's `cloudHosts`, e.g. ssh.github.com) maps to the forge's web
|
||||
* host (`cloudHosts[0]`); any other host, self-hosted included, is returned
|
||||
* as is.
|
||||
*/
|
||||
export function forgeWebHost(forge: string, host: string): string {
|
||||
const normalized = normalizeHost(host);
|
||||
if (!isForgeCloudHost(forge, normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return normalizeHost(getForgeDefinition(forge)?.cloudHosts?.[0] ?? normalized);
|
||||
}
|
||||
|
||||
function normalizeRemotePath(path: string): string | null {
|
||||
let normalized = path.trim().replace(/^\/+|\/+$/gu, "");
|
||||
if (normalized.endsWith(".git")) normalized = normalized.slice(0, -4);
|
||||
|
||||
@@ -4328,6 +4328,22 @@ export const WorkspaceScriptPayloadSchema = z.object({
|
||||
terminalId: z.string().nullable().optional().default(null),
|
||||
});
|
||||
|
||||
/**
|
||||
* Where a workspace's forge is on the web, resolved by the daemon so every
|
||||
* client builds the same links.
|
||||
*/
|
||||
export const ForgeWebSchema = z.object({
|
||||
/** Web host of the forge: the SSH config alias resolved, cloud SSH hosts mapped to their web host. */
|
||||
host: z.string(),
|
||||
/**
|
||||
* Repository path that owns issue and change-request numbers when it is not
|
||||
* the origin repository, such as a fork's parent. Absent: the origin repository.
|
||||
*/
|
||||
referenceRepository: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ForgeWeb = z.infer<typeof ForgeWebSchema>;
|
||||
|
||||
const WorkspaceGitRuntimePayloadSchema = z
|
||||
.object({
|
||||
currentBranch: z.string().nullable().optional(),
|
||||
@@ -4464,6 +4480,8 @@ export const WorkspaceDescriptorPayloadSchema = z
|
||||
// until 2027-01-17; remove the consumer fallback once the supported daemon
|
||||
// floor is >= v0.2.0.
|
||||
forge: z.string().optional(),
|
||||
// Absent from daemons older than v0.16.0; clients then derive links from the origin remote.
|
||||
forgeWeb: ForgeWebSchema.optional(),
|
||||
project: ProjectPlacementPayloadSchema.optional(),
|
||||
// COMPAT(directorySync): sequence of this latest directory projection.
|
||||
syncSeq: z.number().int().positive().optional(),
|
||||
@@ -5697,6 +5715,7 @@ const CheckoutStatusCommonSchema = z.object({
|
||||
// upstream branch name is not necessarily currentBranch, so composing one is wrong.
|
||||
// aheadOfOrigin/behindOfOrigin are measured against exactly this ref.
|
||||
upstreamRef: z.string().nullable().optional(),
|
||||
forgeWeb: ForgeWebSchema.optional(),
|
||||
});
|
||||
|
||||
const CheckoutStatusNotGitSchema = CheckoutStatusCommonSchema.extend({
|
||||
|
||||
@@ -1251,6 +1251,15 @@ describe("workspace message schemas", () => {
|
||||
expect(
|
||||
WorkspaceDescriptorPayloadSchema.parse({ ...baseWorkspace, forge: "gitlab" }).forge,
|
||||
).toBe("gitlab");
|
||||
|
||||
// forgeWeb is optional: an old daemon omits it, a new daemon's value round-trips.
|
||||
expect(WorkspaceDescriptorPayloadSchema.parse(baseWorkspace).forgeWeb).toBeUndefined();
|
||||
expect(
|
||||
WorkspaceDescriptorPayloadSchema.parse({
|
||||
...baseWorkspace,
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
}).forgeWeb,
|
||||
).toEqual({ host: "github.com", referenceRepository: "main-owner/repo" });
|
||||
});
|
||||
|
||||
test("workspace.create.request rejects old flat backing shape and accepts new source envelope", () => {
|
||||
|
||||
@@ -1,13 +1,52 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
|
||||
import { CheckoutPrStatusSchema, CheckoutStatusResponseSchema } from "@getpaseo/protocol/messages";
|
||||
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
|
||||
import {
|
||||
buildCheckoutPrStatusPayloadFromSnapshot,
|
||||
buildCheckoutStatusPayloadFromSnapshot,
|
||||
normalizeCheckoutPrStatusPayload,
|
||||
} from "./status-projection.js";
|
||||
|
||||
describe("checkout status projection", () => {
|
||||
test("publishes where the forge is on the web with the checkout status", () => {
|
||||
const snapshot: WorkspaceGitRuntimeSnapshot = {
|
||||
cwd: "/repo",
|
||||
git: {
|
||||
isGit: true,
|
||||
repoRoot: "/repo",
|
||||
mainRepoRoot: null,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "git@github.com-work:fork-owner/repo.git",
|
||||
isPaseoOwnedWorktree: false,
|
||||
isDirty: false,
|
||||
baseRef: "main",
|
||||
aheadBehind: null,
|
||||
upstreamRef: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
hasRemote: true,
|
||||
diffStat: null,
|
||||
},
|
||||
forge: { featuresEnabled: true, authState: "authenticated", pullRequest: null, error: null },
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
};
|
||||
|
||||
const payload = buildCheckoutStatusPayloadFromSnapshot({
|
||||
cwd: "/repo",
|
||||
requestId: "req-1",
|
||||
snapshot,
|
||||
});
|
||||
|
||||
expect(payload.forgeWeb).toEqual({
|
||||
host: "github.com",
|
||||
referenceRepository: "main-owner/repo",
|
||||
});
|
||||
expect(
|
||||
CheckoutStatusResponseSchema.parse({ type: "checkout_status_response", payload }).payload,
|
||||
).toMatchObject({ forgeWeb: payload.forgeWeb });
|
||||
});
|
||||
|
||||
test("includes repository identity fields on the PR status wire payload", () => {
|
||||
const payload = normalizeCheckoutPrStatusPayload(
|
||||
{
|
||||
|
||||
@@ -69,6 +69,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({
|
||||
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
|
||||
hasRemote: snapshot.git.hasRemote,
|
||||
remoteUrl: snapshot.git.remoteUrl,
|
||||
...(snapshot.forgeWeb ? { forgeWeb: snapshot.forgeWeb } : {}),
|
||||
isPaseoOwnedWorktree: true,
|
||||
error: null,
|
||||
requestId,
|
||||
@@ -91,6 +92,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({
|
||||
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
|
||||
hasRemote: snapshot.git.hasRemote,
|
||||
remoteUrl: snapshot.git.remoteUrl,
|
||||
...(snapshot.forgeWeb ? { forgeWeb: snapshot.forgeWeb } : {}),
|
||||
isPaseoOwnedWorktree: false,
|
||||
error: null,
|
||||
requestId,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { forgeWebHost } from "@getpaseo/protocol/git-remote";
|
||||
import type { ForgeWeb } from "@getpaseo/protocol/messages";
|
||||
import type { ForgeResolution } from "../services/forge-resolver.js";
|
||||
|
||||
// A failed reference-repository lookup (signed out, network down, rate limit) is retried after this long.
|
||||
const REFERENCE_REPOSITORY_RETRY_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Web location of a workspace's forge, remembered per origin remote. The host
|
||||
* comes from the forge resolution; the reference repository needs the forge, so
|
||||
* it is looked up once per remote, and again only after a failed lookup, once
|
||||
* the retry delay has passed.
|
||||
*/
|
||||
export interface ForgeWebEntry {
|
||||
remoteUrl: string;
|
||||
remoteKey: string;
|
||||
forgeWeb: ForgeWeb;
|
||||
retryAtMs: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The web location available without the forge lookup: a settled answer for this
|
||||
* remote, or the host alone while its reference repository is due. Publish this
|
||||
* with the snapshot immediately; a slow or signed-out forge CLI must not hold
|
||||
* back the workspace's git state or pull request status.
|
||||
*/
|
||||
export function forgeWebForResolution(input: {
|
||||
remoteUrl: string;
|
||||
resolution: ForgeResolution;
|
||||
previous: ForgeWebEntry | null;
|
||||
nowMs: number;
|
||||
}): ForgeWebEntry {
|
||||
const remoteKey = `${input.resolution.forge}\0${input.remoteUrl}`;
|
||||
const previous = input.previous?.remoteKey === remoteKey ? input.previous : null;
|
||||
// A settled lookup stands for as long as the remote does, and a failed one keeps its retry
|
||||
// time, however often forced refreshes (every push, fetch and branch switch) ask.
|
||||
if (previous) {
|
||||
return previous;
|
||||
}
|
||||
return {
|
||||
remoteUrl: input.remoteUrl,
|
||||
remoteKey,
|
||||
forgeWeb: { host: forgeWebHost(input.resolution.forge, input.resolution.host) },
|
||||
// A lookup that has never run is due now; one that failed keeps its own retry time.
|
||||
retryAtMs: input.resolution.service.getReferenceRepository ? input.nowMs : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches an entry with the repository that owns issue and change-request
|
||||
* numbers. The links still work without it, so a failed lookup is retried later
|
||||
* rather than surfaced.
|
||||
*/
|
||||
export async function resolveForgeWebReference(input: {
|
||||
cwd: string;
|
||||
resolution: ForgeResolution;
|
||||
entry: ForgeWebEntry;
|
||||
nowMs: number;
|
||||
}): Promise<ForgeWebEntry> {
|
||||
const host = input.entry.forgeWeb.host;
|
||||
const lookup = input.resolution.service.getReferenceRepository;
|
||||
if (!lookup) {
|
||||
return input.entry.retryAtMs === null ? input.entry : { ...input.entry, retryAtMs: null };
|
||||
}
|
||||
try {
|
||||
const referenceRepository = await lookup.call(input.resolution.service, { cwd: input.cwd });
|
||||
return {
|
||||
...input.entry,
|
||||
forgeWeb: referenceRepository ? { host, referenceRepository } : { host },
|
||||
retryAtMs: null,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...input.entry,
|
||||
forgeWeb: { host },
|
||||
retryAtMs: input.nowMs + REFERENCE_REPOSITORY_RETRY_MS,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6184,6 +6184,7 @@ export class Session {
|
||||
// resolves to "github") so the sidebar/hover-card brand mark matches the
|
||||
// status projection without a second resolve.
|
||||
forge: snapshot.forge.forge,
|
||||
forgeWeb: snapshot.forgeWeb,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6619,6 +6619,45 @@ test("lists Git runtime for a checkout explicitly owned by a non-Git project", a
|
||||
});
|
||||
});
|
||||
|
||||
test("lists where the workspace's forge is on the web so every client builds the same links", async () => {
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-forge-web",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-forge-web",
|
||||
projectId: project.projectId,
|
||||
cwd: REPO_CWD,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
session.listAgentPayloads = async () => [];
|
||||
session.projectRegistry.list = async () => [project];
|
||||
session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [
|
||||
workspace,
|
||||
];
|
||||
session.workspaceRuntimeStore.get = () => ({
|
||||
...createWorkspaceRuntimeSnapshot(REPO_CWD),
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
});
|
||||
|
||||
const [descriptor] = (
|
||||
await session.buildWorkspaceDescriptorMap({ includeGitData: true })
|
||||
).values();
|
||||
|
||||
expect(descriptor?.forgeWeb).toEqual({
|
||||
host: "github.com",
|
||||
referenceRepository: "main-owner/repo",
|
||||
});
|
||||
});
|
||||
|
||||
test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fields", async () => {
|
||||
const setupSession = () => {
|
||||
const session = createSessionForWorkspaceTests();
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type CheckoutSnapshotFacts,
|
||||
type CheckoutStatusGit,
|
||||
} from "../utils/checkout-git.js";
|
||||
import { createGitHubService, GitHubCommandError } from "../services/github-service.js";
|
||||
import { createFileObserver } from "./file-observer/index.js";
|
||||
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||
import type { FileChange, FileObserver, SubscribeToFileChanges } from "./file-observer/index.js";
|
||||
@@ -1019,3 +1020,556 @@ test("a branch switch made before the Git metadata watcher starts still reaches
|
||||
)
|
||||
.toBe("feature");
|
||||
}, 30_000);
|
||||
|
||||
function createCheckoutWithOrigin(origin: string): string {
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-forge-web-")));
|
||||
const repo = path.join(root, "repo");
|
||||
mkdirSync(repo);
|
||||
const git = (...args: string[]) =>
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", ...args], { cwd: repo, stdio: "pipe" });
|
||||
git("init", "-b", "main");
|
||||
git("config", "user.email", "test@example.com");
|
||||
git("config", "user.name", "Test");
|
||||
writeFileSync(path.join(repo, "README.md"), "base\n");
|
||||
git("add", ".");
|
||||
git("commit", "-m", "base");
|
||||
git("remote", "add", "origin", origin);
|
||||
cleanup.push(async () =>
|
||||
rmSync(root, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }),
|
||||
);
|
||||
return repo;
|
||||
}
|
||||
|
||||
// The GitHub adapter runs for real; only the `gh` binary it spawns is scripted.
|
||||
function createGitHubWithRepositoryView(repositoryView: object) {
|
||||
return createGitHubService({
|
||||
resolveGhPath: async () => "gh",
|
||||
resolveRepoHost: async () => null,
|
||||
runner: async (args) => {
|
||||
if (args[0] === "repo" && args[1] === "view") {
|
||||
return { stdout: JSON.stringify(repositoryView), stderr: "" };
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function snapshotOf(
|
||||
repo: string,
|
||||
deps: ConstructorParameters<typeof WorkspaceGitServiceImpl>[0]["deps"],
|
||||
) {
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(path.dirname(repo), "home"),
|
||||
deps,
|
||||
});
|
||||
cleanup.push(() => service.dispose());
|
||||
return {
|
||||
service,
|
||||
snapshot: await service.getSnapshot(repo, { force: true, reason: "forge-web-test" }),
|
||||
};
|
||||
}
|
||||
|
||||
async function expectForgeWebEventually(
|
||||
service: WorkspaceGitServiceImpl,
|
||||
repo: string,
|
||||
expected: { host: string; referenceRepository?: string },
|
||||
) {
|
||||
await expect
|
||||
.poll(async () => (await service.getSnapshot(repo, { includeForge: false })).forgeWeb, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toEqual(expected);
|
||||
}
|
||||
|
||||
test("an SSH config alias remote publishes the web host the alias resolves to", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@github.com-work:acme/repo.git");
|
||||
|
||||
const { snapshot } = await snapshotOf(repo, {
|
||||
resolveSshHostname: async (host) => (host === "github.com-work" ? "github.com" : null),
|
||||
forgeOverrides: { github: createGitHubWithRepositoryView({}) },
|
||||
});
|
||||
|
||||
expect(snapshot.forgeWeb).toEqual({ host: "github.com" });
|
||||
});
|
||||
|
||||
test("a cloud SSH host publishes the forge's web host", async () => {
|
||||
const repo = createCheckoutWithOrigin("ssh://git@ssh.github.com/acme/repo.git");
|
||||
|
||||
const { snapshot } = await snapshotOf(repo, {
|
||||
forgeOverrides: { github: createGitHubWithRepositoryView({}) },
|
||||
});
|
||||
|
||||
expect(snapshot.forgeWeb).toEqual({ host: "github.com" });
|
||||
});
|
||||
|
||||
test("a fork checkout publishes its parent as the repository that owns issue and pull request numbers", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
|
||||
|
||||
const { service, snapshot } = await snapshotOf(repo, {
|
||||
forgeOverrides: {
|
||||
github: createGitHubWithRepositoryView({
|
||||
owner: { login: "fork-owner" },
|
||||
name: "repo",
|
||||
parent: { owner: { login: "main-owner" }, name: "repo" },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// The host is available with the first snapshot; the parent follows in a background update.
|
||||
expect(snapshot.forgeWeb).toEqual({ host: "github.com" });
|
||||
await expectForgeWebEventually(service, repo, {
|
||||
host: "github.com",
|
||||
referenceRepository: "main-owner/repo",
|
||||
});
|
||||
});
|
||||
|
||||
test("a plain remote publishes no separate reference repository", async () => {
|
||||
const repo = createCheckoutWithOrigin("https://github.com/acme/repo.git");
|
||||
|
||||
const { service, snapshot } = await snapshotOf(repo, {
|
||||
forgeOverrides: {
|
||||
github: createGitHubWithRepositoryView({ owner: { login: "acme" }, name: "repo" }),
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.forgeWeb).toEqual({ host: "github.com" });
|
||||
await expectForgeWebEventually(service, repo, { host: "github.com" });
|
||||
});
|
||||
|
||||
test("a remote on no forge publishes no web location", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@bitbucket.org:acme/repo.git");
|
||||
|
||||
const { snapshot } = await snapshotOf(repo, { resolveSshHostname: async () => null });
|
||||
|
||||
expect(snapshot.forgeWeb).toBeUndefined();
|
||||
});
|
||||
|
||||
test("changing the origin remote publishes the new remote's web location, not the previous one", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(path.dirname(repo), "home"),
|
||||
deps: {
|
||||
resolveSshHostname: async (host) => (host === "github.com-work" ? "github.com" : null),
|
||||
forgeOverrides: {
|
||||
github: createGitHubService({
|
||||
resolveGhPath: async () => "gh",
|
||||
resolveRepoHost: async () => null,
|
||||
runner: async (args) => {
|
||||
if (args[0] === "repo" && args[1] === "view") {
|
||||
const isFork = args[2] === "fork-owner/repo";
|
||||
return {
|
||||
stdout: JSON.stringify(
|
||||
isFork
|
||||
? {
|
||||
owner: { login: "fork-owner" },
|
||||
name: "repo",
|
||||
parent: { owner: { login: "main-owner" }, name: "repo" },
|
||||
}
|
||||
: { owner: { login: "acme" }, name: "other" },
|
||||
),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanup.push(() => service.dispose());
|
||||
const before = await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
await expectForgeWebEventually(service, repo, {
|
||||
host: "github.com",
|
||||
referenceRepository: "main-owner/repo",
|
||||
});
|
||||
|
||||
execFileSync("git", ["remote", "set-url", "origin", "git@github.com-work:acme/other.git"], {
|
||||
cwd: repo,
|
||||
});
|
||||
// The refresh a `.git/config` change schedules does not ask for forge data.
|
||||
const after = await service.getSnapshot(repo, {
|
||||
force: true,
|
||||
includeForge: false,
|
||||
reason: "forge-web-test",
|
||||
});
|
||||
|
||||
expect(after.git.remoteUrl).toBe("git@github.com-work:acme/other.git");
|
||||
expect(after.forgeWeb).toEqual({ host: "github.com" });
|
||||
expect(before.forgeWeb).toEqual({ host: "github.com" });
|
||||
}, 30_000);
|
||||
|
||||
test("a failed parent lookup is retried by the pull request status poll, keeping the host an SSH alias resolves to", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@github.com-work:fork-owner/repo.git");
|
||||
let nowMs = Date.parse("2026-01-01T00:00:00Z");
|
||||
let ghAnswers = false;
|
||||
let lookups = 0;
|
||||
let pollStatus: ((status: null) => void) | undefined;
|
||||
const github = createGitHubService({
|
||||
resolveGhPath: async () => "gh",
|
||||
resolveRepoHost: async () => null,
|
||||
runner: async (args) => {
|
||||
// The reference lookup names the origin repository; the PR status load asks for the cwd repo.
|
||||
if (args[0] === "repo" && args[1] === "view" && args[2] === "fork-owner/repo") {
|
||||
lookups += 1;
|
||||
if (!ghAnswers) {
|
||||
throw new GitHubCommandError({
|
||||
args,
|
||||
cwd: repo,
|
||||
exitCode: 1,
|
||||
stderr: "error connecting to api.github.com",
|
||||
});
|
||||
}
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
owner: { login: "fork-owner" },
|
||||
name: "repo",
|
||||
parent: { owner: { login: "main-owner" }, name: "repo" },
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
});
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(path.dirname(repo), "home"),
|
||||
deps: {
|
||||
now: () => new Date(nowMs),
|
||||
resolveSshHostname: async (host) => (host === "github.com-work" ? "github.com" : null),
|
||||
forgeOverrides: {
|
||||
// The adapter is real; only the poll is driven by the test instead of a timer.
|
||||
github: Object.assign(Object.create(github), {
|
||||
retainCurrentPullRequestStatusPoll: (options: { onStatus?: (status: null) => void }) => {
|
||||
pollStatus = options.onStatus;
|
||||
return { unsubscribe: () => {} };
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: repo }, () => {});
|
||||
cleanup.push(async () => {
|
||||
subscription.unsubscribe();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
const failed = await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
expect(failed.forgeWeb).toEqual({ host: "github.com" });
|
||||
await expect.poll(() => pollStatus, { timeout: 10_000 }).toBeDefined();
|
||||
await expect.poll(() => lookups, { timeout: 10_000 }).toBe(1);
|
||||
// Let the failed lookup record its retry time before advancing the clock.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
ghAnswers = true;
|
||||
nowMs += 61_000;
|
||||
pollStatus?.(null);
|
||||
|
||||
await expectForgeWebEventually(service, repo, {
|
||||
host: "github.com",
|
||||
referenceRepository: "main-owner/repo",
|
||||
});
|
||||
const forced = await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
expect(forced.forgeWeb).toEqual({ host: "github.com", referenceRepository: "main-owner/repo" });
|
||||
}, 30_000);
|
||||
|
||||
test("a successful parent lookup is reused across forced refreshes of the same remote", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
|
||||
let repositoryViews = 0;
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(path.dirname(repo), "home"),
|
||||
deps: {
|
||||
forgeOverrides: {
|
||||
github: createGitHubService({
|
||||
resolveGhPath: async () => "gh",
|
||||
resolveRepoHost: async () => null,
|
||||
runner: async (args) => {
|
||||
if (args[0] === "repo" && args[1] === "view") {
|
||||
// The parent lookup is the one that names the origin repository.
|
||||
if (args[2] === "fork-owner/repo") repositoryViews += 1;
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
owner: { login: "fork-owner" },
|
||||
name: "repo",
|
||||
parent: { owner: { login: "main-owner" }, name: "repo" },
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanup.push(() => service.dispose());
|
||||
|
||||
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
|
||||
await expectForgeWebEventually(service, repo, {
|
||||
host: "github.com",
|
||||
referenceRepository: "main-owner/repo",
|
||||
});
|
||||
expect(repositoryViews).toBe(1);
|
||||
});
|
||||
|
||||
test("a fork checked out at a detached HEAD still publishes its parent", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
|
||||
execFileSync("git", ["checkout", "--detach"], { cwd: repo, stdio: "pipe" });
|
||||
|
||||
const { service, snapshot } = await snapshotOf(repo, {
|
||||
forgeOverrides: {
|
||||
github: createGitHubWithRepositoryView({
|
||||
owner: { login: "fork-owner" },
|
||||
name: "repo",
|
||||
parent: { owner: { login: "main-owner" }, name: "repo" },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.forgeWeb).toEqual({ host: "github.com" });
|
||||
await expectForgeWebEventually(service, repo, {
|
||||
host: "github.com",
|
||||
referenceRepository: "main-owner/repo",
|
||||
});
|
||||
});
|
||||
|
||||
test("a parent lookup that finishes during a refresh keeps its answer", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
|
||||
const lookupStarted = Promise.withResolvers<void>();
|
||||
const releaseLookup = Promise.withResolvers<void>();
|
||||
const statusLoadStarted = Promise.withResolvers<void>();
|
||||
const releaseStatusLoad = Promise.withResolvers<void>();
|
||||
let holdNextStatusLoad = false;
|
||||
let repositoryViews = 0;
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(path.dirname(repo), "home"),
|
||||
deps: {
|
||||
forgeOverrides: {
|
||||
github: createGitHubService({
|
||||
resolveGhPath: async () => "gh",
|
||||
resolveRepoHost: async () => null,
|
||||
runner: async (args) => {
|
||||
if (args[0] === "auth" && args[1] === "status" && holdNextStatusLoad) {
|
||||
holdNextStatusLoad = false;
|
||||
statusLoadStarted.resolve();
|
||||
await releaseStatusLoad.promise;
|
||||
}
|
||||
if (args[0] === "repo" && args[1] === "view" && args[2] === "fork-owner/repo") {
|
||||
repositoryViews += 1;
|
||||
if (repositoryViews === 1) {
|
||||
lookupStarted.resolve();
|
||||
await releaseLookup.promise;
|
||||
}
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
owner: { login: "fork-owner" },
|
||||
name: "repo",
|
||||
parent: { owner: { login: "main-owner" }, name: "repo" },
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanup.push(() => service.dispose());
|
||||
|
||||
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
await lookupStarted.promise;
|
||||
|
||||
holdNextStatusLoad = true;
|
||||
const second = service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
await statusLoadStarted.promise;
|
||||
|
||||
// The first refresh's lookup finishes while the second refresh is still loading the PR status.
|
||||
releaseLookup.resolve();
|
||||
await expectForgeWebEventually(service, repo, {
|
||||
host: "github.com",
|
||||
referenceRepository: "main-owner/repo",
|
||||
});
|
||||
releaseStatusLoad.resolve();
|
||||
|
||||
const snapshot = await second;
|
||||
expect(snapshot.forgeWeb).toEqual({
|
||||
host: "github.com",
|
||||
referenceRepository: "main-owner/repo",
|
||||
});
|
||||
expect(repositoryViews).toBe(1);
|
||||
}, 30_000);
|
||||
|
||||
test("disposal waits for a background parent lookup", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
|
||||
const lookupStarted = Promise.withResolvers<void>();
|
||||
const releaseLookup = Promise.withResolvers<void>();
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(path.dirname(repo), "home"),
|
||||
deps: {
|
||||
forgeOverrides: {
|
||||
github: createGitHubService({
|
||||
resolveGhPath: async () => "gh",
|
||||
resolveRepoHost: async () => null,
|
||||
runner: async (args) => {
|
||||
if (args[0] === "repo" && args[1] === "view" && args[2] === "fork-owner/repo") {
|
||||
lookupStarted.resolve();
|
||||
await releaseLookup.promise;
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
owner: { login: "fork-owner" },
|
||||
name: "repo",
|
||||
parent: { owner: { login: "main-owner" }, name: "repo" },
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
await lookupStarted.promise;
|
||||
|
||||
let disposed = false;
|
||||
const disposal = service.dispose().then(() => {
|
||||
disposed = true;
|
||||
return undefined;
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(disposed).toBe(false);
|
||||
|
||||
releaseLookup.resolve();
|
||||
await disposal;
|
||||
expect(disposed).toBe(true);
|
||||
} finally {
|
||||
releaseLookup.resolve();
|
||||
await service.dispose();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("the first snapshot publishes git state and pull request status without waiting for the parent lookup", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
|
||||
writeFileSync(path.join(repo, "changed.txt"), "dirty\n");
|
||||
const lookupStarted = Promise.withResolvers<void>();
|
||||
const releaseLookup = Promise.withResolvers<void>();
|
||||
const pullRequestStatusLoaded = Promise.withResolvers<void>();
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(path.dirname(repo), "home"),
|
||||
deps: {
|
||||
forgeOverrides: {
|
||||
github: createGitHubService({
|
||||
resolveGhPath: async () => "gh",
|
||||
resolveRepoHost: async () => null,
|
||||
runner: async (args) => {
|
||||
if (args[0] === "repo" && args[1] === "view" && args[2] === "fork-owner/repo") {
|
||||
lookupStarted.resolve();
|
||||
await releaseLookup.promise;
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
owner: { login: "fork-owner" },
|
||||
name: "repo",
|
||||
parent: { owner: { login: "main-owner" }, name: "repo" },
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
if (args[0] === "pr" && args[1] === "view") {
|
||||
pullRequestStatusLoaded.resolve();
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
number: 42,
|
||||
url: "https://github.com/fork-owner/repo/pull/42",
|
||||
title: "Already known pull request",
|
||||
state: "OPEN",
|
||||
baseRefName: "main",
|
||||
headRefName: "main",
|
||||
mergedAt: null,
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanup.push(() => service.dispose());
|
||||
|
||||
let snapshot: Awaited<ReturnType<typeof service.getSnapshot>> | null = null;
|
||||
const pending = service
|
||||
.getSnapshot(repo, { force: true, reason: "forge-web-test" })
|
||||
.then((value) => {
|
||||
snapshot = value;
|
||||
return value;
|
||||
});
|
||||
|
||||
await pullRequestStatusLoaded.promise;
|
||||
// The forge lookup is still held here; the snapshot must not wait for it.
|
||||
await expect.poll(() => snapshot !== null, { timeout: 5_000 }).toBe(true);
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(snapshot!.git.currentBranch).toBe("main");
|
||||
expect(snapshot!.git.isDirty).toBe(true);
|
||||
expect(snapshot!.forge.pullRequest?.number).toBe(42);
|
||||
expect(snapshot!.forgeWeb).toEqual({ host: "github.com" });
|
||||
|
||||
await lookupStarted.promise;
|
||||
releaseLookup.resolve();
|
||||
await pending;
|
||||
await expectForgeWebEventually(service, repo, {
|
||||
host: "github.com",
|
||||
referenceRepository: "main-owner/repo",
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
test("a failed parent lookup is not repeated by forced refreshes before the retry delay", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
|
||||
let lookups = 0;
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(path.dirname(repo), "home"),
|
||||
deps: {
|
||||
forgeOverrides: {
|
||||
github: createGitHubService({
|
||||
resolveGhPath: async () => "gh",
|
||||
resolveRepoHost: async () => null,
|
||||
runner: async (args) => {
|
||||
if (args[0] === "repo" && args[1] === "view" && args[2] === "fork-owner/repo") {
|
||||
lookups += 1;
|
||||
throw new GitHubCommandError({
|
||||
args,
|
||||
cwd: repo,
|
||||
exitCode: 1,
|
||||
stderr: "error connecting to api.github.com",
|
||||
});
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanup.push(() => service.dispose());
|
||||
|
||||
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
await expect.poll(() => lookups, { timeout: 10_000 }).toBe(1);
|
||||
// Let the failed lookup record its retry time.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
|
||||
expect(lookups).toBe(1);
|
||||
}, 30_000);
|
||||
|
||||
@@ -264,6 +264,8 @@ function createSnapshot(cwd: string, overrides?: SnapshotOverrides): WorkspaceGi
|
||||
pullRequest: resolveSnapshotPullRequest(base, overrides),
|
||||
error: resolveSnapshotError(base, overrides),
|
||||
},
|
||||
// Every remote these fixtures use is on github.com.
|
||||
...(forge ? { forgeWeb: { host: "github.com" } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -704,8 +706,9 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
|
||||
refreshStatus.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" }));
|
||||
await refresh;
|
||||
expect(await service.getSnapshot(REPO_CWD)).toEqual(
|
||||
createSnapshot(REPO_CWD, {
|
||||
// A branch change resets the PR status but not where the same remote lives on the web.
|
||||
expect(await service.getSnapshot(REPO_CWD)).toEqual({
|
||||
...createSnapshot(REPO_CWD, {
|
||||
git: {
|
||||
currentBranch: "feature",
|
||||
diffStat: { additions: 4, deletions: 2 },
|
||||
@@ -716,7 +719,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
forgeWeb: { host: "github.com" },
|
||||
});
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
expect(getCheckoutShortstat).toHaveBeenCalledTimes(2);
|
||||
|
||||
|
||||
@@ -93,6 +93,8 @@ function createSnapshot(
|
||||
pullRequest: resolveSnapshotPullRequest(base, overrides),
|
||||
error: resolveSnapshotError(base, overrides),
|
||||
},
|
||||
// Every remote these fixtures use is on github.com.
|
||||
...(forgeName ? { forgeWeb: { host: "github.com" } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { LRUCache } from "lru-cache";
|
||||
import { CheckoutDiffCache } from "./checkout-diff-cache.js";
|
||||
import pLimit from "p-limit";
|
||||
import type pino from "pino";
|
||||
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
|
||||
import type { ForgeWeb, ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
|
||||
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import type { CheckoutContext } from "../utils/checkout-git.js";
|
||||
import {
|
||||
@@ -71,6 +71,12 @@ import {
|
||||
type SubscribeToFileChanges,
|
||||
createFileObserver,
|
||||
} from "./file-observer/index.js";
|
||||
import {
|
||||
forgeWebForResolution,
|
||||
resolveForgeWebReference,
|
||||
type ForgeWebEntry,
|
||||
} from "./forge-web.js";
|
||||
import type { SshHostnameResolver } from "../utils/ssh-hostname.js";
|
||||
import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
|
||||
import { createWatcherLivenessCanary } from "./watcher-liveness-canary.js";
|
||||
|
||||
@@ -200,6 +206,8 @@ export interface WorkspaceGitRuntimeSnapshot {
|
||||
} | null;
|
||||
error: { message: string } | null;
|
||||
};
|
||||
/** Absent when the remote resolves to no forge. */
|
||||
forgeWeb?: ForgeWeb;
|
||||
}
|
||||
|
||||
export interface WorkspaceGitService {
|
||||
@@ -406,6 +414,8 @@ interface WorkspaceGitServiceDependencies {
|
||||
* fakes. Any forge not listed here is built (and cached once) by the registry.
|
||||
*/
|
||||
forgeOverrides?: Record<string, ForgeService>;
|
||||
/** Resolves an SSH config alias to its HostName; defaults to `ssh -G`. */
|
||||
resolveSshHostname?: SshHostnameResolver;
|
||||
resolveAbsoluteGitDir: (cwd: string) => Promise<string | null>;
|
||||
hasOriginRemote: (cwd: string) => Promise<boolean>;
|
||||
runGitFetch: (
|
||||
@@ -459,6 +469,11 @@ interface WorkspaceGitTarget {
|
||||
latestGitLoadedAtMs: number | null;
|
||||
latestForge: WorkspaceGitRuntimeSnapshot["forge"] | null;
|
||||
latestForgeLoadedAtMs: number | null;
|
||||
latestForgeWeb: ForgeWebEntry | null;
|
||||
/** The entry whose reference repository is being looked up, so one remote costs one call. */
|
||||
forgeWebLookupEntry: ForgeWebEntry | null;
|
||||
/** The remote the forge snapshot was last refreshed for; null until the first forge refresh. */
|
||||
forgeRefreshedForRemote: { remoteUrl: string | null } | null;
|
||||
latestSnapshot: WorkspaceGitRuntimeSnapshot | null;
|
||||
latestSnapshotLoadedAtMs: number | null;
|
||||
latestFacts: CheckoutSnapshotFacts | null;
|
||||
@@ -647,6 +662,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
private disposed = false;
|
||||
private disposePromise: Promise<void> | null = null;
|
||||
private readonly pendingRefreshes = new Set<Promise<WorkspaceGitRuntimeSnapshot>>();
|
||||
private readonly pendingForgeWebLookups = new Set<Promise<void>>();
|
||||
private readonly snapshotUpdatedListeners = new Set<WorkspaceGitSnapshotUpdatedListener>();
|
||||
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
|
||||
private readonly repoTargets = new Map<string, RepoGitTarget>();
|
||||
@@ -691,6 +707,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
);
|
||||
this.forgeResolver = createForgeResolver({
|
||||
createService: (forge) => this.deps.forgeOverrides?.[forge] ?? createForgeService(forge),
|
||||
resolveSshHostname: this.deps.resolveSshHostname,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1140,11 +1157,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
this.workingTreeWatchAliases.clear();
|
||||
this.snapshotUpdatedListeners.clear();
|
||||
// Unsubscribing can remove a target while its Git command still owns the cwd.
|
||||
// Stop admission first, then drain those refreshes before releasing the service.
|
||||
this.disposePromise = Promise.all([
|
||||
this.fileObserver.close(),
|
||||
Promise.allSettled(this.pendingRefreshes),
|
||||
]).then(() => undefined);
|
||||
// Stop admission first, then drain those refreshes and the parent lookups they start.
|
||||
this.disposePromise = (async () => {
|
||||
await Promise.all([this.fileObserver.close(), Promise.allSettled(this.pendingRefreshes)]);
|
||||
await Promise.allSettled(this.pendingForgeWebLookups);
|
||||
})();
|
||||
return this.disposePromise;
|
||||
}
|
||||
|
||||
@@ -1301,6 +1318,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
latestGit: null,
|
||||
latestGitLoadedAtMs: null,
|
||||
latestForge: null,
|
||||
latestForgeWeb: null,
|
||||
forgeWebLookupEntry: null,
|
||||
forgeRefreshedForRemote: null,
|
||||
latestForgeLoadedAtMs: null,
|
||||
latestSnapshot: null,
|
||||
latestSnapshotLoadedAtMs: null,
|
||||
@@ -3568,6 +3588,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
if (request.includeForge) {
|
||||
await this.refreshForgeSnapshot(target, request, facts, runRefreshGitCommand);
|
||||
} else if (this.hasRemoteChangedSinceForgeRefresh(target)) {
|
||||
// `git remote set-url` reaches here as a git-only refresh. The forge, its host and the
|
||||
// fork's parent all belong to the old remote, so resolve the new one now.
|
||||
await this.refreshForgeSnapshot(
|
||||
target,
|
||||
{ ...request, includeForge: true, force: true },
|
||||
facts,
|
||||
runRefreshGitCommand,
|
||||
);
|
||||
}
|
||||
|
||||
const snapshot = this.combineSnapshot(target);
|
||||
@@ -3669,6 +3698,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
const loadedAtMs = this.deps.now().getTime();
|
||||
target.latestGitLoadedAtMs = loadedAtMs;
|
||||
target.latestForge = buildForgeUnavailableSnapshot();
|
||||
target.latestForgeWeb = null;
|
||||
target.latestForgeLoadedAtMs = target.latestGitLoadedAtMs;
|
||||
return facts;
|
||||
}
|
||||
@@ -3713,6 +3743,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return facts;
|
||||
}
|
||||
|
||||
private hasRemoteChangedSinceForgeRefresh(target: WorkspaceGitTarget): boolean {
|
||||
const refreshedFor = target.forgeRefreshedForRemote;
|
||||
return (
|
||||
refreshedFor !== null &&
|
||||
target.latestGit?.isGit === true &&
|
||||
refreshedFor.remoteUrl !== target.latestGit.remoteUrl
|
||||
);
|
||||
}
|
||||
|
||||
private async refreshForgeSnapshot(
|
||||
target: WorkspaceGitTarget,
|
||||
request: WorkspaceGitRefreshRequest,
|
||||
@@ -3720,13 +3759,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
runRefreshGitCommand: RunGitCommand,
|
||||
): Promise<void> {
|
||||
const remoteUrl = target.latestGit?.remoteUrl ?? null;
|
||||
target.forgeRefreshedForRemote = { remoteUrl };
|
||||
const resolution = await this.forgeResolver.resolveFromRemoteUrlAsync(remoteUrl);
|
||||
// Every forge gates on the resolver alone: a cloud host matches synchronously
|
||||
// and a self-hosted/Enterprise host is recognized by the adapter probe (which
|
||||
// this async resolution populates), so GitHub Enterprise is no longer gated
|
||||
// out by a cloud-only identity check.
|
||||
if (!resolution) {
|
||||
if (!resolution || !remoteUrl) {
|
||||
target.latestForge = buildUnresolvedRemoteForgeSnapshot(remoteUrl);
|
||||
target.latestForgeWeb = null;
|
||||
target.latestForgeLoadedAtMs = this.deps.now().getTime();
|
||||
return;
|
||||
}
|
||||
@@ -3749,7 +3790,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
// Carry the resolved forge (probe-aware) so the wire projection labels
|
||||
// self-managed GitLab hosts correctly instead of falling back to "github".
|
||||
target.latestForge = { ...forgeSnapshot, forge: resolution.forge };
|
||||
// Publish the host with the snapshot; the parent lookup finishes in the background. Read the
|
||||
// previous entry after the wait: a lookup for the old entry may have completed meanwhile.
|
||||
target.latestForgeWeb = forgeWebForResolution({
|
||||
remoteUrl,
|
||||
resolution,
|
||||
previous: target.latestForgeWeb,
|
||||
nowMs: this.deps.now().getTime(),
|
||||
});
|
||||
target.latestForgeLoadedAtMs = this.deps.now().getTime();
|
||||
this.queueForgeWebReferenceLookup(target);
|
||||
}
|
||||
|
||||
private combineSnapshot(target: WorkspaceGitTarget): WorkspaceGitRuntimeSnapshot {
|
||||
@@ -3761,6 +3811,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
cwd: target.cwd,
|
||||
git: target.latestGit,
|
||||
forge: target.latestForge ?? buildForgeUnavailableSnapshot(),
|
||||
// Only the remote it was resolved for: after `git remote set-url` the old host and
|
||||
// parent are wrong until the next forge refresh resolves the new remote.
|
||||
...(target.latestForgeWeb?.remoteUrl === target.latestGit.remoteUrl
|
||||
? { forgeWeb: target.latestForgeWeb.forgeWeb }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3800,6 +3855,68 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
this.rememberSnapshot(target, this.combineSnapshot(target), {
|
||||
forceEmit: false,
|
||||
});
|
||||
this.queueForgeWebReferenceLookup(target);
|
||||
}
|
||||
|
||||
private queueForgeWebReferenceLookup(target: WorkspaceGitTarget): void {
|
||||
const lookup = this.resolveForgeWebReferenceIfDue(target)
|
||||
.catch((error) => {
|
||||
if (!this.disposed) {
|
||||
this.logger.warn(
|
||||
{ err: error, cwd: target.cwd },
|
||||
"Failed to resolve the forge reference repository",
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.pendingForgeWebLookups.delete(lookup);
|
||||
});
|
||||
this.pendingForgeWebLookups.add(lookup);
|
||||
}
|
||||
|
||||
// The status poll is the only periodic work an idle workspace has, so it also
|
||||
// retries a parent lookup that failed or ran before the forge CLI was signed in.
|
||||
private async resolveForgeWebReferenceIfDue(target: WorkspaceGitTarget): Promise<void> {
|
||||
const entry = target.latestForgeWeb;
|
||||
const remoteUrl = target.latestGit?.remoteUrl ?? null;
|
||||
const nowMs = this.deps.now().getTime();
|
||||
if (
|
||||
!entry ||
|
||||
!remoteUrl ||
|
||||
entry.remoteUrl !== remoteUrl ||
|
||||
entry.retryAtMs === null ||
|
||||
nowMs < entry.retryAtMs ||
|
||||
target.forgeWebLookupEntry === entry
|
||||
) {
|
||||
return;
|
||||
}
|
||||
target.forgeWebLookupEntry = entry;
|
||||
try {
|
||||
// The async resolver, like the forge refresh: only it reports the `ssh -G` host for an alias.
|
||||
const resolution = await this.forgeResolver.resolveFromRemoteUrlAsync(remoteUrl);
|
||||
if (!resolution) {
|
||||
return;
|
||||
}
|
||||
const next = await resolveForgeWebReference({
|
||||
cwd: target.cwd,
|
||||
resolution,
|
||||
entry,
|
||||
nowMs,
|
||||
});
|
||||
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
||||
return;
|
||||
}
|
||||
// The origin can change while the lookup runs; the old remote's answer is not ours to publish.
|
||||
if (target.latestForgeWeb !== entry || target.latestGit?.remoteUrl !== entry.remoteUrl) {
|
||||
return;
|
||||
}
|
||||
target.latestForgeWeb = next;
|
||||
this.rememberSnapshot(target, this.combineSnapshot(target), { forceEmit: false });
|
||||
} finally {
|
||||
if (target.forgeWebLookupEntry === entry) {
|
||||
target.forgeWebLookupEntry = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private rememberSnapshot(
|
||||
|
||||
@@ -467,6 +467,13 @@ export interface ForgeService {
|
||||
* Others reject a cross-repo checkout that carries no refs.
|
||||
*/
|
||||
supportsCrossRepoCheckoutWithoutRefs?: boolean;
|
||||
/**
|
||||
* Repository path that owns the issue and change-request numbers of the
|
||||
* checkout's origin repository when that is a different repository, such as a
|
||||
* fork's parent. Null when the origin owns its numbers. Adapters whose forge
|
||||
* has no fork model omit it.
|
||||
*/
|
||||
getReferenceRepository?(options: { cwd: string } & ForgeReadOptions): Promise<string | null>;
|
||||
getCurrentPullRequestStatus(
|
||||
options: {
|
||||
cwd: string;
|
||||
|
||||
@@ -429,6 +429,41 @@ function giteaMergeStatus(
|
||||
};
|
||||
}
|
||||
|
||||
describe("createGiteaService getReferenceRepository", () => {
|
||||
it("names the repository a fork was forked from, asking about the origin repository explicitly", async () => {
|
||||
const { service, calls } = makeService(() =>
|
||||
ok(
|
||||
JSON.stringify({
|
||||
full_name: "example-user/sample-repo",
|
||||
fork: true,
|
||||
parent: { full_name: "upstream-org/sample-repo" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(service.getReferenceRepository?.({ cwd: "/repo" })).resolves.toBe(
|
||||
"upstream-org/sample-repo",
|
||||
);
|
||||
expect(calls).toEqual([["api", "repos/example-user/sample-repo"]]);
|
||||
});
|
||||
|
||||
it("rejects when tea api answers with an error body, since tea exits 0 on HTTP errors", async () => {
|
||||
const { service } = makeService(() =>
|
||||
ok(JSON.stringify({ message: "user does not exist [uid: 0, name: ]" })),
|
||||
);
|
||||
|
||||
await expect(service.getReferenceRepository?.({ cwd: "/repo" })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("is null for a repository that is not a fork", async () => {
|
||||
const { service } = makeService(() =>
|
||||
ok(JSON.stringify({ full_name: "example-user/sample-repo", fork: false, parent: null })),
|
||||
);
|
||||
|
||||
await expect(service.getReferenceRepository?.({ cwd: "/repo" })).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createGiteaService", () => {
|
||||
it("maps a tea pr list item to the neutral current PR status by head branch", async () => {
|
||||
const { service, calls } = makeService((args) => {
|
||||
|
||||
@@ -247,6 +247,14 @@ const GiteaPullRequestApiSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
// `full_name` is required so an HTTP error body, which `tea api` returns with exit code 0, fails the parse.
|
||||
const GiteaRepositoryApiSchema = z
|
||||
.object({
|
||||
full_name: z.string(),
|
||||
parent: z.object({ full_name: z.string().optional() }).nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const GiteaCurrentPullRequestApiSchema = z
|
||||
.object({
|
||||
number: z.number(),
|
||||
@@ -2007,6 +2015,19 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
|
||||
}
|
||||
},
|
||||
|
||||
async getReferenceRepository(input): Promise<string | null> {
|
||||
const repo = await resolveCurrentRepoIdentity(input.cwd);
|
||||
if (!repo) {
|
||||
return null;
|
||||
}
|
||||
const repository = await runJson(
|
||||
["api", `repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}`],
|
||||
{ cwd: input.cwd },
|
||||
GiteaRepositoryApiSchema,
|
||||
);
|
||||
return repository.parent?.full_name ?? null;
|
||||
},
|
||||
|
||||
getCurrentPullRequestStatus(input): Promise<CurrentPullRequestStatus | null> {
|
||||
return loadCurrentPullRequestStatus({
|
||||
cwd: input.cwd,
|
||||
|
||||
@@ -528,6 +528,65 @@ describe("ForgeService", () => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
describe("getReferenceRepository", () => {
|
||||
const forkView = JSON.stringify({
|
||||
owner: { login: "fork-owner" },
|
||||
name: "repo",
|
||||
parent: { owner: { login: "main-owner" }, name: "repo" },
|
||||
});
|
||||
|
||||
it("names the parent of a fork checkout, asking about the origin repository explicitly", async () => {
|
||||
const runner = createRunner([forkView]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveRepoHost: async () => null,
|
||||
resolveOriginRepository: async () => "fork-owner/repo",
|
||||
});
|
||||
|
||||
await expect(service.getReferenceRepository?.({ cwd: "/tmp/repo" })).resolves.toBe(
|
||||
"main-owner/repo",
|
||||
);
|
||||
expect(runner.calls).toHaveLength(1);
|
||||
expect(runner.calls[0].args).toEqual([
|
||||
"repo",
|
||||
"view",
|
||||
"fork-owner/repo",
|
||||
"--json",
|
||||
"owner,name,parent",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects when gh cannot answer, so a failed lookup is not mistaken for a repository without a parent", async () => {
|
||||
const service = createGitHubService({
|
||||
runner: async () => {
|
||||
throw new GitHubCommandError({
|
||||
args: ["repo", "view"],
|
||||
cwd: "/tmp/repo",
|
||||
exitCode: 1,
|
||||
stderr: "error connecting to api.github.com",
|
||||
});
|
||||
},
|
||||
resolveRepoHost: async () => null,
|
||||
resolveOriginRepository: async () => "fork-owner/repo",
|
||||
});
|
||||
|
||||
await expect(service.getReferenceRepository?.({ cwd: "/tmp/repo" })).rejects.toBeInstanceOf(
|
||||
GitHubCommandError,
|
||||
);
|
||||
});
|
||||
|
||||
it("is null for a repository that is not a fork", async () => {
|
||||
const runner = createRunner([JSON.stringify({ owner: { login: "acme" }, name: "repo" })]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveRepoHost: async () => null,
|
||||
resolveOriginRepository: async () => "acme/repo",
|
||||
});
|
||||
|
||||
await expect(service.getReferenceRepository?.({ cwd: "/tmp/repo" })).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
isGitHubHost,
|
||||
parseGitHubRemoteIdentity,
|
||||
parseGitHubRemoteUrl,
|
||||
parseGitRemoteLocation,
|
||||
} from "@getpaseo/protocol/git-remote";
|
||||
@@ -959,6 +960,11 @@ interface GitHubServiceDependencies {
|
||||
* per invocation.
|
||||
*/
|
||||
resolveRepoSlug: (cwd: string) => Promise<string | null>;
|
||||
/**
|
||||
* owner/name of the origin remote on any host: an SSH alias or Enterprise
|
||||
* host included, which `resolveRepoSlug` leaves to `gh`.
|
||||
*/
|
||||
resolveOriginRepository: (cwd: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export interface GitHubCommandRunnerOptions {
|
||||
@@ -1050,6 +1056,7 @@ interface CreateGitHubServiceOptions {
|
||||
now?: () => number;
|
||||
resolveRepoHost?: (cwd: string) => Promise<string | null>;
|
||||
resolveRepoSlug?: (cwd: string) => Promise<string | null>;
|
||||
resolveOriginRepository?: (cwd: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
type PullRequestCheckRunNode = z.infer<typeof PullRequestCheckRunNodeSchema>;
|
||||
@@ -1132,6 +1139,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
now: options.now ?? Date.now,
|
||||
resolveRepoHost: options.resolveRepoHost ?? resolveGitHubEnterpriseHost,
|
||||
resolveRepoSlug: options.resolveRepoSlug ?? resolveGitHubSlugFromOrigin,
|
||||
resolveOriginRepository: options.resolveOriginRepository ?? resolveOriginRepositoryPath,
|
||||
};
|
||||
// A resolved enterprise host is cached permanently; a null resolution (no
|
||||
// host, or the auth probe said no) expires so `gh auth login --hostname`
|
||||
@@ -2543,6 +2551,26 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
getReferenceRepository(input) {
|
||||
return cached({
|
||||
cwd: input.cwd,
|
||||
method: "getReferenceRepository",
|
||||
args: {},
|
||||
readOptions: input,
|
||||
load: async () => {
|
||||
const repo = await deps.resolveOriginRepository(input.cwd);
|
||||
if (!repo) {
|
||||
return null;
|
||||
}
|
||||
// A failed lookup must reject: "no parent" is cached as a final answer.
|
||||
const view = await readGitHubRepoView({ cwd: input.cwd, repo, run });
|
||||
const parentOwner = view.parent?.owner?.login;
|
||||
const parentName = view.parent?.name;
|
||||
return parentOwner && parentName ? `${parentOwner}/${parentName}` : null;
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
isAuthenticated(input) {
|
||||
return cached({
|
||||
cwd: input.cwd,
|
||||
@@ -3224,20 +3252,53 @@ async function resolveGitHubSlugFromOrigin(cwd: string): Promise<string | null>
|
||||
return parseGitHubRemoteUrl(stdout.trim())?.repo ?? null;
|
||||
}
|
||||
|
||||
async function getGitHubRepoView(options: {
|
||||
cwd: string;
|
||||
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
||||
}): Promise<z.infer<typeof GitHubRepoViewSchema> | null> {
|
||||
const args = ["repo", "view", "--json", "owner,name,parent"];
|
||||
async function resolveOriginRepositoryPath(cwd: string): Promise<string | null> {
|
||||
let stdout: string;
|
||||
try {
|
||||
const stdout = await options.run(args, {
|
||||
cwd: options.cwd,
|
||||
});
|
||||
return parseGitHubJsonOutput(stdout, GitHubRepoViewSchema, {
|
||||
args,
|
||||
cwd: options.cwd,
|
||||
emptyFallback: "{}",
|
||||
});
|
||||
({ stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], {
|
||||
cwd,
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
||||
timeout: GIT_ORIGIN_URL_READ_TIMEOUT_MS,
|
||||
}));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const location = parseGitRemoteLocation(stdout.trim());
|
||||
return location ? (parseGitHubRemoteIdentity(location.path)?.repo ?? null) : null;
|
||||
}
|
||||
|
||||
interface GitHubRepoViewOptions {
|
||||
cwd: string;
|
||||
/** owner/name to ask about; `gh` picks the default repository for the directory when absent. */
|
||||
repo?: string;
|
||||
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
||||
}
|
||||
|
||||
async function readGitHubRepoView(
|
||||
options: GitHubRepoViewOptions,
|
||||
): Promise<z.infer<typeof GitHubRepoViewSchema>> {
|
||||
const args = [
|
||||
"repo",
|
||||
"view",
|
||||
...(options.repo ? [options.repo] : []),
|
||||
"--json",
|
||||
"owner,name,parent",
|
||||
];
|
||||
const stdout = await options.run(args, {
|
||||
cwd: options.cwd,
|
||||
});
|
||||
return parseGitHubJsonOutput(stdout, GitHubRepoViewSchema, {
|
||||
args,
|
||||
cwd: options.cwd,
|
||||
emptyFallback: "{}",
|
||||
});
|
||||
}
|
||||
|
||||
async function getGitHubRepoView(
|
||||
options: GitHubRepoViewOptions,
|
||||
): Promise<z.infer<typeof GitHubRepoViewSchema> | null> {
|
||||
try {
|
||||
return await readGitHubRepoView(options);
|
||||
} catch (error) {
|
||||
// A missing CLI or an auth failure must surface as its typed class so the
|
||||
// caller reports the real problem; only a genuine "not a resolvable repo"
|
||||
|
||||
@@ -260,6 +260,38 @@ const DISCUSSIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
describe("createGitLabService getReferenceRepository", () => {
|
||||
it("names the project a fork was forked from, asking about the origin project explicitly", async () => {
|
||||
const { service, calls } = makeService(() =>
|
||||
ok(
|
||||
JSON.stringify({
|
||||
path_with_namespace: "example-group/example-project",
|
||||
forked_from_project: { path_with_namespace: "upstream-group/sub/example-project" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(service.getReferenceRepository?.({ cwd: "/repo" })).resolves.toBe(
|
||||
"upstream-group/sub/example-project",
|
||||
);
|
||||
expect(calls).toEqual([["api", "projects/example-group%2Fexample-project"]]);
|
||||
});
|
||||
|
||||
it("rejects when glab api answers with an error body", async () => {
|
||||
const { service } = makeService(() => ok(JSON.stringify({ message: "404 Project Not Found" })));
|
||||
|
||||
await expect(service.getReferenceRepository?.({ cwd: "/repo" })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("is null for a project that is not a fork", async () => {
|
||||
const { service } = makeService(() =>
|
||||
ok(JSON.stringify({ path_with_namespace: "example-group/example-project" })),
|
||||
);
|
||||
|
||||
await expect(service.getReferenceRepository?.({ cwd: "/repo" })).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createGitLabService", () => {
|
||||
it("maps a glab merge request view to the neutral current PR status", async () => {
|
||||
const { service, calls } = makeService((args) =>
|
||||
|
||||
@@ -249,6 +249,16 @@ const GitLabApprovalsSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const GitLabProjectSchema = z
|
||||
.object({
|
||||
path_with_namespace: z.string(),
|
||||
forked_from_project: z
|
||||
.object({ path_with_namespace: z.string().optional() })
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type GitLabMergeRequest = z.infer<typeof GitLabMergeRequestSchema>;
|
||||
type GitLabIssue = z.infer<typeof GitLabIssueSchema>;
|
||||
type GitLabNote = z.infer<typeof GitLabNoteSchema>;
|
||||
@@ -1151,6 +1161,20 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
|
||||
}
|
||||
},
|
||||
|
||||
async getReferenceRepository(input): Promise<string | null> {
|
||||
const remoteUrl = await resolveRemoteUrl(input.cwd);
|
||||
const projectPath = remoteUrl ? parseGitRemoteLocation(remoteUrl)?.path : null;
|
||||
if (!projectPath) {
|
||||
return null;
|
||||
}
|
||||
const project = await runJson(
|
||||
["api", `projects/${encodeURIComponent(projectPath)}`],
|
||||
{ cwd: input.cwd },
|
||||
GitLabProjectSchema,
|
||||
);
|
||||
return project.forked_from_project?.path_with_namespace ?? null;
|
||||
},
|
||||
|
||||
async getCurrentPullRequestStatus(input): Promise<CurrentPullRequestStatus | null> {
|
||||
try {
|
||||
const mr = await resolveCurrentMergeRequest(input.cwd, input.headRef, input.headSha);
|
||||
|
||||
Reference in New Issue
Block a user