Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 43f5a41847 test(forge): retry temp cleanup after the poll lookup on Windows 2026-11-13 12:32:03 +01:00
Mohamed Boudra a0408321fd fix(forge): drain the background parent lookup on dispose 2026-11-13 12:07:31 +01:00
Mohamed Boudra 7fa66e2357 test(forge): make the parent lookup tests Windows-safe 2026-11-13 11:40:13 +01:00
Mohamed Boudra 60b93a0688 fix(forge): keep a parent answer that lands during a refresh 2026-11-13 11:06:32 +01:00
Mohamed Boudra c6bc0d0af2 fix(forge): keep the parent lookup off the workspace refresh path 2026-11-13 10:59:10 +01:00
Mohamed Boudra 6b2db04c91 fix(forge): treat a Gitea or GitLab API error body as a failed parent lookup
`tea api` exits 0 on HTTP errors, so an error body parsed as a repository without a parent and was
cached as "not a fork". The repository schemas now require the repository's own name, so an error
body rejects and the lookup is retried. The shorthand provider also keys its context on the
forgeWeb strings, so unrelated workspace updates no longer re-render every Markdown message.
2026-11-13 10:01:44 +01:00
Mohamed Boudra 9345dd2d86 fix(forge): keep the alias-resolved host when a failed parent lookup is retried
The poll's retry resolved the forge with the synchronous resolver, which reports the raw SSH alias
as the host. A lookup that settled on retry then published and kept `github.com-work` as the web
host. The retry now uses the async resolver, like the forge refresh, so the host means the same
thing on both paths.
2026-11-13 09:42:03 +01:00
Mohamed Boudra d14513a2ef fix(forge): resolve the new remote's web location when origin changes
`git remote set-url` reaches the daemon as a git-only refresh, which stopped publishing the old
remote's forgeWeb but never resolved the new one, so links fell back to parsing the remote.
A git refresh that finds the remote changed since the last forge refresh now runs a forced forge
refresh, which also probes a newly seen SSH alias.
2026-11-13 09:32:08 +01:00
Mohamed Boudra 70daf6962d fix(forge): look up a fork's parent once per remote, on any checkout
- A successful parent lookup is kept for the life of the remote instead of being repeated by every
  forced refresh (each push, fetch and branch switch).
- The lookup no longer waits on the pull request status's auth state, which is "no_remote" on a
  detached HEAD; a failed or signed-out lookup is retried on the next forced refresh or poll.
2026-11-13 09:12:06 +01:00
Mohamed Boudra ee32792257 fix(forge): keep the published web location tied to its remote and retry failed parent lookups
- A web location resolved for one origin remote is no longer published after
  `git remote set-url`; it returns when the next forge refresh resolves the new remote.
- The GitHub parent lookup rejects on a gh failure instead of returning null, so a
  network error is retried rather than cached as "this repository has no parent".
- The pull request status poll retries a due lookup, so an idle workspace recovers
  without waiting for a forced refresh.
- Workspace snapshot fixtures now expect `forgeWeb` for github.com remotes.
2026-11-13 09:05:55 +01:00
Mohamed Boudra a8d44c3d89 fix(forge): build web links from the host and repository the daemon resolves
The app built forge links by parsing the origin remote itself, so an SSH
config alias (git@github.com-work:...) produced https://github.com-work/...
and a fork checkout sent #123 to the fork instead of the repository that owns
the numbers.

The daemon now publishes `forgeWeb` { host, referenceRepository? } on the
workspace descriptor and on checkout status, and the app builds every forge URL
from it. The host is the ssh -G resolved cloud host. referenceRepository is the
fork's parent, read through an optional ForgeService.getReferenceRepository
(GitHub, GitLab, Gitea family). Branch and file links stay on the origin
repository because a feature branch usually exists only on the fork.

Old daemons omit the field and the app derives links from the remote as before
(tagged COMPAT(forgeWeb)).
2026-11-13 08:52:58 +01:00
31 changed files with 1403 additions and 47 deletions
+32
View File
@@ -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
+16 -1
View File
@@ -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]),
+78
View File
@@ -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");
});
});
+32 -20
View File
@@ -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>
+2
View File
@@ -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,
+20
View File
@@ -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);
+19
View File
@@ -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,
+79
View File
@@ -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,
};
}
}
+1
View File
@@ -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();
});
+74 -13
View File
@@ -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);