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

* refactor(forge): forge-neutral foundation (GitHub-only)

Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged.

- Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry.
- GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved.
- forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client).
- createPullRequest reports typed CLI/auth errors instead of a generic message.
- forge-resolver host/remote caches are LRU-bounded.
- Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com.
- Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology.
- Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs.

* feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals

GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals.

- threadIsResolved is part of the neutral timeline item.
- Pipeline load failures show an error instead of an empty section.
- Manual pipeline jobs render as pending.
- Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request).

* feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg)

One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts.

- CI status aggregates commit statuses and Actions runs together.
- Gitea's terminal "warning" state maps to failure on server and client.
- Gitea Actions check details are reachable from the PR pane by workflowRunId.

* refactor(forge): localize compatibility handling

* test(forge): expect normalized GitLab facts

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
nllptrx
2026-07-17 15:03:26 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 04d1ebdce0
commit a8ebd390fa
187 changed files with 20988 additions and 2907 deletions
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import type pino from "pino";
import type { GitHubService } from "../../services/github-service.js";
import type { ForgeService } from "../../services/forge-service.js";
import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
import {
archiveByScope,
@@ -26,7 +26,7 @@ interface CreateAgentLifecycleDispatchDependencies {
worktreesRoot?: string;
agentManager: AgentManager;
agentStorage: AgentStorage;
github: GitHubService;
github: ForgeService;
workspaceGitService: WorkspaceGitService;
createPaseoWorktreeWorkflow: CreatePaseoWorktreeWorkflowFn;
archiveAgentForClose: (agentId: string) => Promise<unknown>;
@@ -45,7 +45,7 @@ import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
import { WorkspaceAutoName } from "../workspace-auto-name.js";
import { createGitMutationService } from "../session/git-mutation/git-mutation-service.js";
import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.js";
import type { GitHubService } from "../../services/github-service.js";
import type { ForgeService } from "../../services/forge-service.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
@@ -505,11 +505,15 @@ function createManagedAgent(overrides: Partial<ManagedAgent> = {}): ManagedAgent
} as ManagedAgent;
}
function createGitHubServiceStub(): GitHubService {
function createGitHubServiceStub(): ForgeService {
return {
listPullRequests: async () => [],
listIssues: async () => [],
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
searchIssuesAndPrs: async () => ({
items: [],
featuresEnabled: true,
githubFeaturesEnabled: true,
}),
getPullRequest: async ({ number }) => ({
number,
title: `PR ${number}`,
@@ -521,6 +525,15 @@ function createGitHubServiceStub(): GitHubService {
labels: [],
}),
getPullRequestHeadRef: async ({ number }) => `pr-${number}`,
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: `pr-${number}`,
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
}),
getCurrentPullRequestStatus: async () => null,
createPullRequest: async () => ({
number: 1,
@@ -655,7 +668,7 @@ function createPaseoWorktreeForMcpTest(options: {
const workspaceGitService = new WorkspaceGitServiceImpl({
logger: createTestLogger(),
paseoHome: options.paseoHome,
deps: { github },
deps: { forgeOverrides: { github } },
});
const workspaceRegistry = {
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
@@ -52,6 +52,23 @@ describe("prompt attachments", () => {
).toContain("GitHub PR #123: Fix race in worktree setup");
});
it("renders GitLab change request attachments with MR numbering", () => {
expect(
renderPromptAttachmentAsText({
type: "forge_change_request",
mimeType: "application/paseo-forge-change-request",
forge: "gitlab",
number: 123,
title: "Fix race in worktree setup",
url: "https://gitlab.com/getpaseo/paseo/-/merge_requests/123",
body: "MR body",
projectPath: "getpaseo/paseo",
baseRefName: "main",
headRefName: "fix/worktree-race",
}),
).toContain("GitLab MR !123: Fix race in worktree setup");
});
it("renders review attachments with compact file, line, comment, and context details", () => {
expect(
renderPromptAttachmentAsText({
@@ -1,3 +1,4 @@
import { getForgeDefinitionOrNeutral } from "@getpaseo/protocol/forge-manifest";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { AgentPromptContentBlock, AgentPromptInput } from "./agent-sdk-types.js";
@@ -38,25 +39,47 @@ export function buildAgentPrompt(
export function renderPromptAttachmentAsText(attachment: AgentAttachment): string {
switch (attachment.type) {
case "forge_change_request": {
return renderChangeRequestAttachment({
forge: attachment.forge,
number: attachment.number,
title: attachment.title,
url: attachment.url,
body: attachment.body,
projectPath: attachment.projectPath,
baseRefName: attachment.baseRefName,
headRefName: attachment.headRefName,
});
}
case "github_pr": {
const lines = [`GitHub PR #${attachment.number}: ${attachment.title}`, attachment.url];
if (attachment.baseRefName) {
lines.push(`Base: ${attachment.baseRefName}`);
}
if (attachment.headRefName) {
lines.push(`Head: ${attachment.headRefName}`);
}
if (attachment.body) {
lines.push("", attachment.body);
}
return lines.join("\n");
return renderChangeRequestAttachment({
forge: "github",
number: attachment.number,
title: attachment.title,
url: attachment.url,
body: attachment.body,
baseRefName: attachment.baseRefName,
headRefName: attachment.headRefName,
});
}
case "forge_issue": {
return renderIssueAttachment({
forge: attachment.forge,
number: attachment.number,
title: attachment.title,
url: attachment.url,
body: attachment.body,
projectPath: attachment.projectPath,
});
}
case "github_issue": {
const lines = [`GitHub Issue #${attachment.number}: ${attachment.title}`, attachment.url];
if (attachment.body) {
lines.push("", attachment.body);
}
return lines.join("\n");
return renderIssueAttachment({
forge: "github",
number: attachment.number,
title: attachment.title,
url: attachment.url,
body: attachment.body,
});
}
case "text": {
return attachment.text;
@@ -101,6 +124,72 @@ export function renderPromptAttachmentAsText(attachment: AgentAttachment): strin
}
}
function renderChangeRequestAttachment(input: {
forge: string;
number: number;
title: string;
url: string;
body?: string | null;
projectPath?: string;
baseRefName?: string | null;
headRefName?: string | null;
}): string {
const lines = [
`${formatForgeLabel(input.forge)} ${formatChangeRequestAbbrev(input.forge)} ${formatChangeRequestNumber(input.forge, input.number)}: ${input.title}`,
input.url,
];
if (input.projectPath) {
lines.push(`Project: ${input.projectPath}`);
}
if (input.baseRefName) {
lines.push(`Base: ${input.baseRefName}`);
}
if (input.headRefName) {
lines.push(`Head: ${input.headRefName}`);
}
if (input.body) {
lines.push("", input.body);
}
return lines.join("\n");
}
function renderIssueAttachment(input: {
forge: string;
number: number;
title: string;
url: string;
body?: string | null;
projectPath?: string;
}): string {
const lines = [
`${formatForgeLabel(input.forge)} Issue ${formatIssueNumber(input.forge, input.number)}: ${input.title}`,
input.url,
];
if (input.projectPath) {
lines.push(`Project: ${input.projectPath}`);
}
if (input.body) {
lines.push("", input.body);
}
return lines.join("\n");
}
function formatForgeLabel(forge: string): string {
return getForgeDefinitionOrNeutral(forge).displayName;
}
function formatChangeRequestAbbrev(forge: string): string {
return getForgeDefinitionOrNeutral(forge).changeRequestAbbrev;
}
function formatChangeRequestNumber(forge: string, number: number): string {
return `${getForgeDefinitionOrNeutral(forge).changeRequestNumberPrefix}${number}`;
}
function formatIssueNumber(forge: string, number: number): string {
return `${getForgeDefinitionOrNeutral(forge).issueNumberPrefix}${number}`;
}
function padLineNumber(lineNumber: number | null): string {
return (lineNumber?.toString() ?? "-").padStart(2);
}
@@ -63,7 +63,7 @@ import {
setAgentModeCommand,
updateAgentCommand,
} from "../lifecycle-command.js";
import type { GitHubService } from "../../../services/github-service.js";
import type { ForgeService } from "../../../services/forge-service.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js";
import type { WorkspaceRegistry } from "../../workspace-registry.js";
import { WorktreeRequestError } from "../../worktree-errors.js";
@@ -91,7 +91,7 @@ export interface PaseoToolHostDependencies {
getDaemonTcpPort?: () => number | null;
scheduleService?: ScheduleService | null;
providerSnapshotManager: ProviderSnapshotManager;
github?: GitHubService;
github?: ForgeService;
workspaceGitService?: Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import type { Logger } from "pino";
import pino from "pino";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
archiveIfSafe,
@@ -14,7 +14,7 @@ import {
import type { ArchiveResult, ActiveWorkspaceRef } from "../workspace-archive-service.js";
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
import { createWorktree, type WorktreeConfig } from "../../utils/worktree.js";
import type { GitHubService } from "../../../services/github-service.js";
import type { ForgeService } from "../../../services/forge-service.js";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
const CWD = "/tmp/paseo/worktrees/repo/branch";
@@ -22,8 +22,8 @@ const PASEO_HOME = "/tmp/paseo";
const WORKTREES_ROOT = "/tmp/paseo/worktrees/repo";
function createPullRequest(
overrides?: Partial<NonNullable<WorkspaceGitRuntimeSnapshot["github"]["pullRequest"]>>,
): NonNullable<WorkspaceGitRuntimeSnapshot["github"]["pullRequest"]> {
overrides?: Partial<NonNullable<WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"]>>,
): NonNullable<WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"]> {
return {
url: "https://github.com/acme/repo/pull/123",
title: "Merge me",
@@ -37,7 +37,7 @@ function createPullRequest(
function createSnapshot(overrides?: {
git?: Partial<WorkspaceGitRuntimeSnapshot["git"]>;
pullRequest?: WorkspaceGitRuntimeSnapshot["github"]["pullRequest"];
pullRequest?: WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"];
}): WorkspaceGitRuntimeSnapshot {
return {
cwd: CWD,
@@ -154,7 +154,7 @@ async function runArchiveIfSafe(
harness: ReturnType<typeof createHarness>,
overrides?: {
cwd?: string;
pullRequest?: WorkspaceGitRuntimeSnapshot["github"]["pullRequest"];
pullRequest?: WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"];
},
): Promise<void> {
await archiveIfSafe({
@@ -211,11 +211,15 @@ async function createPaseoOwnedWorktree(
});
}
function createGitHubServiceStub(): GitHubService {
function createGitHubServiceStub(): ForgeService {
return {
listPullRequests: async () => [],
listIssues: async () => [],
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
searchIssuesAndPrs: async () => ({
items: [],
featuresEnabled: true,
githubFeaturesEnabled: true,
}),
getPullRequest: async ({ number }) => ({
number,
title: `PR ${number}`,
@@ -227,6 +231,15 @@ function createGitHubServiceStub(): GitHubService {
labels: [],
}),
getPullRequestHeadRef: async ({ number }) => `pr-${number}`,
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: `pr-${number}`,
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
}),
getCurrentPullRequestStatus: async () => null,
createPullRequest: async () => ({
number: 1,
@@ -13,7 +13,7 @@ import type {
WorkspaceGitRuntimeSnapshot,
WorkspaceGitServiceImpl,
} from "../workspace-git-service.js";
import type { GitHubService } from "../../services/github-service.js";
import type { ForgeService } from "../../services/forge-service.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
@@ -22,7 +22,7 @@ export interface AutoArchiveArchiveOptions {
paseoWorktreesBaseRoot?: string;
daemonConfigStore: DaemonConfigStore;
workspaceGitService: WorkspaceGitServiceImpl;
github: GitHubService;
github: ForgeService;
agentManager: AgentManager;
agentStorage: AgentStorage;
terminalManager: TerminalManager;
@@ -50,7 +50,7 @@ const defaultDependencies: ArchiveIfSafeDependencies = {
export async function archiveIfSafe(input: {
cwd: string;
pullRequest: WorkspaceGitRuntimeSnapshot["github"]["pullRequest"];
pullRequest: WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"];
inFlight: Set<string>;
options: AutoArchiveArchiveOptions;
log: Logger;
@@ -16,7 +16,7 @@ export function setupAutoArchiveOnMerge(
return options.workspaceGitService.onSnapshotUpdated((snapshot) => {
void archiveIfSafe({
cwd: snapshot.cwd,
pullRequest: snapshot.github.pullRequest,
pullRequest: snapshot.forge.pullRequest,
inFlight,
options,
log,
+1 -2
View File
@@ -741,7 +741,7 @@ export async function createPaseoDaemon(
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
deps: {
github,
forgeOverrides: { github },
},
});
const providerSnapshotLogger = logger.child({ module: "provider-snapshot-manager" });
@@ -899,7 +899,6 @@ export async function createPaseoDaemon(
readDaemonConfig: () => ({ metadataGeneration: daemonConfigStore.get().metadataGeneration }),
gitMutation: createGitMutationService({
workspaceGitService,
github,
logger,
}),
emitWorkspaceUpdateForCwd: emitWorkspaceUpdateForCwdExternal,
@@ -1,87 +1,108 @@
import { describe, expect, test } from "vitest";
import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
import { normalizeCheckoutPrStatusPayload } from "./status-projection.js";
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
import {
buildCheckoutPrStatusPayloadFromSnapshot,
normalizeCheckoutPrStatusPayload,
} from "./status-projection.js";
describe("checkout status projection", () => {
test("includes repository identity fields on the PR status wire payload", () => {
const payload = normalizeCheckoutPrStatusPayload({
number: 123,
repoOwner: "internal-owner",
repoName: "internal-repo",
url: "https://github.com/getpaseo/paseo/pull/123",
title: "Ship PR pane",
state: "open",
baseRefName: "main",
headRefName: "feature/pr-pane",
isMerged: false,
isDraft: true,
mergeable: "MERGEABLE",
checks: [
{
name: "typecheck",
status: "success",
url: "https://github.com/getpaseo/paseo/actions/runs/1",
workflow: "CI",
duration: "1m 20s",
},
],
checksStatus: "success",
reviewDecision: "approved",
});
const payload = normalizeCheckoutPrStatusPayload(
{
number: 123,
repoOwner: "internal-owner",
repoName: "internal-repo",
url: "https://github.com/getpaseo/paseo/pull/123",
title: "Ship PR pane",
state: "open",
baseRefName: "main",
headRefName: "feature/pr-pane",
isMerged: false,
isDraft: true,
mergeable: "MERGEABLE",
checks: [
{
name: "typecheck",
status: "success",
url: "https://github.com/getpaseo/paseo/actions/runs/1",
workflow: "CI",
duration: "1m 20s",
},
],
checksStatus: "success",
reviewDecision: "approved",
},
"github",
);
expect(payload).toHaveProperty("repoOwner", "internal-owner");
expect(payload).toHaveProperty("repoName", "internal-repo");
expect(payload).toHaveProperty("forge", "github");
expect(payload).toHaveProperty("projectPath", "internal-owner/internal-repo");
expect(payload).toHaveProperty("mergeable", "MERGEABLE");
expect(CheckoutPrStatusSchema.parse(payload)).toEqual(payload);
});
test("projects PR 993 GitHub merge facts without changing top-level status fields", () => {
const payload = normalizeCheckoutPrStatusPayload({
number: 993,
repoOwner: "getpaseo",
repoName: "paseo",
url: "https://github.com/getpaseo/paseo/pull/993",
title: "Auto-merge UX",
state: "open",
baseRefName: "main",
headRefName: "github-pr-auto-merge-ux",
isMerged: false,
isDraft: false,
mergeable: "MERGEABLE",
checks: [
{
name: "server tests",
status: "pending",
url: "https://github.com/getpaseo/paseo/actions/runs/993",
workflow: "CI",
const payload = normalizeCheckoutPrStatusPayload(
{
number: 993,
repoOwner: "getpaseo",
repoName: "paseo",
url: "https://github.com/getpaseo/paseo/pull/993",
title: "Auto-merge UX",
state: "open",
baseRefName: "main",
headRefName: "github-pr-auto-merge-ux",
isMerged: false,
isDraft: false,
mergeable: "MERGEABLE",
checks: [
{
name: "server tests",
status: "pending",
url: "https://github.com/getpaseo/paseo/actions/runs/993",
workflow: "CI",
},
],
checksStatus: "pending",
reviewDecision: "approved",
forgeSpecific: {
forge: "github",
mergeStateStatus: "BLOCKED",
autoMergeRequest: null,
viewerCanEnableAutoMerge: true,
viewerCanDisableAutoMerge: false,
viewerCanMergeAsAdmin: false,
viewerCanUpdateBranch: true,
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: false,
squashMergeAllowed: true,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: "SQUASH",
},
isMergeQueueEnabled: false,
isInMergeQueue: false,
},
],
checksStatus: "pending",
reviewDecision: "approved",
github: {
mergeStateStatus: "BLOCKED",
autoMergeRequest: null,
viewerCanEnableAutoMerge: true,
viewerCanDisableAutoMerge: false,
viewerCanMergeAsAdmin: false,
viewerCanUpdateBranch: true,
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: false,
squashMergeAllowed: true,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: "SQUASH",
},
isMergeQueueEnabled: false,
isInMergeQueue: false,
},
});
"github",
);
expect(payload).toMatchObject({
forge: "github",
projectPath: "getpaseo/paseo",
number: 993,
mergeable: "MERGEABLE",
checksStatus: "pending",
forgeSpecific: {
forge: "github",
mergeStateStatus: "BLOCKED",
viewerCanEnableAutoMerge: true,
},
// COMPAT(forgeSpecific): GitHub facts are still mirrored onto `github` for old clients.
github: {
mergeStateStatus: "BLOCKED",
viewerCanEnableAutoMerge: true,
@@ -94,4 +115,256 @@ describe("checkout status projection", () => {
});
expect(CheckoutPrStatusSchema.parse(payload)).toEqual(payload);
});
test("projects GitLab merge facts onto forgeSpecific without a github mirror", () => {
const payload = normalizeCheckoutPrStatusPayload(
{
number: 1,
repoOwner: "group",
repoName: "repo",
projectPath: "group/subgroup/repo",
url: "https://gitlab.com/group/subgroup/repo/-/merge_requests/1",
title: "Add sample change",
state: "open",
baseRefName: "main",
headRefName: "feat/sample-change",
isMerged: false,
mergeable: "MERGEABLE",
checksStatus: "success",
forgeSpecific: {
forge: "gitlab",
detailedMergeStatus: "mergeable",
hasConflicts: false,
blockingDiscussionsResolved: true,
approvalsRequired: 1,
approvalsGiven: 1,
pipelineStatus: "success",
pipelineId: 306,
pipelineUrl: "https://gitlab.com/group/subgroup/repo/-/pipelines/306",
mergeWhenPipelineSucceeds: false,
},
},
"gitlab",
);
expect(payload).toHaveProperty("projectPath", "group/subgroup/repo");
expect(payload).toMatchObject({
forgeSpecific: {
forge: "gitlab",
detailedMergeStatus: "mergeable",
pipelineStatus: "success",
},
});
expect(payload).not.toHaveProperty("github");
expect(CheckoutPrStatusSchema.parse(payload)).toEqual(payload);
});
test("projects Gitea-family facts while preserving the resolved Forgejo brand", () => {
const payload = normalizeCheckoutPrStatusPayload(
{
number: 5,
repoOwner: "example",
repoName: "repo",
url: "https://codeberg.org/example/repo/pulls/5",
title: "Add sample change",
state: "open",
baseRefName: "main",
headRefName: "feat/sample-change",
isMerged: false,
mergeable: "MERGEABLE",
checksStatus: "success",
forgeSpecific: {
forge: "gitea",
mergeable: true,
hasMerged: false,
ciStatus: "success",
},
},
"forgejo",
);
expect(payload).toMatchObject({
forge: "forgejo",
forgeSpecific: {
forge: "gitea",
mergeable: true,
hasMerged: false,
ciStatus: "success",
},
});
expect(CheckoutPrStatusSchema.parse(payload)).toEqual(payload);
});
test("labels the nested status.forge with the resolved forge", () => {
const payload = normalizeCheckoutPrStatusPayload(
{
number: 1,
url: "https://gitlab.com/group/proj/-/merge_requests/1",
title: "MR",
state: "open",
baseRefName: "main",
headRefName: "feat/x",
isMerged: false,
mergeable: "MERGEABLE",
},
"gitlab",
);
expect(payload).toHaveProperty("forge", "gitlab");
});
test("a gitlab-resolved snapshot emits status.forge and payload forge as gitlab", () => {
const snapshot = {
git: { remoteUrl: "git@gitlab.com:group/proj.git" },
forge: {
featuresEnabled: true,
authState: "authenticated",
forge: "gitlab",
error: null,
pullRequest: {
number: 7,
url: "https://gitlab.com/group/proj/-/merge_requests/7",
title: "MR 7",
state: "open",
baseRefName: "main",
headRefName: "feat/seven",
isMerged: false,
mergeable: "MERGEABLE",
forgeSpecific: {
forge: "gitlab",
detailedMergeStatus: "mergeable",
hasConflicts: false,
blockingDiscussionsResolved: true,
approvalsRequired: 0,
approvalsGiven: 0,
pipelineStatus: "success",
pipelineId: 307,
pipelineUrl: "https://gitlab.com/group/proj/-/pipelines/307",
mergeWhenPipelineSucceeds: false,
},
},
},
} as unknown as WorkspaceGitRuntimeSnapshot;
const payload = buildCheckoutPrStatusPayloadFromSnapshot({
cwd: "/repo",
requestId: "req-1",
snapshot,
});
expect(payload.forge).toBe("gitlab");
expect(payload.status?.forge).toBe("gitlab");
});
test("an unresolved self-hosted remote projects as neutral unauthenticated", () => {
const snapshot = {
git: { remoteUrl: "https://git.internal/acme/repo.git" },
forge: {
featuresEnabled: false,
authState: "unauthenticated",
forge: "git.internal",
error: null,
pullRequest: null,
},
} as unknown as WorkspaceGitRuntimeSnapshot;
const payload = buildCheckoutPrStatusPayloadFromSnapshot({
cwd: "/repo",
requestId: "req-neutral",
snapshot,
});
expect(payload).toMatchObject({
status: null,
githubFeaturesEnabled: false,
authState: "unauthenticated",
forge: "git.internal",
error: null,
requestId: "req-neutral",
});
});
test("a forgejo-resolved snapshot keeps Forgejo branding with Gitea-family facts", () => {
const snapshot = {
git: { remoteUrl: "git@codeberg.org:example/repo.git" },
forge: {
featuresEnabled: true,
authState: "authenticated",
forge: "forgejo",
error: null,
pullRequest: {
number: 5,
url: "https://codeberg.org/example/repo/pulls/5",
title: "PR 5",
state: "open",
baseRefName: "main",
headRefName: "feat/five",
isMerged: false,
mergeable: "MERGEABLE",
forgeSpecific: {
forge: "gitea",
mergeable: true,
hasMerged: false,
ciStatus: "success",
},
},
},
} as unknown as WorkspaceGitRuntimeSnapshot;
const payload = buildCheckoutPrStatusPayloadFromSnapshot({
cwd: "/repo",
requestId: "req-forgejo",
snapshot,
});
expect(payload.forge).toBe("forgejo");
expect(payload.status?.forge).toBe("forgejo");
expect(payload.status?.forgeSpecific).toEqual({
forge: "gitea",
mergeable: true,
hasMerged: false,
ciStatus: "success",
});
});
test("does not project a facts-family tag as the brand forge for unresolved snapshots", () => {
const snapshot = {
git: { remoteUrl: "git@codeberg.org:example/repo.git" },
forge: {
featuresEnabled: true,
authState: "authenticated",
error: null,
pullRequest: {
number: 5,
url: "https://codeberg.org/example/repo/pulls/5",
title: "PR 5",
state: "open",
baseRefName: "main",
headRefName: "feat/five",
isMerged: false,
mergeable: "MERGEABLE",
forgeSpecific: {
forge: "gitea",
mergeable: true,
hasMerged: false,
ciStatus: "success",
},
},
},
} as unknown as WorkspaceGitRuntimeSnapshot;
const payload = buildCheckoutPrStatusPayloadFromSnapshot({
cwd: "/repo",
requestId: "req-unresolved",
snapshot,
});
expect(payload.forge).toBeUndefined();
expect(payload.status?.forge).toBeUndefined();
expect(payload.status?.forgeSpecific).toEqual({
forge: "gitea",
mergeable: true,
hasMerged: false,
ciStatus: "success",
});
});
});
@@ -3,6 +3,7 @@ import type {
CheckoutStatusResponse,
SessionOutboundMessage,
} from "@getpaseo/protocol/messages";
import { isGitHubPullRequestStatusFacts } from "../../services/github-facts.js";
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
type CheckoutPrStatusPayload = Extract<
@@ -10,6 +11,9 @@ type CheckoutPrStatusPayload = Extract<
{ type: "checkout_pr_status_response" }
>["payload"];
type CheckoutPrStatusPayloadStatus = NonNullable<CheckoutPrStatusPayload["status"]>;
type CheckoutPrStatusWireStatus = Omit<CheckoutPrStatusPayloadStatus, "forge"> & {
forge?: string;
};
export function buildCheckoutStatusPayloadFromSnapshot({
cwd,
@@ -95,27 +99,35 @@ export function buildCheckoutPrStatusPayloadFromSnapshot({
requestId: string;
snapshot: WorkspaceGitRuntimeSnapshot;
}): CheckoutPrStatusResponse["payload"] {
// Prefer the forge resolved during snapshot refresh (probe-aware, so
// self-managed GitLab hosts are correct). forgeSpecific.forge is only a facts
// family tag, not a brand id, so unresolved snapshots stay unlabeled.
const forge = snapshot.forge.forge;
return {
cwd,
status: normalizeCheckoutPrStatusPayload(snapshot.github.pullRequest),
githubFeaturesEnabled: snapshot.github.featuresEnabled,
error: snapshot.github.error
status: normalizeCheckoutPrStatusPayload(snapshot.forge.pullRequest, forge),
githubFeaturesEnabled: snapshot.forge.featuresEnabled,
authState: snapshot.forge.authState,
...(forge ? { forge } : {}),
error: snapshot.forge.error
? {
code: "UNKNOWN",
message: snapshot.github.error.message,
message: snapshot.forge.error.message,
}
: null,
requestId,
};
} as CheckoutPrStatusResponse["payload"];
}
export function normalizeCheckoutPrStatusPayload(
status: WorkspaceGitRuntimeSnapshot["github"]["pullRequest"],
status: WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"],
forge?: string,
): CheckoutPrStatusPayloadStatus | null {
if (!status) {
return null;
}
const payload: CheckoutPrStatusPayloadStatus = {
const payload: CheckoutPrStatusWireStatus = {
...(forge ? { forge } : {}),
number: status.number,
url: status.url,
title: status.title,
@@ -131,8 +143,20 @@ export function normalizeCheckoutPrStatusPayload(
checksStatus: status.checksStatus,
reviewDecision: status.reviewDecision,
};
if (status.github) {
payload.github = status.github;
if (status.projectPath) {
payload.projectPath = status.projectPath;
} else if (status.repoOwner && status.repoName) {
payload.projectPath = `${status.repoOwner}/${status.repoName}`;
}
return payload;
if (status.forgeSpecific) {
payload.forgeSpecific = status.forgeSpecific;
// COMPAT(forgeSpecific): added in v0.1.106, remove after 2026-12-27. Keep
// mirroring GitHub facts onto `github` for clients that predate forgeSpecific;
// drop once the daemon floor >= v0.1.106.
if (isGitHubPullRequestStatusFacts(status.forgeSpecific)) {
const { forge: _forge, ...githubFacts } = status.forgeSpecific;
payload.github = githubFacts;
}
}
return payload as CheckoutPrStatusPayloadStatus;
}
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, expect, test, vi } from "vitest";
import type { GitHubService } from "../services/github-service.js";
import type { ForgeService } from "../services/forge-service.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
import type {
PersistedProjectRecord,
@@ -752,11 +752,15 @@ function createPersistedWorkspaceRecordForTest(input: {
};
}
function createGitHubServiceStub(): GitHubService {
function createGitHubServiceStub(): ForgeService {
return {
listPullRequests: async () => [],
listIssues: async () => [],
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
searchIssuesAndPrs: async () => ({
items: [],
featuresEnabled: true,
githubFeaturesEnabled: true,
}),
getPullRequest: async ({ number }) => ({
number,
title: `PR ${number}`,
@@ -768,6 +772,25 @@ function createGitHubServiceStub(): GitHubService {
labels: [],
}),
getPullRequestHeadRef: async ({ number }) => `pr-${number}`,
defaultCheckoutRefs: ({ changeRequestNumber }) => [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
],
buildPrLocalBranchName: ({ headRef, checkoutTarget }) => {
const normalized = checkoutTarget.headOwnerLogin?.trim().toLowerCase() ?? "";
const owner =
checkoutTarget.isCrossRepository && /^[a-z0-9-]+$/.test(normalized) ? normalized : null;
return owner ? `${owner}/${headRef}` : headRef;
},
supportsCrossRepoCheckoutWithoutRefs: true,
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: `pr-${number}`,
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
}),
getCurrentPullRequestStatus: async () => null,
createPullRequest: async () => ({
number: 1,
@@ -795,6 +818,7 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService {
mainRepoRoot: null,
}),
getSnapshot: async (cwd) => createWorkspaceGitSnapshot(cwd),
resolveForge: async () => null,
resolveRepoRoot: async (cwd) => {
try {
return createWorkspaceGitSnapshot(cwd).git.repoRoot ?? cwd;
@@ -810,6 +834,7 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService {
}),
scheduleRefreshForCwd: () => {},
onWorkspaceStateMayHaveChanged: () => {},
invalidateForge: () => {},
dispose: () => {},
};
}
@@ -853,7 +878,7 @@ function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
hasRemote: false,
diffStat: null,
},
github: {
forge: {
featuresEnabled: false,
pullRequest: null,
error: null,
@@ -205,6 +205,8 @@ function resolveIntentBaseBranch(intent: WorktreeCreationIntent): string | null
switch (intent.kind) {
case "branch-off":
return normalizeBaseRefName(intent.baseBranch);
case "checkout-change-request":
return normalizeBaseRefName(intent.baseRefName);
case "checkout-github-pr":
return normalizeBaseRefName(intent.baseRefName);
case "checkout-branch":
@@ -1,9 +1,11 @@
import { describe, expect, test } from "vitest";
import type { GitHubService } from "../services/github-service.js";
import type { ForgeService } from "../services/forge-service.js";
import {
CheckoutSourceForgeMismatchError,
MissingCheckoutTargetError,
resolveWorktreeCreationIntent,
UnsupportedForgeCheckoutTargetError,
} from "./resolve-worktree-creation-intent.js";
interface GitHubHeadRefLookup {
@@ -12,17 +14,43 @@ interface GitHubHeadRefLookup {
}
interface ResolverHarness {
github: GitHubService;
forge: string;
forgeService: ForgeService;
headRefLookups: GitHubHeadRefLookup[];
resolveDefaultBranch: (repoRoot: string) => Promise<string>;
}
function createResolverHarness(): ResolverHarness {
function createResolverHarness(overrides?: {
forge?: string;
forgeService?: Partial<ForgeService>;
}): ResolverHarness {
const forge = overrides?.forge ?? "github";
const headRefLookups: GitHubHeadRefLookup[] = [];
const github: GitHubService = {
const githubCapabilities: Partial<ForgeService> =
forge === "github"
? {
defaultCheckoutRefs: ({ changeRequestNumber }) => [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
],
buildPrLocalBranchName: ({ headRef, checkoutTarget }) => {
const normalized = checkoutTarget.headOwnerLogin?.trim().toLowerCase() ?? "";
const owner =
checkoutTarget.isCrossRepository && /^[a-z0-9-]+$/.test(normalized)
? normalized
: null;
return owner ? `${owner}/${headRef}` : headRef;
},
supportsCrossRepoCheckoutWithoutRefs: true,
}
: {};
const forgeService: ForgeService = {
listPullRequests: async () => [],
listIssues: async () => [],
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
searchIssuesAndPrs: async () => ({
items: [],
featuresEnabled: true,
githubFeaturesEnabled: true,
}),
getPullRequest: async ({ number }) => ({
number,
title: `PR ${number}`,
@@ -37,6 +65,15 @@ function createResolverHarness(): ResolverHarness {
headRefLookups.push({ cwd, number });
return `pr-${number}`;
},
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: `pr-${number}`,
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
}),
getCurrentPullRequestStatus: async () => null,
createPullRequest: async () => ({
number: 1,
@@ -45,10 +82,13 @@ function createResolverHarness(): ResolverHarness {
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true,
invalidate: () => {},
...githubCapabilities,
...overrides?.forgeService,
};
return {
github,
forge,
forgeService,
headRefLookups,
resolveDefaultBranch: async () => "main",
};
@@ -105,17 +145,20 @@ describe("resolveWorktreeCreationIntent", () => {
await expect(
resolveWorktreeCreationIntent({ action: "checkout", githubPrNumber: 42 }, repoRoot, deps),
).resolves.toEqual({
kind: "checkout-github-pr",
githubPrNumber: 42,
kind: "checkout-change-request",
forge: "github",
changeRequestNumber: 42,
headRef: "pr-42",
baseRefName: "main",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/42/head" }],
trackOriginHead: true,
});
expect(deps.headRefLookups).toEqual([{ cwd: repoRoot, number: 42 }]);
expect(deps.headRefLookups).toEqual([]);
});
test("does not configure a synthetic push remote for same-repo PR targets", async () => {
const deps = createResolverHarness();
deps.github.getPullRequestCheckoutTarget = async () => ({
deps.forgeService.getPullRequestCheckoutTarget = async () => ({
number: 1790,
baseRefName: "main",
headRefName: "daemon-shutdown-diagnostics",
@@ -128,10 +171,12 @@ describe("resolveWorktreeCreationIntent", () => {
await expect(
resolveWorktreeCreationIntent({ action: "checkout", githubPrNumber: 1790 }, repoRoot, deps),
).resolves.toEqual({
kind: "checkout-github-pr",
githubPrNumber: 1790,
kind: "checkout-change-request",
forge: "github",
changeRequestNumber: 1790,
headRef: "daemon-shutdown-diagnostics",
baseRefName: "main",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/1790/head" }],
trackOriginHead: true,
});
expect(deps.headRefLookups).toEqual([]);
@@ -139,7 +184,7 @@ describe("resolveWorktreeCreationIntent", () => {
test("configures the contributor remote for fork PR targets", async () => {
const deps = createResolverHarness();
deps.github.getPullRequestCheckoutTarget = async () => ({
deps.forgeService.getPullRequestCheckoutTarget = async () => ({
number: 526,
baseRefName: "main",
headRefName: "main",
@@ -152,10 +197,13 @@ describe("resolveWorktreeCreationIntent", () => {
await expect(
resolveWorktreeCreationIntent({ action: "checkout", githubPrNumber: 526 }, repoRoot, deps),
).resolves.toEqual({
kind: "checkout-github-pr",
githubPrNumber: 526,
kind: "checkout-change-request",
forge: "github",
changeRequestNumber: 526,
headRef: "main",
baseRefName: "main",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/526/head" }],
headRepositoryOwner: "therainisme",
localBranchName: "therainisme/main",
pushRemoteUrl: "git@github.com:therainisme/paseo.git",
});
@@ -172,14 +220,113 @@ describe("resolveWorktreeCreationIntent", () => {
deps,
),
).resolves.toEqual({
kind: "checkout-github-pr",
githubPrNumber: 42,
kind: "checkout-change-request",
forge: "github",
changeRequestNumber: 42,
headRef: "head-ref",
baseRefName: "main",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/42/head" }],
trackOriginHead: true,
});
expect(deps.headRefLookups).toEqual([]);
});
test("checks out a GitLab MR source branch from the checkout target", async () => {
const deps = createResolverHarness({
forge: "gitlab",
forgeService: {
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: "feature/mr-source",
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
}),
},
});
await expect(
resolveWorktreeCreationIntent({ action: "checkout", githubPrNumber: 7 }, repoRoot, deps),
).resolves.toEqual({
kind: "checkout-change-request",
forge: "gitlab",
changeRequestNumber: 7,
headRef: "feature/mr-source",
baseRefName: "main",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/heads/feature/mr-source" }],
trackOriginHead: true,
});
expect(deps.headRefLookups).toEqual([]);
});
test("checks out a cross-repository GitLab MR when the adapter provides a checkout ref", async () => {
const deps = createResolverHarness({
forge: "gitlab",
forgeService: {
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: "feature/mr-source",
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/merge-requests/7/head" }],
isCrossRepository: true,
}),
},
});
await expect(
resolveWorktreeCreationIntent({ action: "checkout", githubPrNumber: 7 }, repoRoot, deps),
).resolves.toEqual({
kind: "checkout-change-request",
forge: "gitlab",
changeRequestNumber: 7,
headRef: "feature/mr-source",
baseRefName: "main",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/merge-requests/7/head" }],
});
expect(deps.headRefLookups).toEqual([]);
});
test("reports unsupported cross-repository checkout targets without a checkout ref", async () => {
const deps = createResolverHarness({
forge: "gitea",
forgeService: {
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: "feature/fork",
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: true,
}),
},
});
await expect(
resolveWorktreeCreationIntent({ action: "checkout", githubPrNumber: 7 }, repoRoot, deps),
).rejects.toThrow(UnsupportedForgeCheckoutTargetError);
});
test("rejects change request checkout when the source forge differs from the workspace forge", async () => {
const deps = createResolverHarness({ forge: "github" });
await expect(
resolveWorktreeCreationIntent(
{
action: "checkout",
checkoutSource: { kind: "change_request", forge: "gitlab", number: 7 },
},
repoRoot,
deps,
),
).rejects.toThrow(CheckoutSourceForgeMismatchError);
});
test("rejects checkout without a target", async () => {
const deps = createResolverHarness();
@@ -1,33 +1,28 @@
import type { GitHubPullRequestCheckoutTarget, GitHubService } from "../services/github-service.js";
import type { ForgeService, PullRequestCheckoutTarget } from "../services/forge-service.js";
import type { WorktreeSource } from "../utils/worktree.js";
export type WorktreeCreationIntent = WorktreeSource;
export type ResolveWorktreeCreationIntentInput =
| {
worktreeSlug: string;
branchName?: string;
refName?: string;
action?: "branch-off";
githubPrNumber?: undefined;
}
| {
worktreeSlug?: string;
branchName?: string;
refName?: string;
action: "checkout";
githubPrNumber?: number;
}
| {
worktreeSlug?: string;
branchName?: string;
refName?: string;
action?: undefined;
githubPrNumber: number;
};
export interface ResolveWorktreeCreationIntentInput {
worktreeSlug?: string;
branchName?: string;
refName?: string;
action?: "branch-off" | "checkout";
checkoutSource?: {
kind: "change_request";
forge?: string;
number: number;
projectPath?: string;
};
/**
* COMPAT(githubPrNumber): added in v0.1.106, remove after 2026-12-28.
*/
githubPrNumber?: number;
}
export interface ResolveWorktreeCreationIntentDeps {
github: GitHubService;
forge: string;
forgeService: ForgeService;
resolveDefaultBranch: (repoRoot: string) => Promise<string>;
}
@@ -35,11 +30,35 @@ export class MissingCheckoutTargetError extends Error {
readonly action = "checkout";
constructor() {
super('action "checkout" requires refName or githubPrNumber');
super('action "checkout" requires refName or checkoutSource');
this.name = "MissingCheckoutTargetError";
}
}
export class UnsupportedForgeCheckoutTargetError extends Error {
readonly forge: string;
constructor(forge: string) {
super(`Checkout from change request is not supported for ${forge} yet`);
this.name = "UnsupportedForgeCheckoutTargetError";
this.forge = forge;
}
}
export class CheckoutSourceForgeMismatchError extends Error {
readonly checkoutSourceForge: string;
readonly workspaceForge: string;
constructor(params: { checkoutSourceForge: string; workspaceForge: string }) {
super(
`Checkout source is for ${params.checkoutSourceForge}, but this workspace resolved to ${params.workspaceForge}`,
);
this.name = "CheckoutSourceForgeMismatchError";
this.checkoutSourceForge = params.checkoutSourceForge;
this.workspaceForge = params.workspaceForge;
}
}
export async function resolveWorktreeCreationIntent(
input: ResolveWorktreeCreationIntentInput,
repoRoot: string,
@@ -49,15 +68,17 @@ export async function resolveWorktreeCreationIntent(
return {
kind: "branch-off",
baseBranch: input.refName?.trim() || (await resolveDefaultBranch(repoRoot, deps)),
branchName: input.branchName ?? input.worktreeSlug,
branchName: input.branchName ?? input.worktreeSlug ?? "worktree",
};
}
if (input.action === "checkout") {
if (input.githubPrNumber !== undefined) {
return resolveGitHubPrCheckoutIntent({
const changeRequest = resolveInputChangeRequest(input);
if (changeRequest) {
assertCheckoutSourceMatchesResolvedForge(changeRequest, deps);
return resolvePrCheckoutIntent({
refName: input.refName,
githubPrNumber: input.githubPrNumber,
changeRequestNumber: changeRequest.number,
repoRoot,
deps,
});
@@ -74,10 +95,12 @@ export async function resolveWorktreeCreationIntent(
throw new MissingCheckoutTargetError();
}
if (input.githubPrNumber !== undefined) {
return resolveGitHubPrCheckoutIntent({
const changeRequest = resolveInputChangeRequest(input);
if (changeRequest) {
assertCheckoutSourceMatchesResolvedForge(changeRequest, deps);
return resolvePrCheckoutIntent({
refName: input.refName,
githubPrNumber: input.githubPrNumber,
changeRequestNumber: changeRequest.number,
repoRoot,
deps,
});
@@ -87,62 +110,115 @@ export async function resolveWorktreeCreationIntent(
return {
kind: "branch-off",
baseBranch: input.refName.trim(),
branchName: input.branchName ?? input.worktreeSlug,
branchName: input.branchName ?? input.worktreeSlug ?? "worktree",
};
}
return {
kind: "branch-off",
baseBranch: await resolveDefaultBranch(repoRoot, deps),
branchName: input.branchName ?? input.worktreeSlug,
branchName: input.branchName ?? input.worktreeSlug ?? "worktree",
};
}
async function resolveGitHubPrCheckoutIntent(params: {
interface PrCheckoutIntentParams {
refName?: string;
githubPrNumber: number;
changeRequestNumber: number;
repoRoot: string;
deps: ResolveWorktreeCreationIntentDeps;
}): Promise<Extract<WorktreeCreationIntent, { kind: "checkout-github-pr" }>> {
const checkoutTarget = await resolveGitHubPrCheckoutTarget(params);
const headRef = await resolveGitHubPrHeadRef({
}
function resolveInputChangeRequest(
input: ResolveWorktreeCreationIntentInput,
): { number: number; forge?: string; projectPath?: string } | null {
if (input.checkoutSource) {
return {
number: input.checkoutSource.number,
...(input.checkoutSource.forge ? { forge: input.checkoutSource.forge } : {}),
...(input.checkoutSource.projectPath
? { projectPath: input.checkoutSource.projectPath }
: {}),
};
}
if (input.githubPrNumber !== undefined) {
return { number: input.githubPrNumber };
}
return null;
}
function assertCheckoutSourceMatchesResolvedForge(
source: { forge?: string },
deps: ResolveWorktreeCreationIntentDeps,
): void {
if (source.forge && source.forge !== deps.forge) {
throw new CheckoutSourceForgeMismatchError({
checkoutSourceForge: source.forge,
workspaceForge: deps.forge,
});
}
}
async function resolvePrCheckoutIntent(
params: PrCheckoutIntentParams,
): Promise<Extract<WorktreeCreationIntent, { kind: "checkout-change-request" }>> {
const { deps } = params;
const service = deps.forgeService;
const checkoutTarget = await resolvePrCheckoutTarget(params);
const headRef = await resolvePrHeadRef({
refName: params.refName,
githubPrNumber: params.githubPrNumber,
changeRequestNumber: params.changeRequestNumber,
checkoutTarget,
repoRoot: params.repoRoot,
deps: params.deps,
deps,
});
const canCrossRepo =
hasCheckoutRefs(checkoutTarget) || service.supportsCrossRepoCheckoutWithoutRefs === true;
if (checkoutTarget.isCrossRepository && !canCrossRepo) {
throw new UnsupportedForgeCheckoutTargetError(deps.forge);
}
const baseRefName =
checkoutTarget?.baseRefName?.trim() ||
(await resolveDefaultBranch(params.repoRoot, params.deps));
const localBranchName = buildGitHubPrLocalBranchName({ headRef, checkoutTarget });
const pushRemoteUrl = checkoutTarget?.isCrossRepository
checkoutTarget.baseRefName.trim() || (await resolveDefaultBranch(params.repoRoot, deps));
const defaultRefs = service.defaultCheckoutRefs?.({
changeRequestNumber: params.changeRequestNumber,
headRef,
}) ?? [{ remoteName: "origin", remoteRef: `refs/heads/${headRef}` }];
const localBranchName = service.buildPrLocalBranchName?.({ headRef, checkoutTarget });
const headRepositoryOwner = checkoutTarget.isCrossRepository
? checkoutTarget.headOwnerLogin?.trim() || undefined
: undefined;
const pushRemoteUrl = checkoutTarget.isCrossRepository
? checkoutTarget.headRepositorySshUrl || checkoutTarget.headRepositoryUrl || undefined
: undefined;
const trackOriginHead = checkoutTarget ? !checkoutTarget.isCrossRepository : false;
const trackOriginHead = !checkoutTarget.isCrossRepository;
return {
kind: "checkout-github-pr",
githubPrNumber: params.githubPrNumber,
kind: "checkout-change-request",
forge: deps.forge,
changeRequestNumber: params.changeRequestNumber,
headRef,
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
baseRefName,
...(localBranchName !== headRef ? { localBranchName } : {}),
checkoutRefs: checkoutTarget.checkoutRefs ?? defaultRefs,
...(localBranchName && localBranchName !== headRef ? { localBranchName } : {}),
...(pushRemoteUrl ? { pushRemoteUrl } : {}),
...(trackOriginHead ? { trackOriginHead } : {}),
};
}
async function resolveGitHubPrCheckoutTarget(params: {
githubPrNumber: number;
function hasCheckoutRefs(target: PullRequestCheckoutTarget): boolean {
return Array.isArray(target.checkoutRefs) && target.checkoutRefs.length > 0;
}
async function resolvePrCheckoutTarget(params: {
changeRequestNumber: number;
repoRoot: string;
deps: ResolveWorktreeCreationIntentDeps;
}): Promise<GitHubPullRequestCheckoutTarget | null> {
if (!params.deps.github.getPullRequestCheckoutTarget) {
return null;
}
return params.deps.github.getPullRequestCheckoutTarget({
}): Promise<PullRequestCheckoutTarget> {
return params.deps.forgeService.getPullRequestCheckoutTarget({
cwd: params.repoRoot,
number: params.githubPrNumber,
number: params.changeRequestNumber,
});
}
@@ -157,10 +233,10 @@ async function resolveDefaultBranch(
return baseBranch;
}
async function resolveGitHubPrHeadRef(params: {
async function resolvePrHeadRef(params: {
refName?: string;
githubPrNumber: number;
checkoutTarget?: GitHubPullRequestCheckoutTarget | null;
changeRequestNumber: number;
checkoutTarget: PullRequestCheckoutTarget;
repoRoot: string;
deps: ResolveWorktreeCreationIntentDeps;
}): Promise<string> {
@@ -168,27 +244,12 @@ async function resolveGitHubPrHeadRef(params: {
if (trimmedRefName) {
return trimmedRefName;
}
const checkoutTargetHeadRef = params.checkoutTarget?.headRefName.trim();
const checkoutTargetHeadRef = params.checkoutTarget.headRefName.trim();
if (checkoutTargetHeadRef) {
return checkoutTargetHeadRef;
}
return params.deps.github.getPullRequestHeadRef({
return params.deps.forgeService.getPullRequestHeadRef({
cwd: params.repoRoot,
number: params.githubPrNumber,
number: params.changeRequestNumber,
});
}
function buildGitHubPrLocalBranchName(params: {
headRef: string;
checkoutTarget: GitHubPullRequestCheckoutTarget | null;
}): string {
const owner = params.checkoutTarget?.isCrossRepository
? normalizeGitHubOwnerForBranch(params.checkoutTarget.headOwnerLogin)
: null;
return owner ? `${owner}/${params.headRef}` : params.headRef;
}
function normalizeGitHubOwnerForBranch(owner: string | null): string | null {
const normalized = owner?.trim().toLowerCase() ?? "";
return /^[a-z0-9-]+$/.test(normalized) ? normalized : null;
}
+170 -82
View File
@@ -5,6 +5,10 @@ import { join, resolve as resolvePath } from "path";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
assertPullRequestAutoMergeDisableReady,
assertPullRequestAutoMergeEnableReady,
} from "../services/github-service.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import {
@@ -36,16 +40,14 @@ import {
createProviderSnapshotManagerStub,
} from "./test-utils/session-stubs.js";
import { isPlatform } from "../test-utils/platform.js";
import type {
GitHubCheckDetails,
GitHubPullRequestStatusFacts,
GitHubService,
} from "../services/github-service.js";
import {
GitHubAuthenticationError,
GitHubCliMissingError,
GitHubCommandError,
type GitHubService,
} from "../services/github-service.js";
import type { CheckDetails, ForgeService } from "../services/forge-service.js";
import type { GitHubPullRequestStatusFacts } from "../services/github-facts.js";
interface SessionHandlerInternals {
interruptAgentIfRunning(agentId: string): Promise<void>;
@@ -63,7 +65,7 @@ interface SessionHandlerInternals {
handleCheckoutCommitRequest(params: unknown): Promise<unknown>;
handleCheckoutPrCreateRequest(params: unknown): Promise<unknown>;
handleCheckoutPrMergeRequest(params: unknown): Promise<unknown>;
handleCheckoutGithubSetAutoMergeRequest(params: unknown): Promise<unknown>;
handleCheckoutForgeSetAutoMergeRequest(params: unknown): Promise<unknown>;
handleCheckoutPullRequest(params: unknown): Promise<unknown>;
handleCheckoutPushRequest(params: unknown): Promise<unknown>;
handleCheckoutRefreshRequest(params: unknown): Promise<unknown>;
@@ -275,7 +277,7 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => {
interface SessionForTestOptions {
agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown };
agentStorage?: { [K in keyof SessionOptions["agentStorage"]]?: unknown };
github?: Partial<GitHubService>;
github?: Partial<ForgeService & GitHubService>;
checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType<typeof vi.fn> };
workspaceGitService?: {
getCheckout?: ReturnType<typeof vi.fn>;
@@ -288,6 +290,7 @@ interface SessionForTestOptions {
hasLocalBranch?: ReturnType<typeof vi.fn>;
resolveRepoRemoteUrl?: ReturnType<typeof vi.fn>;
resolveRepoRoot?: ReturnType<typeof vi.fn>;
resolveForge?: ReturnType<typeof vi.fn>;
getWorkspaceGitMetadata?: ReturnType<typeof vi.fn>;
};
workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
@@ -320,7 +323,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
const checkoutDiffManager = options.checkoutDiffManager ?? {
scheduleRefreshForCwd: vi.fn(),
};
const workspaceGitService = options.workspaceGitService ?? {
const workspaceGitService = {
getCheckout: vi.fn(),
getCheckoutDiff: vi.fn(),
getSnapshot: vi.fn(),
@@ -332,6 +335,11 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
resolveRepoRemoteUrl: vi.fn(),
resolveRepoRoot: vi.fn(),
getWorkspaceGitMetadata: vi.fn(),
resolveForge: vi.fn().mockResolvedValue({ forge: "github", service: github }),
// Mirror production: invalidateForge resolves the forge and busts the
// adapter's cache. The resolved forge here is github, so delegate to it.
invalidateForge: vi.fn((cwd: string) => github.invalidate({ cwd })),
...options.workspaceGitService,
};
const messages = options.messages ?? [];
@@ -1412,7 +1420,7 @@ function createWorkspaceGitSnapshot(
cwd: string,
overrides?: {
git?: Record<string, unknown>;
github?: Record<string, unknown>;
forge?: Record<string, unknown>;
},
) {
return {
@@ -1433,11 +1441,11 @@ function createWorkspaceGitSnapshot(
diffStat: { additions: 3, deletions: 1 },
...overrides?.git,
},
github: {
forge: {
featuresEnabled: false,
pullRequest: null,
error: null,
...overrides?.github,
...overrides?.forge,
},
};
}
@@ -2436,10 +2444,11 @@ describe("session checkout pull request merge", () => {
};
const workspaceGitService = {
getSnapshot: vi.fn().mockResolvedValue({
github: {
forge: {
pullRequest: {
number: 42,
github: {
forgeSpecific: {
forge: "github",
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
@@ -2475,7 +2484,8 @@ describe("session checkout pull request merge", () => {
mergeMethod: "squash",
status: {
number: 42,
github: {
forgeSpecific: {
forge: "github",
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
@@ -2496,7 +2506,7 @@ describe("session checkout pull request merge", () => {
});
expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, "/tmp/request-worktree", {
force: true,
includeGitHub: true,
includeForge: true,
reason: "merge-pr-validation",
});
expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, "/tmp/request-worktree", {
@@ -2520,8 +2530,8 @@ describe("session checkout pull request merge", () => {
const github = {
invalidate: vi.fn(),
mergePullRequest: vi.fn(
async (input: { status?: { github?: { mergeStateStatus?: string | null } } }) => {
if (input.status?.github?.mergeStateStatus === "BLOCKED") {
async (input: { status?: { forgeSpecific?: { mergeStateStatus?: string | null } } }) => {
if (input.status?.forgeSpecific?.mergeStateStatus === "BLOCKED") {
throw new Error("GitHub does not report this pull request as ready for direct merge");
}
return { success: true };
@@ -2529,10 +2539,11 @@ describe("session checkout pull request merge", () => {
),
};
const createSnapshot = (mergeStateStatus: "CLEAN" | "BLOCKED") => ({
github: {
forge: {
pullRequest: {
number: 42,
github: {
forgeSpecific: {
forge: "github",
mergeStateStatus,
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
@@ -2569,13 +2580,13 @@ describe("session checkout pull request merge", () => {
expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(1);
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
force: true,
includeGitHub: true,
includeForge: true,
reason: "merge-pr-validation",
});
expect(github.mergePullRequest).toHaveBeenCalledWith(
expect.objectContaining({
status: expect.objectContaining({
github: expect.objectContaining({ mergeStateStatus: "BLOCKED" }),
forgeSpecific: expect.objectContaining({ mergeStateStatus: "BLOCKED" }),
}),
}),
);
@@ -2594,7 +2605,7 @@ describe("session checkout pull request merge", () => {
});
});
test("rejects direct merge when the current pull request is missing GitHub merge facts", async () => {
test("delegates direct merge when the current change request lacks GitHub-only merge facts", async () => {
const messages: unknown[] = [];
const github = {
invalidate: vi.fn(),
@@ -2602,7 +2613,7 @@ describe("session checkout pull request merge", () => {
};
const workspaceGitService = {
getSnapshot: vi.fn().mockResolvedValue({
github: {
forge: {
pullRequest: {
number: 42,
mergeable: "MERGEABLE",
@@ -2619,22 +2630,31 @@ describe("session checkout pull request merge", () => {
requestId: "request-pr-merge-missing-github-facts",
});
expect(github.mergePullRequest).not.toHaveBeenCalled();
expect(github.invalidate).not.toHaveBeenCalled();
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
expect(github.mergePullRequest).toHaveBeenCalledWith({
cwd: "/tmp/request-worktree",
prNumber: 42,
mergeMethod: "squash",
status: {
number: 42,
mergeable: "MERGEABLE",
},
});
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" });
expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, "/tmp/request-worktree", {
force: true,
includeGitHub: true,
includeForge: true,
reason: "merge-pr-validation",
});
expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, "/tmp/request-worktree", {
force: true,
reason: "merge-pr",
});
expect(messages).toContainEqual({
type: "checkout_pr_merge_response",
payload: {
cwd: "/tmp/request-worktree",
success: false,
error: {
code: "UNKNOWN",
message: "GitHub merge facts are unavailable for this pull request",
},
success: true,
error: null,
requestId: "request-pr-merge-missing-github-facts",
},
});
@@ -2648,10 +2668,11 @@ describe("session checkout pull request merge", () => {
};
const workspaceGitService = {
getSnapshot: vi.fn().mockResolvedValue({
github: {
forge: {
pullRequest: {
number: 42,
github: {
forgeSpecific: {
forge: "github",
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
@@ -2699,7 +2720,8 @@ describe("session checkout pull request merge", () => {
describe("session checkout pull request auto-merge", () => {
const autoMergeGithubFacts = (
overrides: Partial<GitHubPullRequestStatusFacts> = {},
): GitHubPullRequestStatusFacts => ({
): GitHubPullRequestStatusFacts & { forge: "github" } => ({
forge: "github",
mergeStateStatus: "BLOCKED",
autoMergeRequest: null,
viewerCanEnableAutoMerge: true,
@@ -2722,15 +2744,18 @@ describe("session checkout pull request auto-merge", () => {
const messages: unknown[] = [];
const github = {
invalidate: vi.fn(),
enablePullRequestAutoMerge: vi.fn().mockResolvedValue({ success: true }),
enablePullRequestAutoMerge: vi.fn(async (input) => {
assertPullRequestAutoMergeEnableReady(input);
return { success: true };
}),
};
const workspaceGitService = {
getSnapshot: vi.fn().mockResolvedValue({
github: {
forge: {
pullRequest: {
number: 42,
mergeable: "MERGEABLE",
github: autoMergeGithubFacts(),
forgeSpecific: autoMergeGithubFacts(),
},
},
}),
@@ -2738,7 +2763,7 @@ describe("session checkout pull request auto-merge", () => {
const session = createSessionForTest({ github, workspaceGitService, messages });
await session.handleMessage({
type: "checkout.github.set_auto_merge.request",
type: "checkout.forge.set_auto_merge.request",
cwd: "/tmp/request-worktree",
enabled: true,
mergeMethod: "squash",
@@ -2752,12 +2777,12 @@ describe("session checkout pull request auto-merge", () => {
status: {
number: 42,
mergeable: "MERGEABLE",
github: autoMergeGithubFacts(),
forgeSpecific: autoMergeGithubFacts(),
},
});
expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, "/tmp/request-worktree", {
force: true,
includeGitHub: true,
includeForge: true,
reason: "auto-merge-validation",
});
expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, "/tmp/request-worktree", {
@@ -2766,7 +2791,7 @@ describe("session checkout pull request auto-merge", () => {
});
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" });
expect(messages).toContainEqual({
type: "checkout.github.set_auto_merge.response",
type: "checkout.forge.set_auto_merge.response",
payload: {
cwd: "/tmp/request-worktree",
enabled: true,
@@ -2781,14 +2806,17 @@ describe("session checkout pull request auto-merge", () => {
const messages: unknown[] = [];
const github = {
invalidate: vi.fn(),
disablePullRequestAutoMerge: vi.fn().mockResolvedValue({ success: true }),
disablePullRequestAutoMerge: vi.fn(async (input) => {
assertPullRequestAutoMergeDisableReady(input);
return { success: true };
}),
};
const workspaceGitService = {
getSnapshot: vi.fn().mockResolvedValue({
github: {
forge: {
pullRequest: {
number: 42,
github: autoMergeGithubFacts({
forgeSpecific: autoMergeGithubFacts({
autoMergeRequest: {
enabledAt: "2026-05-13T17:00:00Z",
mergeMethod: "SQUASH",
@@ -2804,7 +2832,7 @@ describe("session checkout pull request auto-merge", () => {
const session = createSessionForTest({ github, workspaceGitService, messages });
await session.handleMessage({
type: "checkout.github.set_auto_merge.request",
type: "checkout.forge.set_auto_merge.request",
cwd: "/tmp/request-worktree",
enabled: false,
requestId: "request-pr-auto-merge-disable",
@@ -2815,7 +2843,7 @@ describe("session checkout pull request auto-merge", () => {
prNumber: 42,
status: {
number: 42,
github: autoMergeGithubFacts({
forgeSpecific: autoMergeGithubFacts({
autoMergeRequest: {
enabledAt: "2026-05-13T17:00:00Z",
mergeMethod: "SQUASH",
@@ -2828,7 +2856,7 @@ describe("session checkout pull request auto-merge", () => {
});
expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, "/tmp/request-worktree", {
force: true,
includeGitHub: true,
includeForge: true,
reason: "auto-merge-validation",
});
expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, "/tmp/request-worktree", {
@@ -2837,7 +2865,7 @@ describe("session checkout pull request auto-merge", () => {
});
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" });
expect(messages).toContainEqual({
type: "checkout.github.set_auto_merge.response",
type: "checkout.forge.set_auto_merge.response",
payload: {
cwd: "/tmp/request-worktree",
enabled: false,
@@ -2856,10 +2884,10 @@ describe("session checkout pull request auto-merge", () => {
};
const workspaceGitService = {
getSnapshot: vi.fn().mockResolvedValue({
github: {
forge: {
pullRequest: {
number: 42,
github: autoMergeGithubFacts(),
forgeSpecific: autoMergeGithubFacts(),
},
},
}),
@@ -2867,7 +2895,7 @@ describe("session checkout pull request auto-merge", () => {
const session = createSessionForTest({ github, workspaceGitService, messages });
await session.handleMessage({
type: "checkout.github.set_auto_merge.request",
type: "checkout.forge.set_auto_merge.request",
cwd: "/tmp/request-worktree",
enabled: true,
mergeMethod: "merge",
@@ -2875,7 +2903,7 @@ describe("session checkout pull request auto-merge", () => {
});
expect(messages).toContainEqual({
type: "checkout.github.set_auto_merge.response",
type: "checkout.forge.set_auto_merge.response",
payload: {
cwd: "/tmp/request-worktree",
enabled: true,
@@ -2893,14 +2921,17 @@ describe("session checkout pull request auto-merge", () => {
const messages: unknown[] = [];
const github = {
invalidate: vi.fn(),
enablePullRequestAutoMerge: vi.fn().mockResolvedValue({ success: true }),
enablePullRequestAutoMerge: vi.fn(async (input) => {
assertPullRequestAutoMergeEnableReady(input);
return { success: true };
}),
};
const workspaceGitService = {
getSnapshot: vi.fn().mockResolvedValue({
github: {
forge: {
pullRequest: {
number: 42,
github: autoMergeGithubFacts({
forgeSpecific: autoMergeGithubFacts({
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
@@ -2916,22 +2947,24 @@ describe("session checkout pull request auto-merge", () => {
const session = createSessionForTest({ github, workspaceGitService, messages });
await session.handleMessage({
type: "checkout.github.set_auto_merge.request",
type: "checkout.forge.set_auto_merge.request",
cwd: "/tmp/request-worktree",
enabled: true,
mergeMethod: "squash",
requestId: "request-pr-auto-merge-method-disabled",
});
expect(github.enablePullRequestAutoMerge).not.toHaveBeenCalled();
// The adapter owns the readiness precondition and rejects before any side
// effect, so it is invoked but the mutation never completes (no invalidate).
expect(github.enablePullRequestAutoMerge).toHaveBeenCalled();
expect(github.invalidate).not.toHaveBeenCalled();
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
force: true,
includeGitHub: true,
includeForge: true,
reason: "auto-merge-validation",
});
expect(messages).toContainEqual({
type: "checkout.github.set_auto_merge.response",
type: "checkout.forge.set_auto_merge.response",
payload: {
cwd: "/tmp/request-worktree",
enabled: true,
@@ -2949,14 +2982,17 @@ describe("session checkout pull request auto-merge", () => {
const messages: unknown[] = [];
const github = {
invalidate: vi.fn(),
disablePullRequestAutoMerge: vi.fn().mockResolvedValue({ success: true }),
disablePullRequestAutoMerge: vi.fn(async (input) => {
assertPullRequestAutoMergeDisableReady(input);
return { success: true };
}),
};
const workspaceGitService = {
getSnapshot: vi.fn().mockResolvedValue({
github: {
forge: {
pullRequest: {
number: 42,
github: autoMergeGithubFacts({
forgeSpecific: autoMergeGithubFacts({
autoMergeRequest: {
enabledAt: "2026-05-13T17:00:00Z",
mergeMethod: "SQUASH",
@@ -2972,21 +3008,23 @@ describe("session checkout pull request auto-merge", () => {
const session = createSessionForTest({ github, workspaceGitService, messages });
await session.handleMessage({
type: "checkout.github.set_auto_merge.request",
type: "checkout.forge.set_auto_merge.request",
cwd: "/tmp/request-worktree",
enabled: false,
requestId: "request-pr-auto-merge-disable-forbidden",
});
expect(github.disablePullRequestAutoMerge).not.toHaveBeenCalled();
// The adapter owns the readiness precondition and rejects before any side
// effect, so it is invoked but the mutation never completes (no invalidate).
expect(github.disablePullRequestAutoMerge).toHaveBeenCalled();
expect(github.invalidate).not.toHaveBeenCalled();
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
force: true,
includeGitHub: true,
includeForge: true,
reason: "auto-merge-validation",
});
expect(messages).toContainEqual({
type: "checkout.github.set_auto_merge.response",
type: "checkout.forge.set_auto_merge.response",
payload: {
cwd: "/tmp/request-worktree",
enabled: false,
@@ -3004,14 +3042,17 @@ describe("session checkout pull request auto-merge", () => {
const messages: unknown[] = [];
const github = {
invalidate: vi.fn(),
disablePullRequestAutoMerge: vi.fn().mockResolvedValue({ success: true }),
disablePullRequestAutoMerge: vi.fn(async (input) => {
assertPullRequestAutoMergeDisableReady(input);
return { success: true };
}),
};
const workspaceGitService = {
getSnapshot: vi.fn().mockResolvedValue({
github: {
forge: {
pullRequest: {
number: 42,
github: autoMergeGithubFacts({
forgeSpecific: autoMergeGithubFacts({
autoMergeRequest: {
enabledAt: "2026-05-13T17:00:00Z",
mergeMethod: "SQUASH",
@@ -3027,7 +3068,7 @@ describe("session checkout pull request auto-merge", () => {
const session = createSessionForTest({ github, workspaceGitService, messages });
await session.handleMessage({
type: "checkout.github.set_auto_merge.request",
type: "checkout.forge.set_auto_merge.request",
cwd: "/tmp/request-worktree",
enabled: false,
mergeMethod: "squash",
@@ -3037,7 +3078,7 @@ describe("session checkout pull request auto-merge", () => {
expect(github.disablePullRequestAutoMerge).not.toHaveBeenCalled();
expect(github.invalidate).not.toHaveBeenCalled();
expect(messages).toContainEqual({
type: "checkout.github.set_auto_merge.response",
type: "checkout.forge.set_auto_merge.response",
payload: {
cwd: "/tmp/request-worktree",
enabled: false,
@@ -3136,7 +3177,7 @@ describe("session checkout refresh handling", () => {
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" });
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
force: true,
includeGitHub: true,
includeForge: true,
reason: "manual-refresh",
});
expect(checkoutDiffManager.scheduleRefreshForCwd).toHaveBeenCalledWith("/tmp/request-worktree");
@@ -4061,7 +4102,7 @@ describe("session workspace script handling", () => {
});
describe("session pull request timeline handling", () => {
test("routes GitHub search requests through GitHubService", async () => {
test("routes GitHub search requests through ForgeService", async () => {
const messages: unknown[] = [];
const github = {
invalidate: vi.fn(),
@@ -4069,7 +4110,8 @@ describe("session pull request timeline handling", () => {
githubFeaturesEnabled: true,
items: [
{
kind: "pr",
kind: "change_request",
forge: "github",
number: 42,
title: "Ship search",
url: "https://github.com/getpaseo/paseo/pull/42",
@@ -4083,7 +4125,17 @@ describe("session pull request timeline handling", () => {
],
}),
};
const session = createSessionForTest({ github, messages });
const gitlab = {
searchIssuesAndPrs: vi.fn().mockResolvedValue({
featuresEnabled: true,
authState: "authenticated",
items: [],
}),
};
const workspaceGitService = {
resolveForge: vi.fn().mockResolvedValue({ forge: "gitlab", service: gitlab }),
};
const session = createSessionForTest({ github, workspaceGitService, messages });
await session.handleMessage({
type: "github_search_request",
@@ -4100,12 +4152,14 @@ describe("session pull request timeline handling", () => {
limit: 5,
kinds: ["github-pr"],
});
expect(gitlab.searchIssuesAndPrs).not.toHaveBeenCalled();
expect(messages).toContainEqual({
type: "github_search_response",
payload: {
items: [
{
kind: "pr",
forge: "github",
number: 42,
title: "Ship search",
url: "https://github.com/getpaseo/paseo/pull/42",
@@ -4117,6 +4171,8 @@ describe("session pull request timeline handling", () => {
updatedAt: "2026-04-18T13:00:00Z",
},
],
featuresEnabled: true,
authState: "authenticated",
githubFeaturesEnabled: true,
error: null,
requestId: "request-search",
@@ -4124,7 +4180,39 @@ describe("session pull request timeline handling", () => {
});
});
test("passes request identity to GitHubService and emits timeline items", async () => {
test("reports no remote when forge search has no resolved forge", async () => {
const messages: unknown[] = [];
const github = {
invalidate: vi.fn(),
searchIssuesAndPrs: vi.fn(),
};
const workspaceGitService = {
resolveForge: vi.fn().mockResolvedValue(null),
};
const session = createSessionForTest({ github, workspaceGitService, messages });
await session.handleMessage({
type: "forge.search.request",
cwd: "/tmp/repo",
query: "search",
limit: 5,
kinds: ["change_request"],
requestId: "request-search",
});
expect(github.searchIssuesAndPrs).not.toHaveBeenCalled();
expect(messages).toContainEqual({
type: "forge.search.response",
payload: {
items: [],
authState: "no_remote",
error: null,
requestId: "request-search",
},
});
});
test("passes request identity to ForgeService and emits timeline items", async () => {
const messages: unknown[] = [];
const github = {
invalidate: vi.fn(),
@@ -4282,7 +4370,7 @@ describe("session pull request timeline handling", () => {
checkRunId: number;
workflowRunId?: number;
}> = [];
const checkDetails: GitHubCheckDetails = {
const checkDetails: CheckDetails = {
checkRunId: 12345,
workflowRunId: 456,
name: "server-tests",
@@ -4295,12 +4383,12 @@ describe("session pull request timeline handling", () => {
failedJobs: [],
truncated: false,
};
const github: Partial<GitHubService> = {
const github: Partial<ForgeService> = {
invalidate() {},
async isAuthenticated() {
return true;
},
async getGitHubCheckDetails(request) {
async getCheckDetails(request) {
checkDetailRequests.push({
cwd: request.cwd,
repoOwner: request.repoOwner,
@@ -4314,7 +4402,7 @@ describe("session pull request timeline handling", () => {
const session = createSessionForTest({ github, messages });
await session.handleMessage({
type: "checkout.github.get_check_details.request",
type: "checkout.forge.get_check_details.request",
cwd: "/tmp/repo",
repoOwner: "getpaseo",
repoName: "paseo",
@@ -4334,7 +4422,7 @@ describe("session pull request timeline handling", () => {
]);
expect(messages).toContainEqual({
type: "checkout.github.get_check_details.response",
type: "checkout.forge.get_check_details.response",
payload: {
cwd: "/tmp/repo",
success: true,
+24 -13
View File
@@ -195,6 +195,7 @@ import {
GitHubCommandError,
type GitHubService,
} from "../services/github-service.js";
import type { ForgeService } from "../services/forge-service.js";
import type { ProviderUsageService } from "../services/quota-fetcher/service.js";
import {
summarizeFetchWorkspacesEntries,
@@ -446,7 +447,7 @@ export interface SessionOptions {
scheduleService: ScheduleService;
loopService: LoopService;
checkoutDiffManager: CheckoutDiffManager;
github?: GitHubService;
github?: ForgeService;
createAgentMcpTransport?: AgentMcpTransportFactory;
// Injected so tests can substitute the git branch rename without module mocks;
// defaults to the real checkout-git implementation.
@@ -572,7 +573,7 @@ export class Session {
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly filesystem: SessionFileSystem;
private readonly github: GitHubService;
private readonly github: ForgeService;
private readonly renameCurrentBranch: typeof renameCurrentBranchDefault;
private readonly workspaceGitService: WorkspaceGitService;
private readonly workspaceAutoName: WorkspaceAutoName;
@@ -706,7 +707,6 @@ export class Session {
this.workspaceGitService = workspaceGitService;
this.gitMutation = createGitMutationService({
workspaceGitService: this.workspaceGitService,
github: this.github,
logger: this.sessionLogger,
});
this.workspaceAutoName = workspaceAutoName;
@@ -1656,16 +1656,19 @@ export class Session {
return this.checkoutSession.handleCheckoutPrCreateRequest(msg);
case "checkout_pr_merge_request":
return this.checkoutSession.handleCheckoutPrMergeRequest(msg);
case "checkout.forge.set_auto_merge.request":
case "checkout.github.set_auto_merge.request":
return this.checkoutSession.handleCheckoutGithubSetAutoMergeRequest(msg);
return this.checkoutSession.handleCheckoutForgeSetAutoMergeRequest(msg);
case "checkout.forge.get_check_details.request":
case "checkout.github.get_check_details.request":
return this.checkoutSession.handleCheckoutGithubGetCheckDetailsRequest(msg);
return this.checkoutSession.handleCheckoutForgeGetCheckDetailsRequest(msg);
case "checkout_pr_status_request":
return this.checkoutSession.handleCheckoutPrStatusRequest(msg);
case "pull_request_timeline_request":
return this.checkoutSession.handlePullRequestTimelineRequest(msg);
case "forge.search.request":
case "github_search_request":
return this.checkoutSession.handleGitHubSearchRequest(msg);
return this.checkoutSession.handleForgeSearchRequest(msg);
case "stash_save_request":
return this.checkoutSession.handleStashSaveRequest(msg);
case "stash_pop_request":
@@ -3049,7 +3052,6 @@ export class Session {
checkoutExistingBranch: (cwd, branch) =>
this.gitMutation.checkoutExistingBranch(cwd, branch),
createBranchFromBase: (params) => this.gitMutation.createBranchFromBase(params),
github: this.github,
},
config,
gitOptions,
@@ -3778,9 +3780,9 @@ export class Session {
snapshot: WorkspaceGitRuntimeSnapshot,
): NonNullable<WorkspaceDescriptorPayload["githubRuntime"]> {
return {
featuresEnabled: snapshot.github.featuresEnabled,
pullRequest: snapshot.github.pullRequest,
error: snapshot.github.error,
featuresEnabled: snapshot.forge.featuresEnabled,
pullRequest: snapshot.forge.pullRequest,
error: snapshot.forge.error,
};
}
@@ -3803,6 +3805,10 @@ export class Session {
diffStat: snapshot.git.diffStat ?? null,
gitRuntime: this.buildWorkspaceGitRuntimePayload(snapshot) ?? undefined,
githubRuntime: this.buildWorkspaceGitHubRuntimePayload(snapshot),
// Reuse the forge already resolved on the snapshot (probe-aware; GitHub-only
// resolves to "github") so the sidebar/hover-card brand mark matches the
// status projection without a second resolve.
forge: snapshot.forge.forge,
};
}
@@ -4657,7 +4663,7 @@ export class Session {
});
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
void this.workspaceGitService
.getSnapshot(workspace.cwd, { force: true, includeGitHub: true, reason: "open_project" })
.getSnapshot(workspace.cwd, { force: true, includeForge: true, reason: "open_project" })
.catch((error) => {
this.sessionLogger.warn(
{ err: error, cwd: workspace.cwd },
@@ -4712,6 +4718,7 @@ export class Session {
worktreeSlug: source.worktreeSlug,
action: source.action,
refName: source.refName,
checkoutSource: source.checkoutSource,
githubPrNumber: source.githubPrNumber,
firstAgentContext: request.firstAgentContext,
},
@@ -4825,7 +4832,7 @@ export class Session {
void this.workspaceGitService
.getSnapshot(workspace.cwd, {
force: true,
includeGitHub: true,
includeForge: true,
reason: "open_project",
})
.catch((error) => {
@@ -4969,7 +4976,11 @@ export class Session {
>,
): Promise<void> {
try {
const repositories = await this.github.searchRepositories({
const searchRepositories = (this.github as Partial<GitHubService>).searchRepositories;
if (!searchRepositories) {
throw new Error("GitHub repository search is unavailable");
}
const repositories = await searchRepositories.call(this.github, {
cwd: homedir(),
query: request.query,
limit: request.limit,
@@ -63,7 +63,7 @@ function createWorkspaceRuntimeSnapshot(
cwd: string,
overrides?: {
git?: Partial<WorkspaceGitRuntimeSnapshot["git"]>;
github?: Partial<WorkspaceGitRuntimeSnapshot["github"]>;
forge?: Partial<WorkspaceGitRuntimeSnapshot["forge"]>;
},
): WorkspaceGitRuntimeSnapshot {
const base: WorkspaceGitRuntimeSnapshot = {
@@ -83,7 +83,8 @@ function createWorkspaceRuntimeSnapshot(
hasRemote: true,
diffStat: { additions: 1, deletions: 0 },
},
github: {
forge: {
forge: "github",
featuresEnabled: true,
pullRequest: null,
error: null,
@@ -96,17 +97,17 @@ function createWorkspaceRuntimeSnapshot(
...base.git,
...overrides?.git,
},
github: {
...base.github,
...overrides?.github,
forge: {
...base.forge,
...overrides?.forge,
pullRequest:
overrides?.github && "pullRequest" in overrides.github
? (overrides.github.pullRequest ?? null)
: base.github.pullRequest,
overrides?.forge && "pullRequest" in overrides.forge
? (overrides.forge.pullRequest ?? null)
: base.forge.pullRequest,
error:
overrides?.github && "error" in overrides.github
? (overrides.github.error ?? null)
: base.github.error,
overrides?.forge && "error" in overrides.forge
? (overrides.forge.error ?? null)
: base.forge.error,
},
};
}
@@ -236,6 +237,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
}),
scheduleRefreshForCwd: () => {},
onWorkspaceStateMayHaveChanged: () => {},
invalidateForge: () => {},
getMetrics: () => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
@@ -594,7 +596,7 @@ describe("workspace git watch targets", () => {
subscriptions[0]?.listener(
createWorkspaceRuntimeSnapshot(REPO_CWD, {
github: {
forge: {
featuresEnabled: true,
pullRequest: {
number: 456,
@@ -613,7 +615,8 @@ describe("workspace git watch targets", () => {
],
checksStatus: "success",
reviewDecision: "approved",
github: {
forgeSpecific: {
forge: "github",
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: true,
@@ -642,6 +645,7 @@ describe("workspace git watch targets", () => {
expect(statusUpdate?.payload.prStatus).toEqual({
cwd: REPO_CWD,
status: {
forge: "github",
number: 456,
url: "https://github.com/acme/repo/pull/456",
title: "Runtime centralization",
@@ -662,6 +666,24 @@ describe("workspace git watch targets", () => {
],
checksStatus: "success",
reviewDecision: "approved",
forgeSpecific: {
forge: "github",
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: true,
viewerCanDisableAutoMerge: false,
viewerCanMergeAsAdmin: false,
viewerCanUpdateBranch: true,
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
squashMergeAllowed: true,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: "SQUASH",
},
isMergeQueueEnabled: false,
isInMergeQueue: false,
},
github: {
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
@@ -680,6 +702,8 @@ describe("workspace git watch targets", () => {
isInMergeQueue: false,
},
},
forge: "github",
authState: undefined,
githubFeaturesEnabled: true,
error: null,
requestId: REPO_SUBSCRIPTION_REQUEST_ID,
@@ -693,7 +717,7 @@ describe("workspace git watch targets", () => {
workspaceGitService.getSnapshot.mockResolvedValue(
createWorkspaceRuntimeSnapshot(REPO_CWD, {
github: {
forge: {
featuresEnabled: true,
pullRequest: {
url: "https://github.com/acme/repo/pull/456",
@@ -719,6 +743,7 @@ describe("workspace git watch targets", () => {
).toEqual({
cwd: REPO_CWD,
status: {
forge: "github",
number: undefined,
url: "https://github.com/acme/repo/pull/456",
title: "Runtime centralization",
@@ -734,6 +759,8 @@ describe("workspace git watch targets", () => {
checksStatus: undefined,
reviewDecision: undefined,
},
forge: "github",
authState: undefined,
githubFeaturesEnabled: true,
error: null,
requestId: "req-pr-status",
@@ -150,6 +150,7 @@ function createHarness(input: {
}),
scheduleRefreshForCwd: () => {},
onWorkspaceStateMayHaveChanged: () => {},
invalidateForge: () => {},
getMetrics: () => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
@@ -42,7 +42,7 @@ import { WorktreeRequestError } from "./worktree-errors.js";
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js";
import { WorkspaceAutoName } from "./workspace-auto-name.js";
import type { GitHubService } from "../services/github-service.js";
import type { ForgeService } from "../services/forge-service.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
import {
asSessionLogger,
@@ -370,7 +370,7 @@ function createWorkspaceRuntimeSnapshot(
cwd: string,
overrides?: {
git?: Partial<WorkspaceGitRuntimeSnapshot["git"]>;
github?: Partial<WorkspaceGitRuntimeSnapshot["github"]>;
forge?: Partial<WorkspaceGitRuntimeSnapshot["forge"]>;
},
): WorkspaceGitRuntimeSnapshot {
const base: WorkspaceGitRuntimeSnapshot = {
@@ -390,7 +390,7 @@ function createWorkspaceRuntimeSnapshot(
hasRemote: true,
diffStat: { additions: 1, deletions: 0 },
},
github: {
forge: {
featuresEnabled: true,
pullRequest: {
url: "https://github.com/acme/repo/pull/123",
@@ -410,17 +410,17 @@ function createWorkspaceRuntimeSnapshot(
...base.git,
...overrides?.git,
},
github: {
...base.github,
...overrides?.github,
forge: {
...base.forge,
...overrides?.forge,
pullRequest:
overrides?.github && "pullRequest" in overrides.github
? (overrides.github.pullRequest ?? null)
: base.github.pullRequest,
overrides?.forge && "pullRequest" in overrides.forge
? (overrides.forge.pullRequest ?? null)
: base.forge.pullRequest,
error:
overrides?.github && "error" in overrides.github
? (overrides.github.error ?? null)
: base.github.error,
overrides?.forge && "error" in overrides.forge
? (overrides.forge.error ?? null)
: base.forge.error,
},
};
}
@@ -532,7 +532,7 @@ function createSessionForWorkspaceTests(
terminalManager?: TerminalManager | null;
projectRegistry?: SessionOptions["projectRegistry"];
workspaceRegistry?: SessionOptions["workspaceRegistry"];
github?: GitHubService;
github?: ForgeService;
paseoHome?: string;
worktreesRoot?: string;
renameCurrentBranch?: (
@@ -668,6 +668,7 @@ function createSessionForWorkspaceTests(
}),
scheduleRefreshForCwd: () => {},
onWorkspaceStateMayHaveChanged: () => {},
invalidateForge: () => {},
getMetrics: () => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
@@ -822,6 +823,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
}),
scheduleRefreshForCwd: () => {},
onWorkspaceStateMayHaveChanged: () => {},
invalidateForge: () => {},
getMetrics: () => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
@@ -945,6 +947,7 @@ test("create_agent_request does not title an existing workspace from the agent p
}),
scheduleRefreshForCwd: () => {},
onWorkspaceStateMayHaveChanged: () => {},
invalidateForge: () => {},
getMetrics: () => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
@@ -1327,6 +1330,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
}),
scheduleRefreshForCwd: () => {},
onWorkspaceStateMayHaveChanged: () => {},
invalidateForge: () => {},
getMetrics: () => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
@@ -1687,6 +1691,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy
}),
scheduleRefreshForCwd: () => {},
onWorkspaceStateMayHaveChanged: () => {},
invalidateForge: () => {},
getMetrics: () => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
@@ -1872,6 +1877,7 @@ test("close_items_request archives stored agents that are not currently loaded",
}),
scheduleRefreshForCwd: () => {},
onWorkspaceStateMayHaveChanged: () => {},
invalidateForge: () => {},
getMetrics: () => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
@@ -2019,6 +2025,7 @@ test("close_items_request continues after an archive failure", async () => {
}),
scheduleRefreshForCwd: () => {},
onWorkspaceStateMayHaveChanged: () => {},
invalidateForge: () => {},
getMetrics: () => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
@@ -3013,6 +3020,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
}),
scheduleRefreshForCwd: () => {},
onWorkspaceStateMayHaveChanged: () => {},
invalidateForge: () => {},
getMetrics: () => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
@@ -5338,7 +5346,7 @@ test("archive_workspace_request archives a worktree-kind workspace and removes t
hasRemote: false,
diffStat: null,
},
github: {
forge: {
featuresEnabled: false,
pullRequest: null,
error: null,
@@ -6190,7 +6198,7 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git
aheadOfOrigin: 3,
behindOfOrigin: 1,
},
github: {
forge: {
pullRequest: {
url: "https://github.com/acme/repo/pull/456",
title: "Ship runtime payloads",
@@ -6357,7 +6365,7 @@ test("workspace_update includes updated runtime fields", async () => {
currentBranch: "feature/runtime-payloads",
isDirty: true,
},
github: {
forge: {
pullRequest: {
url: "https://github.com/acme/repo/pull/789",
title: "Updated runtime payloads",
@@ -7350,7 +7358,7 @@ function createWorkspaceCreatePrRepo(): WorkspaceCreatePrRepoFixture {
return { tempDir, repoDir, paseoHome, headRef, prFileName, prNumber };
}
function createPrCheckoutGitHubService(params: { headRef: string }): GitHubService {
function createPrCheckoutGitHubService(params: { headRef: string }): ForgeService {
return {
listPullRequests: async () => [],
listIssues: async () => [],
@@ -7375,6 +7383,10 @@ function createPrCheckoutGitHubService(params: { headRef: string }): GitHubServi
headRepositoryUrl: null,
isCrossRepository: false,
}),
defaultCheckoutRefs: ({ changeRequestNumber }) => [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
],
buildPrLocalBranchName: ({ headRef }) => headRef,
getCurrentPullRequestStatus: async () => null,
getPullRequestTimeline: async ({ prNumber }) => ({
prNumber,
@@ -7384,7 +7396,7 @@ function createPrCheckoutGitHubService(params: { headRef: string }): GitHubServi
truncated: false,
error: null,
}),
getGitHubCheckDetails: async ({ checkRunId, workflowRunId }) => ({
getCheckDetails: async ({ checkRunId, workflowRunId }) => ({
checkRunId,
workflowRunId: workflowRunId ?? null,
name: "test",
@@ -7397,7 +7409,11 @@ function createPrCheckoutGitHubService(params: { headRef: string }): GitHubServi
failedJobs: [],
truncated: false,
}),
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
searchIssuesAndPrs: async () => ({
items: [],
featuresEnabled: true,
githubFeaturesEnabled: true,
}),
createPullRequest: async () => ({ number: 1, url: "https://github.com/acme/repo/pull/1" }),
mergePullRequest: async () => ({ success: true }),
enablePullRequestAutoMerge: async () => ({ success: true }),
@@ -6,7 +6,9 @@ import {
type CheckoutSessionHost,
} from "./checkout-session.js";
import type { GitMutationService } from "../git-mutation/git-mutation-service.js";
import { createGitHubService, type GitHubService } from "../../../services/github-service.js";
import { createGitHubService } from "../../../services/github-service.js";
import { ForgeCliMissingError } from "../../../services/forge-cli-command.js";
import type { ForgeService } from "../../../services/forge-service.js";
import type { SessionOutboundMessage } from "../../messages.js";
import type {
CheckoutDiffCompareInput,
@@ -23,6 +25,14 @@ import {
import { expandTilde } from "../../../utils/path.js";
import type { GitMetadataGenerator } from "./git-metadata-generator.js";
function isCheckDetailsResponse(msg: SessionOutboundMessage): boolean {
return msg.type === "checkout.forge.get_check_details.response";
}
function isTimelineResponse(msg: SessionOutboundMessage): boolean {
return msg.type === "pull_request_timeline_response";
}
interface FakeDiffSubscription {
cwd: string;
compare: CheckoutDiffCompareInput;
@@ -68,7 +78,7 @@ interface RecordedGitMutationCalls {
notifyGitMutation: Array<{
cwd: string;
reason: string;
options?: { invalidateGithub?: boolean };
options?: { invalidateForge?: boolean };
}>;
checkoutExistingBranch: Array<{ cwd: string; branch: string }>;
}
@@ -81,7 +91,7 @@ interface RecordedGeneratorCalls {
function makeCheckoutSession(options?: {
git?: Partial<WorkspaceGitService>;
diff?: CheckoutDiffSubscriber;
github?: Partial<GitHubService>;
github?: Partial<ForgeService>;
host?: Partial<CheckoutSessionHost>;
gitMutation?: Partial<GitMutationFake>;
gitMetadataGenerator?: Partial<GitMetadataGenerator>;
@@ -135,7 +145,7 @@ function makeCheckoutSession(options?: {
},
...options?.gitMetadataGenerator,
};
const github: GitHubService = { ...createGitHubService(), ...options?.github };
const github: ForgeService = { ...createGitHubService(), ...options?.github };
const checkout = new CheckoutSession({
host,
gitMutation,
@@ -173,7 +183,7 @@ function createGitSnapshot(
hasRemote: false,
diffStat: null,
},
github: { featuresEnabled: false, pullRequest: null, error: null },
forge: { featuresEnabled: false, pullRequest: null, error: null },
};
}
@@ -378,7 +388,7 @@ describe("CheckoutSession", () => {
});
expect(snapshotCalls).toEqual([
{ cwd: "/repo", options: { force: true, includeGitHub: true, reason: "manual-refresh" } },
{ cwd: "/repo", options: { force: true, includeForge: true, reason: "manual-refresh" } },
]);
expect(refreshedCwds).toEqual(["/repo"]);
expect(emitted).toEqual([
@@ -647,7 +657,7 @@ describe("CheckoutSession", () => {
expect(hostCalls.renameCurrentBranch).toEqual([{ cwd: "/repo", branch: "feature-renamed" }]);
expect(gitMutationCalls.notifyGitMutation).toEqual([
{ cwd: "/repo", reason: "rename-branch", options: { invalidateGithub: true } },
{ cwd: "/repo", reason: "rename-branch", options: { invalidateForge: true } },
]);
expect(refreshedCwds).toEqual(["/repo"]);
expect(hostCalls.handleWorkspaceGitBranchSnapshot).toEqual([
@@ -770,7 +780,7 @@ describe("CheckoutSession", () => {
success: false,
error: {
code: "UNKNOWN",
message: "Unable to determine GitHub pull request number for merge",
message: "Unable to determine current change request number for merge",
},
requestId: "pm1",
},
@@ -779,6 +789,493 @@ describe("CheckoutSession", () => {
});
});
describe("auto-merge routing", () => {
function createGitLabPrSnapshot(
cwd: string,
mergeWhenPipelineSucceeds: boolean,
): WorkspaceGitRuntimeSnapshot {
return {
...createGitSnapshot(cwd, "feature/gitlab-auto-merge"),
forge: {
featuresEnabled: true,
error: null,
pullRequest: {
number: 14,
url: "https://gitlab.example.com/g/r/-/merge_requests/14",
title: "GitLab MR",
state: "open",
baseRefName: "main",
headRefName: "feature/gitlab-auto-merge",
isMerged: false,
isDraft: false,
mergeable: "UNKNOWN",
checks: [],
checksStatus: "pending",
reviewDecision: null,
forgeSpecific: {
forge: "gitlab",
detailedMergeStatus: "ci_still_running",
hasConflicts: false,
blockingDiscussionsResolved: true,
approvalsRequired: 0,
approvalsGiven: 0,
pipelineStatus: "running",
pipelineId: 306,
pipelineUrl: "https://gitlab.example.com/g/r/-/pipelines/306",
mergeWhenPipelineSucceeds,
},
},
},
};
}
it("routes GitLab set-auto-merge enable and disable through the resolved adapter", async () => {
const githubCalls: string[] = [];
const gitlabCalls: Array<{ operation: "enable" | "disable"; prNumber: number }> = [];
const gitlabService: Partial<ForgeService> = {
async enablePullRequestAutoMerge(input) {
gitlabCalls.push({ operation: "enable", prNumber: input.prNumber });
return { success: true };
},
async disablePullRequestAutoMerge(input) {
gitlabCalls.push({ operation: "disable", prNumber: input.prNumber });
return { success: true };
},
};
const { checkout, emitted } = makeCheckoutSession({
github: {
async enablePullRequestAutoMerge() {
githubCalls.push("enable");
throw new Error("github adapter should not be reached for a gitlab cwd");
},
async disablePullRequestAutoMerge() {
githubCalls.push("disable");
throw new Error("github adapter should not be reached for a gitlab cwd");
},
},
git: {
getSnapshot: async (cwd) => createGitLabPrSnapshot(cwd, false),
resolveForge: async () => ({
forge: "gitlab",
host: "gitlab.example.com",
service: gitlabService as ForgeService,
}),
},
});
await checkout.handleCheckoutForgeSetAutoMergeRequest({
type: "checkout.forge.set_auto_merge.request",
cwd: "/repo",
enabled: true,
mergeMethod: "squash",
requestId: "am-enable",
});
await checkout.handleCheckoutForgeSetAutoMergeRequest({
type: "checkout.forge.set_auto_merge.request",
cwd: "/repo",
enabled: false,
requestId: "am-disable",
});
expect(githubCalls).toEqual([]);
expect(gitlabCalls).toEqual([
{ operation: "enable", prNumber: 14 },
{ operation: "disable", prNumber: 14 },
]);
expect(emitted).toEqual([
{
type: "checkout.forge.set_auto_merge.response",
payload: {
cwd: "/repo",
enabled: true,
success: true,
error: null,
requestId: "am-enable",
},
},
{
type: "checkout.forge.set_auto_merge.response",
payload: {
cwd: "/repo",
enabled: false,
success: true,
error: null,
requestId: "am-disable",
},
},
]);
});
});
describe("check details routing", () => {
it("routes get-check-details through the resolved GitLab adapter", async () => {
const githubCalls: number[] = [];
const gitlabCalls: Array<{ cwd: string; checkRunId: number }> = [];
const gitlabService: Partial<ForgeService> = {
async getCheckDetails(input) {
gitlabCalls.push({ cwd: input.cwd, checkRunId: input.checkRunId });
return {
checkRunId: input.checkRunId,
name: "Pipeline (feat/x)",
annotations: [],
failedJobs: [],
truncated: false,
pipeline: {
id: input.checkRunId,
status: "success",
rawStatus: "success",
url: "https://gitlab.example.com/g/r/-/pipelines/306",
ref: "feat/x",
sha: "abc",
stages: [
{
name: "test",
status: "success",
jobs: [
{
id: 1,
name: "unit",
stage: "test",
status: "success",
rawStatus: "success",
url: null,
allowFailure: false,
durationSeconds: 4,
},
],
},
],
},
};
},
};
const { checkout, emitted } = makeCheckoutSession({
github: {
async getCheckDetails(input) {
githubCalls.push(input.checkRunId);
throw new Error("github adapter should not be reached for a gitlab cwd");
},
},
git: {
resolveForge: async () => ({
forge: "gitlab",
host: "gitlab.example.com",
service: gitlabService as ForgeService,
}),
},
});
await checkout.handleCheckoutForgeGetCheckDetailsRequest({
type: "checkout.forge.get_check_details.request",
cwd: "/repo",
checkRunId: 306,
requestId: "cd1",
});
expect(githubCalls).toEqual([]);
expect(gitlabCalls).toEqual([{ cwd: "/repo", checkRunId: 306 }]);
const response = emitted.find(isCheckDetailsResponse);
expect(response).toMatchObject({
payload: {
success: true,
details: { pipeline: { id: 306, stages: [{ name: "test" }] } },
},
});
});
});
describe("timeline routing", () => {
it("routes the timeline request through the resolved GitLab adapter", async () => {
const gitlabCalls: Array<{ cwd: string; prNumber: number }> = [];
const gitlabService: Partial<ForgeService> = {
async isAuthenticated() {
return true;
},
async getPullRequestTimeline(input) {
gitlabCalls.push({ cwd: input.cwd, prNumber: input.prNumber });
return {
prNumber: input.prNumber,
repoOwner: input.repoOwner,
repoName: input.repoName,
items: [
{
kind: "comment",
id: "401",
author: "reviewer-a",
authorUrl: "https://gl/reviewer-a",
avatarUrl: null,
body: "Looks good",
createdAt: 1710000000000,
url: "https://gl/g/r/-/merge_requests/14#note_401",
},
],
truncated: false,
error: null,
};
},
};
const { checkout, emitted } = makeCheckoutSession({
github: {
async isAuthenticated() {
throw new Error("github adapter should not be reached for a gitlab cwd");
},
async getPullRequestTimeline() {
throw new Error("github adapter should not be reached for a gitlab cwd");
},
},
git: {
resolveForge: async () => ({
forge: "gitlab",
host: "gitlab.example.com",
service: gitlabService as ForgeService,
}),
},
});
await checkout.handlePullRequestTimelineRequest({
type: "pull_request_timeline_request",
cwd: "/repo",
prNumber: 14,
repoOwner: "g",
repoName: "r",
requestId: "tl1",
});
expect(gitlabCalls).toEqual([{ cwd: "/repo", prNumber: 14 }]);
const response = emitted.find(isTimelineResponse);
expect(response).toMatchObject({
payload: {
prNumber: 14,
items: [{ id: "401", kind: "comment", author: "reviewer-a" }],
githubFeaturesEnabled: true,
error: null,
},
});
});
it("routes the timeline request through the resolved Gitea adapter", async () => {
const giteaCalls: Array<{ cwd: string; prNumber: number }> = [];
const giteaService: Partial<ForgeService> = {
async isAuthenticated() {
return true;
},
async getPullRequestTimeline(input) {
giteaCalls.push({ cwd: input.cwd, prNumber: input.prNumber });
return {
prNumber: input.prNumber,
repoOwner: input.repoOwner,
repoName: input.repoName,
items: [
{
kind: "review",
id: "2001",
author: "reviewer-a",
authorUrl: "https://gitea.com/reviewer-a",
avatarUrl: null,
body: "Approved",
createdAt: 1710000000000,
url: "https://gitea.com/g/r/pulls/12#issuecomment-2001",
reviewState: "approved",
},
],
truncated: false,
error: null,
};
},
};
const { checkout, emitted } = makeCheckoutSession({
github: {
async isAuthenticated() {
throw new Error("github adapter should not be reached for a gitea cwd");
},
async getPullRequestTimeline() {
throw new Error("github adapter should not be reached for a gitea cwd");
},
},
git: {
resolveForge: async () => ({
forge: "gitea",
host: "gitea.com",
service: giteaService as ForgeService,
}),
},
});
await checkout.handlePullRequestTimelineRequest({
type: "pull_request_timeline_request",
cwd: "/repo",
prNumber: 12,
repoOwner: "g",
repoName: "r",
requestId: "tl-gitea",
});
expect(giteaCalls).toEqual([{ cwd: "/repo", prNumber: 12 }]);
const response = emitted.find(isTimelineResponse);
expect(response).toMatchObject({
payload: {
prNumber: 12,
items: [{ id: "2001", kind: "review", author: "reviewer-a" }],
githubFeaturesEnabled: true,
error: null,
},
});
});
it("derives the unauthenticated timeline error label from the forge brand for GitLab", async () => {
const gitlabService: Partial<ForgeService> = {
async isAuthenticated() {
return false;
},
async getPullRequestTimeline() {
throw new Error("timeline fetch should not run while unauthenticated");
},
};
const { checkout, emitted } = makeCheckoutSession({
git: {
resolveForge: async () => ({
forge: "gitlab",
host: "gitlab.example.com",
service: gitlabService as ForgeService,
}),
},
});
await checkout.handlePullRequestTimelineRequest({
type: "pull_request_timeline_request",
cwd: "/repo",
prNumber: 14,
repoOwner: "g",
repoName: "r",
requestId: "tl2",
});
const unauthenticatedResponse = emitted.find(isTimelineResponse);
expect(unauthenticatedResponse).toMatchObject({
payload: {
githubFeaturesEnabled: false,
error: {
kind: "unknown",
message: "GitLab CLI is unavailable or not authenticated",
},
},
});
expect(unauthenticatedResponse?.payload).not.toHaveProperty("authState");
});
it("carries the precise authState when the auth probe throws a classified error", async () => {
const githubService: Partial<ForgeService> = {
async isAuthenticated() {
throw new ForgeCliMissingError("gh not found");
},
async getPullRequestTimeline() {
throw new Error("timeline fetch should not run while unauthenticated");
},
};
const { checkout, emitted } = makeCheckoutSession({
git: {
resolveForge: async () => ({
forge: "github",
host: "github.com",
service: githubService as ForgeService,
}),
},
});
await checkout.handlePullRequestTimelineRequest({
type: "pull_request_timeline_request",
cwd: "/repo",
prNumber: 14,
repoOwner: "g",
repoName: "r",
requestId: "tl-cli-missing",
});
expect(emitted.find(isTimelineResponse)).toMatchObject({
payload: {
githubFeaturesEnabled: false,
authState: "cli_missing",
},
});
});
it("keeps features enabled when the timeline fetch fails for non-auth reasons", async () => {
const gitlabService: Partial<ForgeService> = {
async isAuthenticated() {
return true;
},
async getPullRequestTimeline() {
throw new Error("glab timed out");
},
};
const { checkout, emitted } = makeCheckoutSession({
git: {
resolveForge: async () => ({
forge: "gitlab",
host: "gitlab.example.com",
service: gitlabService as ForgeService,
}),
},
});
await checkout.handlePullRequestTimelineRequest({
type: "pull_request_timeline_request",
cwd: "/repo",
prNumber: 14,
repoOwner: "g",
repoName: "r",
requestId: "tl-error",
});
expect(emitted.find(isTimelineResponse)).toMatchObject({
payload: {
githubFeaturesEnabled: true,
error: {
kind: "unknown",
message: "glab timed out",
},
authState: "error",
},
});
});
it("reports features disabled with the precise authState when the timeline fetch hits an auth error", async () => {
const gitlabService: Partial<ForgeService> = {
async isAuthenticated() {
return true;
},
async getPullRequestTimeline() {
throw new ForgeCliMissingError("glab not found");
},
};
const { checkout, emitted } = makeCheckoutSession({
git: {
resolveForge: async () => ({
forge: "gitlab",
host: "gitlab.example.com",
service: gitlabService as ForgeService,
}),
},
});
await checkout.handlePullRequestTimelineRequest({
type: "pull_request_timeline_request",
cwd: "/repo",
prNumber: 14,
repoOwner: "g",
repoName: "r",
requestId: "tl-fetch-auth-error",
});
expect(emitted.find(isTimelineResponse)).toMatchObject({
payload: {
githubFeaturesEnabled: false,
authState: "cli_missing",
},
});
});
});
describe("stash list", () => {
it("returns stash entries scoped to paseo stashes by default", async () => {
const listStashesCalls: Array<{ cwd: string; paseoOnly: boolean | undefined }> = [];
@@ -826,17 +1323,82 @@ describe("CheckoutSession", () => {
},
]);
});
it("reports non-auth status errors as errors instead of sign-in setup", async () => {
const { checkout, emitted } = makeCheckoutSession({
git: {
getSnapshot: async () => {
throw new Error("glab returned invalid JSON");
},
resolveForge: async () => ({
forge: "gitlab",
host: "gitlab.example.com",
service: {} as ForgeService,
}),
},
});
await checkout.handleCheckoutPrStatusRequest({
type: "checkout_pr_status_request",
cwd: "/repo",
requestId: "ps-error",
});
expect(emitted).toEqual([
{
type: "checkout_pr_status_response",
payload: expect.objectContaining({
cwd: "/repo",
status: null,
githubFeaturesEnabled: true,
authState: "error",
forge: "gitlab",
requestId: "ps-error",
error: expect.objectContaining({ message: "glab returned invalid JSON" }),
}),
},
]);
});
it("resolves the forge once when reporting a non-auth status error", async () => {
let resolveForgeCalls = 0;
const resolveForge: WorkspaceGitService["resolveForge"] = async () => {
resolveForgeCalls += 1;
return { forge: "gitlab", host: "gitlab.example.com", service: {} as ForgeService };
};
const { checkout } = makeCheckoutSession({
git: {
getSnapshot: async () => {
throw new Error("glab returned invalid JSON");
},
resolveForge,
},
});
await checkout.handleCheckoutPrStatusRequest({
type: "checkout_pr_status_request",
cwd: "/repo",
requestId: "ps-error-2",
});
expect(resolveForgeCalls).toBe(1);
});
});
describe("github search", () => {
it("returns search results and the github-features flag", async () => {
const { checkout, emitted } = makeCheckoutSession({
github: {
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: false }),
searchIssuesAndPrs: async () => ({
items: [],
featuresEnabled: false,
authState: "unauthenticated",
githubFeaturesEnabled: false,
}),
},
});
await checkout.handleGitHubSearchRequest({
await checkout.handleForgeSearchRequest({
type: "github_search_request",
cwd: "/repo",
query: "fix",
@@ -848,6 +1410,8 @@ describe("CheckoutSession", () => {
type: "github_search_response",
payload: {
items: [],
featuresEnabled: false,
authState: "unauthenticated",
githubFeaturesEnabled: false,
error: null,
requestId: "gs1",
@@ -855,5 +1419,181 @@ describe("CheckoutSession", () => {
},
]);
});
it("converts neutral change-request items for the legacy github search response", async () => {
const { checkout, emitted } = makeCheckoutSession({
github: {
searchIssuesAndPrs: async () => ({
items: [
{
kind: "change_request",
number: 17,
title: "Fix search",
url: "https://github.com/acme/repo/pull/17",
state: "open",
body: null,
labels: ["bug"],
baseRefName: "main",
headRefName: "fix-search",
updatedAt: "2026-06-28T10:00:00.000Z",
},
{
kind: "issue",
number: 22,
title: "Track search",
url: "https://github.com/acme/repo/issues/22",
state: "open",
body: null,
labels: ["triage"],
baseRefName: null,
headRefName: null,
updatedAt: "2026-06-28T11:00:00.000Z",
},
],
featuresEnabled: true,
authState: "authenticated",
githubFeaturesEnabled: true,
}),
},
});
await checkout.handleForgeSearchRequest({
type: "github_search_request",
cwd: "/repo",
query: "fix",
requestId: "gs-legacy-items",
});
expect(emitted).toEqual([
{
type: "github_search_response",
payload: {
items: [
{
kind: "pr",
forge: "github",
number: 17,
title: "Fix search",
url: "https://github.com/acme/repo/pull/17",
state: "open",
body: null,
labels: ["bug"],
baseRefName: "main",
headRefName: "fix-search",
updatedAt: "2026-06-28T10:00:00.000Z",
},
{
kind: "issue",
forge: "github",
number: 22,
title: "Track search",
url: "https://github.com/acme/repo/issues/22",
state: "open",
body: null,
labels: ["triage"],
baseRefName: null,
headRefName: null,
updatedAt: "2026-06-28T11:00:00.000Z",
},
],
featuresEnabled: true,
authState: "authenticated",
githubFeaturesEnabled: true,
error: null,
requestId: "gs-legacy-items",
},
},
]);
});
it("routes search through the resolved GitLab service without running gh", async () => {
let githubCalled = false;
const gitlabSearches: unknown[] = [];
const { checkout, emitted } = makeCheckoutSession({
github: {
searchIssuesAndPrs: async () => {
githubCalled = true;
return {
items: [],
featuresEnabled: true,
authState: "authenticated",
githubFeaturesEnabled: true,
};
},
},
git: {
resolveForge: async () => ({
forge: "gitlab",
host: "gitlab.com",
service: {
searchIssuesAndPrs: async (input) => {
gitlabSearches.push(input);
return {
items: [
{
kind: "change_request",
number: 17,
title: "GitLab result",
url: "https://gitlab.com/acme/repo/-/merge_requests/17",
state: "opened",
body: null,
labels: [],
baseRefName: "main",
headRefName: "feature",
updatedAt: "2026-06-28T10:00:00.000Z",
},
],
featuresEnabled: true,
authState: "authenticated",
githubFeaturesEnabled: true,
};
},
} as never,
}),
},
});
await checkout.handleForgeSearchRequest({
type: "forge.search.request",
cwd: "/repo",
query: "fix",
requestId: "gs2",
});
expect(githubCalled).toBe(false);
expect(gitlabSearches).toEqual([
{
cwd: "/repo",
query: "fix",
limit: undefined,
kinds: undefined,
},
]);
expect(emitted).toEqual([
{
type: "forge.search.response",
payload: {
items: [
{
kind: "change_request",
forge: "gitlab",
number: 17,
title: "GitLab result",
url: "https://gitlab.com/acme/repo/-/merge_requests/17",
state: "opened",
body: null,
labels: [],
baseRefName: "main",
headRefName: "feature",
updatedAt: "2026-06-28T10:00:00.000Z",
},
],
authState: "authenticated",
error: null,
requestId: "gs2",
},
},
]);
});
});
});
@@ -1,6 +1,7 @@
import type pino from "pino";
import { isAbsolute } from "node:path";
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
import { getForgeDefinitionOrNeutral } from "@getpaseo/protocol/forge-manifest";
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
import type {
BranchSuggestionsRequest,
@@ -31,15 +32,17 @@ import type {
} from "../../workspace-git-service.js";
import { assertSafeGitRef } from "../../worktree-session.js";
import type { GitMutationService } from "../git-mutation/git-mutation-service.js";
import {
assertPullRequestAutoMergeDisableReady,
assertPullRequestAutoMergeEnableReady,
type GitHubService,
type PullRequestTimelineItem,
} from "../../../services/github-service.js";
import type {
ForgeAuthState,
ForgeService,
PullRequestTimelineItem,
SearchResult,
} from "../../../services/forge-service.js";
import {
commitChanges,
createPullRequest,
forgeAuthStateFromError,
isForgeAuthError,
mergeFromBase,
mergeToBase,
pullCurrentBranch,
@@ -71,11 +74,32 @@ export interface CheckoutSessionHost {
}
type CurrentWorkspacePullRequest = NonNullable<
WorkspaceGitRuntimeSnapshot["github"]["pullRequest"]
WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"]
> & {
number: number;
};
class NoResolvedForgeServiceError extends Error {
readonly authState = "no_remote" satisfies ForgeAuthState;
constructor(cwd: string) {
super(`No supported forge remote is configured for ${cwd}`);
this.name = "NoResolvedForgeServiceError";
}
}
type ForgeSearchResultItem = SearchResult["items"][number];
type LegacyGithubSearchResultItem = Omit<ForgeSearchResultItem, "kind"> & { kind: "issue" | "pr" };
function toLegacyGithubSearchItems(items: ForgeSearchResultItem[]): LegacyGithubSearchResultItem[] {
return items.map((item) => {
if (item.kind === "change_request") {
return { ...item, kind: "pr" };
}
return { ...item, kind: "issue" };
});
}
/**
* The slice of CheckoutDiffManager that CheckoutSession needs: open a live diff
* subscription, and nudge open subscriptions to recompute after a mutation. The
@@ -93,7 +117,7 @@ export interface CheckoutSessionOptions {
host: CheckoutSessionHost;
gitMutation: Pick<GitMutationService, "checkoutExistingBranch" | "notifyGitMutation">;
workspaceGitService: WorkspaceGitService;
github: GitHubService;
github: ForgeService;
checkoutDiffManager: CheckoutDiffSubscriber;
gitMetadataGenerator: GitMetadataGenerator;
paseoHome: string;
@@ -120,7 +144,7 @@ export class CheckoutSession {
"checkoutExistingBranch" | "notifyGitMutation"
>;
private readonly workspaceGitService: WorkspaceGitService;
private readonly github: GitHubService;
private readonly github: ForgeService;
private readonly checkoutDiffManager: CheckoutDiffSubscriber;
private readonly gitMetadataGenerator: GitMetadataGenerator;
private readonly paseoHome: string;
@@ -140,6 +164,69 @@ export class CheckoutSession {
this.logger = options.logger;
}
private async resolveForgeService(
cwd: string,
): Promise<{ forge: string; service: ForgeService } | null> {
const resolution = await this.workspaceGitService.resolveForge(cwd);
if (!resolution) {
return null;
}
return { forge: resolution.forge, service: resolution.service };
}
private async requireForgeService(
cwd: string,
): Promise<{ forge: string; service: ForgeService }> {
const resolution = await this.resolveForgeService(cwd);
if (!resolution) {
throw new NoResolvedForgeServiceError(cwd);
}
return resolution;
}
private async resolveForgeIdForError(cwd: string): Promise<string> {
try {
return (await this.workspaceGitService.resolveForge(cwd))?.forge ?? "github";
} catch {
return "github";
}
}
private async resolveAuthStateForError(cwd: string, error: unknown): Promise<ForgeAuthState> {
if (error instanceof NoResolvedForgeServiceError) {
return error.authState;
}
try {
return (await this.workspaceGitService.resolveForge(cwd)) ? "error" : "no_remote";
} catch {
return "error";
}
}
/**
* Combines resolveForgeIdForError + resolveAuthStateForError into a single
* resolveForge(cwd) call for the (common) case where the failure isn't a
* NoResolvedForgeServiceError — both helpers otherwise resolve the same cwd
* independently in the same error path.
*/
private async resolveForgeContextForError(
cwd: string,
error: unknown,
): Promise<{ forge: string; authState: ForgeAuthState }> {
if (error instanceof NoResolvedForgeServiceError) {
return { forge: await this.resolveForgeIdForError(cwd), authState: error.authState };
}
try {
const resolution = await this.workspaceGitService.resolveForge(cwd);
return {
forge: resolution?.forge ?? "github",
authState: resolution ? "error" : "no_remote",
};
} catch {
return { forge: "github", authState: "error" };
}
}
async handleStatusRequest(msg: CheckoutStatusRequest): Promise<void> {
const { cwd, requestId } = msg;
const resolvedCwd = expandTilde(cwd);
@@ -348,10 +435,10 @@ export class CheckoutSession {
const resolvedCwd = expandTilde(cwd);
try {
this.github.invalidate({ cwd: resolvedCwd });
(await this.resolveForgeService(resolvedCwd))?.service.invalidate({ cwd: resolvedCwd });
await this.workspaceGitService.getSnapshot(resolvedCwd, {
force: true,
includeGitHub: true,
includeForge: true,
reason: "manual-refresh",
});
this.checkoutDiffManager.scheduleRefreshForCwd(resolvedCwd);
@@ -470,7 +557,7 @@ export class CheckoutSession {
try {
const result = await this.host.renameCurrentBranch(cwd, branch);
await this.gitMutation.notifyGitMutation(cwd, "rename-branch", { invalidateGithub: true });
await this.gitMutation.notifyGitMutation(cwd, "rename-branch", { invalidateForge: true });
this.scheduleDiffRefresh(cwd);
this.host.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch);
@@ -652,7 +739,7 @@ export class CheckoutSession {
{ paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot },
);
await Promise.all([
this.gitMutation.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateGithub: true }),
this.gitMutation.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateForge: true }),
...(mutatedCwd !== cwd ? [this.gitMutation.notifyGitMutation(cwd, "merge-to-base")] : []),
]);
this.scheduleDiffRefresh(cwd);
@@ -696,7 +783,7 @@ export class CheckoutSession {
baseRef: msg.baseRef,
requireCleanTarget: msg.requireCleanTarget ?? true,
});
await this.gitMutation.notifyGitMutation(cwd, "merge-from-base", { invalidateGithub: true });
await this.gitMutation.notifyGitMutation(cwd, "merge-from-base", { invalidateForge: true });
this.scheduleDiffRefresh(cwd);
this.host.emit({
@@ -728,7 +815,7 @@ export class CheckoutSession {
try {
await pullCurrentBranch(cwd);
await this.gitMutation.notifyGitMutation(cwd, "pull", { invalidateGithub: true });
await this.gitMutation.notifyGitMutation(cwd, "pull", { invalidateForge: true });
this.scheduleDiffRefresh(cwd);
this.host.emit({
@@ -760,7 +847,7 @@ export class CheckoutSession {
try {
await pushCurrentBranch(cwd);
await this.gitMutation.notifyGitMutation(cwd, "push", { invalidateGithub: true });
await this.gitMutation.notifyGitMutation(cwd, "push", { invalidateForge: true });
this.host.emit({
type: "checkout_push_response",
payload: {
@@ -798,6 +885,7 @@ export class CheckoutSession {
if (!body) body = generated.body;
}
const { service } = await this.requireForgeService(cwd);
const result = await createPullRequest(
cwd,
{
@@ -805,9 +893,9 @@ export class CheckoutSession {
body,
base: msg.baseRef,
},
this.github,
service,
);
await this.gitMutation.notifyGitMutation(cwd, "create-pr", { invalidateGithub: true });
await this.gitMutation.notifyGitMutation(cwd, "create-pr", { invalidateForge: true });
this.host.emit({
type: "checkout_pr_create_response",
@@ -841,17 +929,17 @@ export class CheckoutSession {
try {
const pullRequest = await this.resolveCurrentPullRequest(cwd, "merge", {
force: true,
includeGitHub: true,
includeForge: true,
reason: "merge-pr-validation",
});
this.assertCurrentPullRequestHasGithubMergeFacts(pullRequest);
await this.github.mergePullRequest({
const { service } = await this.requireForgeService(cwd);
await service.mergePullRequest({
cwd,
prNumber: pullRequest.number,
mergeMethod: msg.mergeMethod,
status: pullRequest,
});
await this.gitMutation.notifyGitMutation(cwd, "merge-pr", { invalidateGithub: true });
await this.gitMutation.notifyGitMutation(cwd, "merge-pr", { invalidateForge: true });
this.host.emit({
type: "checkout_pr_merge_response",
@@ -875,35 +963,33 @@ export class CheckoutSession {
}
}
private assertCurrentPullRequestHasGithubMergeFacts(
pullRequest: CurrentWorkspacePullRequest,
): void {
if (!pullRequest.github) {
throw new Error("GitHub merge facts are unavailable for this pull request");
}
}
async handleCheckoutGithubSetAutoMergeRequest(
msg: Extract<SessionInboundMessage, { type: "checkout.github.set_auto_merge.request" }>,
async handleCheckoutForgeSetAutoMergeRequest(
msg: Extract<
SessionInboundMessage,
{
type: "checkout.forge.set_auto_merge.request" | "checkout.github.set_auto_merge.request";
}
>,
): Promise<void> {
const { cwd, requestId } = msg;
const responseType =
msg.type === "checkout.forge.set_auto_merge.request"
? "checkout.forge.set_auto_merge.response"
: "checkout.github.set_auto_merge.response";
try {
const pullRequest = await this.resolveCurrentPullRequest(cwd, "auto-merge", {
force: true,
includeGitHub: true,
includeForge: true,
reason: "auto-merge-validation",
});
const { service } = await this.requireForgeService(cwd);
if (msg.enabled) {
const mergeMethod = msg.mergeMethod;
if (!mergeMethod) {
throw new Error("mergeMethod is required when enabling auto-merge");
}
assertPullRequestAutoMergeEnableReady({
mergeMethod,
status: pullRequest,
});
await this.github.enablePullRequestAutoMerge({
await service.enablePullRequestAutoMerge({
cwd,
prNumber: pullRequest.number,
mergeMethod,
@@ -913,8 +999,7 @@ export class CheckoutSession {
if (msg.mergeMethod) {
throw new Error("mergeMethod is not allowed when disabling auto-merge");
}
assertPullRequestAutoMergeDisableReady({ status: pullRequest });
await this.github.disablePullRequestAutoMerge({
await service.disablePullRequestAutoMerge({
cwd,
prNumber: pullRequest.number,
status: pullRequest,
@@ -924,12 +1009,12 @@ export class CheckoutSession {
cwd,
msg.enabled ? "enable-pr-auto-merge" : "disable-pr-auto-merge",
{
invalidateGithub: true,
invalidateForge: true,
},
);
this.host.emit({
type: "checkout.github.set_auto_merge.response",
type: responseType,
payload: {
cwd,
enabled: msg.enabled,
@@ -940,7 +1025,7 @@ export class CheckoutSession {
});
} catch (error) {
this.host.emit({
type: "checkout.github.set_auto_merge.response",
type: responseType,
payload: {
cwd,
enabled: msg.enabled,
@@ -958,9 +1043,9 @@ export class CheckoutSession {
options?: WorkspaceGitSnapshotOptions,
): Promise<CurrentWorkspacePullRequest> {
const snapshot = await this.workspaceGitService.getSnapshot(cwd, options);
const pullRequest = snapshot.github.pullRequest;
const pullRequest = snapshot.forge.pullRequest;
if (!pullRequest || typeof pullRequest.number !== "number") {
throw new Error(`Unable to determine GitHub pull request number for ${operation}`);
throw new Error(`Unable to determine current change request number for ${operation}`);
}
return { ...pullRequest, number: pullRequest.number };
}
@@ -981,12 +1066,15 @@ export class CheckoutSession {
}),
});
} catch (error) {
const { forge, authState } = await this.resolveForgeContextForError(cwd, error);
this.host.emit({
type: "checkout_pr_status_response",
payload: {
cwd,
status: null,
githubFeaturesEnabled: true,
githubFeaturesEnabled: authState === "authenticated" || authState === "error",
authState,
forge,
error: toCheckoutError(error),
requestId,
},
@@ -1018,8 +1106,8 @@ export class CheckoutSession {
return;
}
const githubFeaturesEnabled = await this.github.isAuthenticated({ cwd });
if (!githubFeaturesEnabled) {
const resolvedForge = await this.resolveForgeService(cwd);
if (!resolvedForge) {
this.host.emit({
type: "pull_request_timeline_response",
payload: {
@@ -1029,17 +1117,50 @@ export class CheckoutSession {
truncated: false,
error: {
kind: "unknown",
message: "GitHub CLI is unavailable or not authenticated",
message: "No supported forge remote is configured for this workspace",
},
requestId,
githubFeaturesEnabled: false,
authState: "no_remote",
},
});
return;
}
const { forge, service } = resolvedForge;
// A throwing auth probe (authProbeCanThrow) yields the precise kind; a
// false return can't distinguish cli_missing from unauthenticated, so
// authState is omitted rather than guessed.
let featuresEnabled: boolean;
let probeAuthState: ForgeAuthState | undefined;
try {
featuresEnabled = await service.isAuthenticated({ cwd });
} catch (error) {
featuresEnabled = false;
probeAuthState = forgeAuthStateFromError(error);
}
if (!featuresEnabled) {
this.host.emit({
type: "pull_request_timeline_response",
payload: {
cwd,
prNumber,
items: [],
truncated: false,
error: {
kind: "unknown",
message: `${getForgeDefinitionOrNeutral(forge).displayName} CLI is unavailable or not authenticated`,
},
requestId,
githubFeaturesEnabled: false,
...(probeAuthState ? { authState: probeAuthState } : {}),
},
});
return;
}
try {
const timeline = await this.github.getPullRequestTimeline({
const timeline = await service.getPullRequestTimeline({
cwd,
prNumber,
repoOwner,
@@ -1070,27 +1191,56 @@ export class CheckoutSession {
message: error instanceof Error ? error.message : String(error),
},
requestId,
githubFeaturesEnabled: true,
// A non-auth failure (timeout, API error) must keep reading as
// "features enabled" for old clients that only understand the
// boolean, or they render an auth blank instead of this error.
githubFeaturesEnabled: !isForgeAuthError(error),
authState: isForgeAuthError(error) ? forgeAuthStateFromError(error) : "error",
},
});
}
}
async handleCheckoutGithubGetCheckDetailsRequest(
msg: Extract<SessionInboundMessage, { type: "checkout.github.get_check_details.request" }>,
async handleCheckoutForgeGetCheckDetailsRequest(
msg: Extract<
SessionInboundMessage,
{
type:
| "checkout.forge.get_check_details.request"
| "checkout.github.get_check_details.request";
}
>,
): Promise<void> {
const { cwd, repoOwner, repoName, checkRunId, workflowRunId, requestId } = msg;
const { cwd, repoOwner, repoName, checkRunId, workflowRunId, changeRequestNumber, requestId } =
msg;
const responseType =
msg.type === "checkout.forge.get_check_details.request"
? "checkout.forge.get_check_details.response"
: "checkout.github.get_check_details.response";
try {
const details = await this.github.getGitHubCheckDetails({
// The payload schema keeps checkRunId and workflowRunId optional (a Gitea
// Actions run has no check-run id; GitLab routes by changeRequestNumber),
// but a request that addresses no check at all is not actionable — reject
// it here with a clear message instead of failing deep in an adapter. The
// schema itself cannot enforce this: it is a discriminated-union member, so
// a refine would turn it into a ZodEffects and break the union.
if (checkRunId === undefined && workflowRunId === undefined) {
throw new Error(
"Check details request must address a check by checkRunId or workflowRunId",
);
}
const { service } = await this.requireForgeService(cwd);
const details = await service.getCheckDetails({
cwd,
repoOwner,
repoName,
checkRunId,
workflowRunId,
changeRequestNumber,
});
this.host.emit({
type: "checkout.github.get_check_details.response",
type: responseType,
payload: {
cwd,
success: true,
@@ -1101,7 +1251,7 @@ export class CheckoutSession {
});
} catch (error) {
this.host.emit({
type: "checkout.github.get_check_details.response",
type: responseType,
payload: {
cwd,
success: false,
@@ -1116,34 +1266,104 @@ export class CheckoutSession {
}
}
async handleGitHubSearchRequest(
msg: Extract<SessionInboundMessage, { type: "github_search_request" }>,
async handleForgeSearchRequest(
msg: Extract<SessionInboundMessage, { type: "forge.search.request" | "github_search_request" }>,
): Promise<void> {
const { cwd, query, limit, kinds, requestId } = msg;
try {
const resolvedCwd = expandTilde(cwd);
const result = await this.github.searchIssuesAndPrs({
// COMPAT(githubSearchRpc): added in v0.1.106, remove after 2026-12-28 —
// the legacy github_search RPC is GitHub by definition; the modern
// forge.search RPC resolves the cwd's forge.
const resolvedForge =
msg.type === "github_search_request"
? { forge: "github", service: this.github }
: await this.resolveForgeService(resolvedCwd);
if (!resolvedForge) {
if (msg.type === "github_search_request") {
this.host.emit({
type: "github_search_response",
payload: {
items: [],
featuresEnabled: false,
authState: "no_remote",
githubFeaturesEnabled: false,
error: null,
requestId,
},
});
return;
}
this.host.emit({
type: "forge.search.response",
payload: {
items: [],
authState: "no_remote",
error: null,
requestId,
},
});
return;
}
const { forge, service } = resolvedForge;
const result = await service.searchIssuesAndPrs({
cwd: resolvedCwd,
query,
limit,
kinds,
});
const items = result.items.map((item) =>
Object.assign({}, item, { forge: item.forge ?? forge }),
);
if (msg.type === "github_search_request") {
const featuresEnabled = result.featuresEnabled ?? result.githubFeaturesEnabled ?? true;
const authState =
result.authState ?? (featuresEnabled ? "authenticated" : "unauthenticated");
this.host.emit({
type: "github_search_response",
payload: {
items: toLegacyGithubSearchItems(items),
featuresEnabled,
authState,
githubFeaturesEnabled: featuresEnabled,
error: null,
requestId,
},
});
return;
}
this.host.emit({
type: "github_search_response",
type: "forge.search.response",
payload: {
items: result.items,
githubFeaturesEnabled: result.githubFeaturesEnabled,
items,
authState: result.authState,
error: null,
requestId,
},
});
} catch (error) {
const resolvedCwd = expandTilde(cwd);
const authState = await this.resolveAuthStateForError(resolvedCwd, error);
if (msg.type === "github_search_request") {
this.host.emit({
type: "github_search_response",
payload: {
items: [],
featuresEnabled: false,
authState,
githubFeaturesEnabled: false,
error: error instanceof Error ? error.message : String(error),
requestId,
},
});
return;
}
this.host.emit({
type: "github_search_response",
type: "forge.search.response",
payload: {
items: [],
githubFeaturesEnabled: true,
authState,
error: error instanceof Error ? error.message : String(error),
requestId,
},
@@ -4,7 +4,6 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { pino } from "pino";
import { afterEach, describe, expect, test } from "vitest";
import type { GitHubService } from "../../../services/github-service.js";
import type {
WorkspaceGitBranchValidationResult,
WorkspaceGitRuntimeSnapshot,
@@ -12,12 +11,15 @@ import type {
} from "../../workspace-git-service.js";
import { createGitMutationService } from "./git-mutation-service.js";
// The production module reads only WorkspaceGitService.{validateBranchRef,getSnapshot,hasLocalBranch}
// and GitHubService.invalidate. The fakes below implement exactly that slice as in-memory
// adapters; the happy-path tests cross the real git boundary against a temp repo, since that is
// where checkoutResolvedBranch / `git checkout -b` actually run.
// The production module reads only WorkspaceGitService.{validateBranchRef,getSnapshot,
// hasLocalBranch,invalidateForge}. The fake below implements exactly that slice as an
// in-memory adapter; the happy-path tests cross the real git boundary against a temp repo,
// since that is where checkoutResolvedBranch / `git checkout -b` actually run.
type GitSource = Pick<WorkspaceGitService, "validateBranchRef" | "getSnapshot" | "hasLocalBranch">;
type GitSource = Pick<
WorkspaceGitService,
"validateBranchRef" | "getSnapshot" | "hasLocalBranch" | "invalidateForge"
>;
const logger = pino({ level: "silent" });
@@ -33,6 +35,7 @@ function createFakeGit(opts: FakeGitOptions = {}) {
const isDirty = opts.isDirty ?? false;
const branchExists = opts.branchExists ?? false;
const snapshotCalls: Array<{ cwd: string; force: boolean; reason?: string }> = [];
const invalidateCalls: Array<{ cwd: string }> = [];
const git: GitSource = {
async validateBranchRef() {
return resolution;
@@ -47,24 +50,16 @@ function createFakeGit(opts: FakeGitOptions = {}) {
async hasLocalBranch() {
return branchExists;
},
};
return { git, snapshotCalls };
}
function createFakeGithub() {
const invalidateCalls: Array<{ cwd: string }> = [];
const github: Pick<GitHubService, "invalidate"> = {
invalidate(options) {
invalidateCalls.push(options);
invalidateForge(cwd) {
invalidateCalls.push({ cwd });
},
};
return { github, invalidateCalls };
return { git, snapshotCalls, invalidateCalls };
}
function buildService(gitOptions: FakeGitOptions = {}) {
const { git, snapshotCalls } = createFakeGit(gitOptions);
const { github, invalidateCalls } = createFakeGithub();
const service = createGitMutationService({ workspaceGitService: git, github, logger });
const { git, snapshotCalls, invalidateCalls } = createFakeGit(gitOptions);
const service = createGitMutationService({ workspaceGitService: git, logger });
return { service, snapshotCalls, invalidateCalls };
}
@@ -197,9 +192,9 @@ describe("createBranchFromBase", () => {
});
describe("notifyGitMutation", () => {
test("invalidates github and force-refreshes when invalidateGithub is set", async () => {
test("invalidates github and force-refreshes when invalidateForge is set", async () => {
const { service, snapshotCalls, invalidateCalls } = buildService();
await service.notifyGitMutation("/tmp/repo", "commit-changes", { invalidateGithub: true });
await service.notifyGitMutation("/tmp/repo", "commit-changes", { invalidateForge: true });
expect(invalidateCalls).toEqual([{ cwd: "/tmp/repo" }]);
expect(snapshotCalls).toEqual([{ cwd: "/tmp/repo", force: true, reason: "commit-changes" }]);
});
@@ -1,6 +1,5 @@
import type pino from "pino";
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
import type { GitHubService } from "../../../services/github-service.js";
import {
checkoutResolvedBranch,
type CheckoutExistingBranchResult,
@@ -13,7 +12,7 @@ import { assertSafeGitRef as assertWorktreeSafeGitRef } from "../../worktree-ses
/**
* The git branch / working-tree mutation primitives a client session performs on a
* workspace: switch to an existing branch, create a branch from a base, and force a
* snapshot refresh (plus optional GitHub cache invalidation) after any mutation.
* snapshot refresh (plus optional forge cache invalidation) after any mutation.
*
* CheckoutSession (the branch/commit/merge commands), the worktree session-config
* builder, and the auto-naming + worktree-creation paths all funnel their git
@@ -31,21 +30,20 @@ export interface GitMutationService {
notifyGitMutation(
cwd: string,
reason: GitMutationRefreshReason,
options?: { invalidateGithub?: boolean },
options?: { invalidateForge?: boolean },
): Promise<void>;
}
type GitMutationGitSource = Pick<
WorkspaceGitService,
"validateBranchRef" | "getSnapshot" | "hasLocalBranch"
"validateBranchRef" | "getSnapshot" | "hasLocalBranch" | "invalidateForge"
>;
export function createGitMutationService(deps: {
workspaceGitService: GitMutationGitSource;
github: Pick<GitHubService, "invalidate">;
logger: pino.Logger;
}): GitMutationService {
const { workspaceGitService, github, logger } = deps;
const { workspaceGitService, logger } = deps;
function assertSafeGitRef(ref: string, label: string): void {
if (!/^[A-Za-z0-9._/-]+$/.test(ref)) {
@@ -77,10 +75,10 @@ export function createGitMutationService(deps: {
async function notifyGitMutation(
cwd: string,
reason: GitMutationRefreshReason,
options?: { invalidateGithub?: boolean },
options?: { invalidateForge?: boolean },
): Promise<void> {
if (options?.invalidateGithub) {
github.invalidate({ cwd });
if (options?.invalidateForge) {
workspaceGitService.invalidateForge(cwd);
}
try {
await workspaceGitService.getSnapshot(cwd, { force: true, reason });
@@ -101,7 +99,7 @@ export function createGitMutationService(deps: {
}
await ensureCleanWorkingTree(cwd);
const result = await checkoutResolvedBranch({ cwd, resolution });
await notifyGitMutation(cwd, "switch-branch", { invalidateGithub: true });
await notifyGitMutation(cwd, "switch-branch", { invalidateForge: true });
return result;
},
@@ -24,7 +24,7 @@ export function createNoGitWorkspaceRuntimeSnapshot(cwd: string): WorkspaceGitRu
hasRemote: false,
diffStat: null,
},
github: {
forge: {
featuresEnabled: false,
pullRequest: null,
error: null,
@@ -68,6 +68,7 @@ export function createNoopWorkspaceGitService(
remoteUrl: snapshot.git.remoteUrl,
});
},
resolveForge: async () => null,
resolveRepoRoot: async (cwd: string) => cwd,
resolveDefaultBranch: async () => "main",
resolveRepoRemoteUrl: async () => null,
@@ -78,6 +79,7 @@ export function createNoopWorkspaceGitService(
}),
scheduleRefreshForCwd: () => {},
onWorkspaceStateMayHaveChanged: () => {},
invalidateForge: () => {},
dispose: () => {},
...overrides,
};
+17 -4
View File
@@ -52,7 +52,8 @@ import {
buildAgentAttentionNotificationPayload,
findLatestPermissionRequest,
} from "@getpaseo/protocol/agent-attention-notification";
import { createGitHubService, type GitHubService } from "../services/github-service.js";
import { createGitHubService } from "../services/github-service.js";
import type { ForgeService } from "../services/forge-service.js";
import {
extractWsBearerProtocol,
extractWsBearerToken,
@@ -154,8 +155,9 @@ function createFallbackWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSna
hasRemote: false,
diffStat: null,
},
github: {
forge: {
featuresEnabled: false,
authState: "no_remote",
pullRequest: null,
error: null,
},
@@ -181,6 +183,7 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService {
mainRepoRoot: null,
}),
getSnapshot: async (cwd: string) => createFallbackWorkspaceGitSnapshot(cwd),
resolveForge: async () => null,
getCheckoutDiff: async () => ({ diff: "" }),
validateBranchRef: async () => ({ kind: "not-found" }),
hasLocalBranch: async () => false,
@@ -209,6 +212,7 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService {
}),
scheduleRefreshForCwd: () => {},
onWorkspaceStateMayHaveChanged: () => {},
invalidateForge: () => {},
dispose: () => {},
};
}
@@ -414,7 +418,7 @@ export class VoiceAssistantWebSocketServer {
private readonly loopService: LoopService;
private readonly scheduleService: ScheduleService;
private readonly checkoutDiffManager: CheckoutDiffManager;
private readonly github: GitHubService;
private readonly github: ForgeService;
private readonly workspaceGitService: WorkspaceGitService;
private readonly workspaceAutoName: WorkspaceAutoName;
private readonly downloadTokenStore: DownloadTokenStore;
@@ -493,7 +497,7 @@ export class VoiceAssistantWebSocketServer {
getDaemonTcpHost?: () => string | null,
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null,
workspaceGitService?: WorkspaceGitService,
github?: GitHubService,
github?: ForgeService,
pushNotificationSender?: PushNotificationSender,
providerSnapshotManager?: ProviderSnapshotManager,
daemonRuntimeConfig?: DaemonRuntimeConfig,
@@ -1219,9 +1223,16 @@ export class VoiceAssistantWebSocketServer {
features: {
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
providersSnapshot: true,
// COMPAT(checkoutForgeSetAutoMerge): added in v0.1.106, remove old
// checkoutGithubSetAutoMerge fallback after 2026-12-28.
checkoutForgeSetAutoMerge: true,
// COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13.
checkoutGithubSetAutoMerge: true,
githubCheckDetails: true,
// COMPAT(forgeCheckDetails): added in v0.1.106, remove githubCheckDetails fallback after 2026-12-28.
forgeCheckDetails: true,
// COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28.
forgeSearch: true,
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
daemonStatusRpc: true,
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
@@ -1268,6 +1279,8 @@ export class VoiceAssistantWebSocketServer {
providerRemoval: true,
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
importSessionWorkspaceTarget: true,
// COMPAT(forgeProviders): added in v0.1.106, drop the gate when daemon floor >= v0.1.106.
forgeProviders: true,
},
};
}
@@ -5,7 +5,7 @@ import path from "node:path";
import pino, { type Logger } from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { GitHubService } from "../services/github-service.js";
import type { ForgeService } from "../services/forge-service.js";
import { createWorktree, type WorktreeConfig } from "../utils/worktree.js";
import type { ManagedAgent } from "./agent/agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
@@ -34,11 +34,15 @@ function createLogger(): Logger {
return logger;
}
function createGitHubServiceStub(): GitHubService {
function createGitHubServiceStub(): ForgeService {
return {
listPullRequests: async () => [],
listIssues: async () => [],
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
searchIssuesAndPrs: async () => ({
items: [],
featuresEnabled: true,
githubFeaturesEnabled: true,
}),
getPullRequest: async ({ number }) => ({
number,
title: `PR ${number}`,
@@ -50,6 +54,15 @@ function createGitHubServiceStub(): GitHubService {
labels: [],
}),
getPullRequestHeadRef: async ({ number }) => `pr-${number}`,
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: `pr-${number}`,
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
}),
getCurrentPullRequestStatus: async () => null,
createPullRequest: async () => ({
number: 1,
@@ -5,7 +5,7 @@ import type { Logger } from "pino";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import type { GitHubService } from "../services/github-service.js";
import type { ForgeService } from "../services/forge-service.js";
import {
deletePaseoWorktree,
isPaseoOwnedWorktreeCwd,
@@ -26,7 +26,7 @@ export interface ArchiveDependencies {
// Base directory that may hold worktrees across repositories. Used as a fallback
// when the request does not supply a per-repo root.
paseoWorktreesBaseRoot?: string;
github: GitHubService;
github: ForgeService;
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
agentManager: Pick<AgentManager, "listAgents" | "archiveAgent" | "archiveSnapshot">;
agentStorage: Pick<AgentStorage, "list">;
@@ -1,5 +1,5 @@
import { basename } from "path";
import { parseGitHubRemoteUrl } from "../utils/github-remote.js";
import { parseGitHubRemoteUrl } from "@getpaseo/protocol/git-remote";
import { slugify } from "../utils/worktree.js";
import { deriveProjectGroupingKey, deriveProjectGroupingName } from "./workspace-registry-model.js";
@@ -4,11 +4,9 @@ import { readdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve as resolvePath } from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
createGitHubService,
type GitHubCurrentPullRequestStatus,
type GitHubService,
} from "../services/github-service.js";
import { createGitHubService } from "../services/github-service.js";
import type { CurrentPullRequestStatus, ForgeService } from "../services/forge-service.js";
import { defaultForgeRegistry } from "../services/forge-registry.js";
import {
getCheckoutDiff as getCheckoutDiffUncached,
getCheckoutSnapshotFacts as getCheckoutSnapshotFactsUncached,
@@ -115,10 +113,31 @@ function createPullRequestStatusResult(title = "Update feature"): PullRequestSta
headRefName: "feature",
isMerged: false,
},
authState: "authenticated",
featuresEnabled: true,
githubFeaturesEnabled: true,
};
}
function createCurrentPullRequestStatus(
overrides?: Partial<CurrentPullRequestStatus>,
): CurrentPullRequestStatus {
return {
number: 14,
url: "https://forge-self-heal.test/acme/repo/-/merge_requests/14",
title: "MR self-healed",
state: "open",
baseRefName: "main",
headRefName: "feature",
isMerged: false,
mergeable: "UNKNOWN",
checks: [],
checksStatus: "none",
reviewDecision: null,
...overrides,
};
}
function currentPullRequestJson(overrides: Record<string, unknown> = {}): string {
return JSON.stringify({
number: 123,
@@ -135,14 +154,13 @@ function currentPullRequestJson(overrides: Record<string, unknown> = {}): string
});
}
function createSnapshot(
cwd: string,
overrides?: {
git?: Partial<WorkspaceGitRuntimeSnapshot["git"]>;
github?: Partial<WorkspaceGitRuntimeSnapshot["github"]>;
},
): WorkspaceGitRuntimeSnapshot {
const base: WorkspaceGitRuntimeSnapshot = {
interface SnapshotOverrides {
git?: Partial<WorkspaceGitRuntimeSnapshot["git"]>;
forge?: Partial<WorkspaceGitRuntimeSnapshot["forge"]>;
}
function createBaseSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
return {
cwd,
git: {
isGit: true,
@@ -159,7 +177,7 @@ function createSnapshot(
hasRemote: true,
diffStat: { additions: 1, deletions: 0 },
},
github: {
forge: {
featuresEnabled: true,
pullRequest: {
url: "https://github.com/acme/repo/pull/123",
@@ -172,33 +190,89 @@ function createSnapshot(
error: null,
},
};
}
function hasGithubOverride(
overrides: SnapshotOverrides | undefined,
key: keyof WorkspaceGitRuntimeSnapshot["forge"],
): boolean {
return Boolean(overrides?.forge && key in overrides.forge);
}
function resolveSnapshotForge(
base: WorkspaceGitRuntimeSnapshot,
overrides: SnapshotOverrides | undefined,
): string | undefined {
if (hasGithubOverride(overrides, "forge")) {
return overrides?.forge?.forge;
}
const remoteUrl = overrides?.git?.remoteUrl ?? base.git.remoteUrl;
const explicitlyUnavailable =
overrides?.forge?.featuresEnabled === false && overrides.forge.pullRequest === null;
if (explicitlyUnavailable || !remoteUrl) {
return undefined;
}
return "github";
}
function resolveSnapshotPullRequest(
base: WorkspaceGitRuntimeSnapshot,
overrides: SnapshotOverrides | undefined,
): WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"] {
if (hasGithubOverride(overrides, "pullRequest")) {
return overrides?.forge?.pullRequest ?? null;
}
return base.forge.pullRequest;
}
function resolveSnapshotError(
base: WorkspaceGitRuntimeSnapshot,
overrides: SnapshotOverrides | undefined,
): WorkspaceGitRuntimeSnapshot["forge"]["error"] {
if (hasGithubOverride(overrides, "error")) {
return overrides?.forge?.error ?? null;
}
return base.forge.error;
}
function createSnapshot(cwd: string, overrides?: SnapshotOverrides): WorkspaceGitRuntimeSnapshot {
const base = createBaseSnapshot(cwd);
const featuresEnabled = overrides?.forge?.featuresEnabled ?? base.forge.featuresEnabled;
const authState =
overrides?.forge?.authState ?? (featuresEnabled ? "authenticated" : "no_remote");
const forge = resolveSnapshotForge(base, overrides);
return {
cwd,
git: {
...base.git,
...overrides?.git,
},
github: {
...base.github,
...overrides?.github,
pullRequest:
overrides?.github && "pullRequest" in overrides.github
? (overrides.github.pullRequest ?? null)
: base.github.pullRequest,
error:
overrides?.github && "error" in overrides.github
? (overrides.github.error ?? null)
: base.github.error,
forge: {
...base.forge,
...overrides?.forge,
featuresEnabled,
authState,
...(forge ? { forge } : {}),
pullRequest: resolveSnapshotPullRequest(base, overrides),
error: resolveSnapshotError(base, overrides),
},
};
}
function createGitHubServiceStub(): GitHubService {
function createGitHubServiceStub(): ForgeService {
return {
listPullRequests: vi.fn(async () => []),
listIssues: vi.fn(async () => []),
searchIssuesAndPrs: vi.fn(async () => ({ items: [], githubFeaturesEnabled: true })),
searchIssuesAndPrs: vi.fn(async () => ({
items: [],
featuresEnabled: true,
githubFeaturesEnabled: true,
})),
getPullRequest: vi.fn(async () => ({
number: 1,
title: "PR",
@@ -210,7 +284,20 @@ function createGitHubServiceStub(): GitHubService {
labels: [],
})),
getPullRequestHeadRef: vi.fn(async () => "feature"),
getPullRequestCheckoutTarget: vi.fn(async ({ number }) => ({
number,
baseRefName: "main",
headRefName: "feature",
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
})),
getCurrentPullRequestStatus: vi.fn(async () => null),
// The real GitHub adapter drives PR status through a retained poll rather than
// the generic poll, so the stub mirrors that capability (a no-op subscription)
// to keep the resolver/poll path faithful.
retainCurrentPullRequestStatusPoll: vi.fn(() => ({ unsubscribe: vi.fn() })),
getPullRequestTimeline: vi.fn(async () => ({
pullRequest: null,
events: [],
@@ -235,7 +322,7 @@ interface CreateServiceOptions {
resolveRepositoryDefaultBranch?: ReturnType<typeof vi.fn>;
listBranchSuggestions?: ReturnType<typeof vi.fn>;
listPaseoWorktrees?: ReturnType<typeof vi.fn>;
github?: GitHubService;
github?: ForgeService;
resolveAbsoluteGitDir?: ReturnType<typeof vi.fn>;
hasOriginRemote?: ReturnType<typeof vi.fn>;
runGitFetch?: ReturnType<typeof vi.fn>;
@@ -261,7 +348,7 @@ function buildDefaultServiceDeps() {
resolveRepositoryDefaultBranch: vi.fn(async () => "main"),
listBranchSuggestions: vi.fn(async () => []),
listPaseoWorktrees: vi.fn(async () => []),
github: createGitHubServiceStub(),
forgeOverrides: { github: createGitHubServiceStub() },
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
hasOriginRemote: vi.fn(async () => false),
runGitFetch: vi.fn(async () => {}),
@@ -277,7 +364,13 @@ function buildDefaultServiceDeps() {
}
function buildServiceDeps(options?: CreateServiceOptions) {
return { ...buildDefaultServiceDeps(), ...options };
const { github, ...rest } = options ?? {};
const defaults = buildDefaultServiceDeps();
return {
...defaults,
...rest,
forgeOverrides: github ? { github } : defaults.forgeOverrides,
};
}
function createService(options?: CreateServiceOptions) {
@@ -333,6 +426,49 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
service.dispose();
});
test("getSnapshot reports an unresolved self-hosted remote as neutral unauthenticated", async () => {
vi.useRealTimers();
const previousPath = process.env.PATH;
const emptyPathDir = mkdtempSync(join(tmpdir(), "workspace-git-no-forge-cli-"));
process.env.PATH = emptyPathDir;
const remoteUrl = "https://git.internal/acme/repo.git";
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
const service = createService({
getCheckoutSnapshotFacts: vi.fn(async (cwd: string) =>
createCheckoutFacts(cwd, {
remoteUrl,
}),
),
getCheckoutStatus: vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, {
remoteUrl,
}),
),
getPullRequestStatus,
});
try {
await expect(service.getSnapshot(REPO_CWD)).resolves.toMatchObject({
forge: {
featuresEnabled: false,
authState: "unauthenticated",
forge: "git.internal",
pullRequest: null,
error: null,
},
});
expect(getPullRequestStatus).not.toHaveBeenCalled();
} finally {
service.dispose();
if (previousPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = previousPath;
}
rmSync(emptyPathDir, { recursive: true, force: true });
}
});
test("registerWorkspace returns a subscription without waiting for a cold snapshot", async () => {
const checkoutStatusDeferred = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
@@ -483,7 +619,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
currentBranch: "feature",
diffStat: { additions: 4, deletions: 2 },
},
github: {
forge: {
featuresEnabled: false,
pullRequest: null,
error: null,
@@ -588,14 +724,14 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
const gitRefresh = service.getSnapshot(REPO_CWD, {
force: true,
includeGitHub: false,
includeForge: false,
reason: "watch",
});
await flushPromises();
const validationRefresh = service.getSnapshot(REPO_CWD, {
force: true,
includeGitHub: true,
includeForge: true,
reason: "merge-pr-validation",
});
await flushPromises();
@@ -606,7 +742,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
await expect(validationRefresh).resolves.toEqual(
createSnapshot(REPO_CWD, {
github: {
forge: {
pullRequest: {
url: "https://github.com/acme/repo/pull/123",
title: "Fresh validation PR",
@@ -834,7 +970,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
});
const getCurrentPullRequestStatus = github.getCurrentPullRequestStatus.bind(github);
github.getCurrentPullRequestStatus = vi.fn(
async (options): Promise<GitHubCurrentPullRequestStatus | null> => {
async (options): Promise<CurrentPullRequestStatus | null> => {
githubReadCalls.push({ reason: options.reason, tickMs: nowMs });
return getCurrentPullRequestStatus(options);
},
@@ -865,7 +1001,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(gitReadsAfterInitialSnapshot);
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
github: expect.objectContaining({
forge: expect.objectContaining({
pullRequest: expect.objectContaining({
checksStatus: "pending",
}),
@@ -938,7 +1074,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
});
const getCurrentPullRequestStatus = github.getCurrentPullRequestStatus.bind(github);
github.getCurrentPullRequestStatus = vi.fn(
async (options): Promise<GitHubCurrentPullRequestStatus | null> => {
async (options): Promise<CurrentPullRequestStatus | null> => {
githubReadCalls.push({ reason: options.reason, tickMs: nowMs });
return getCurrentPullRequestStatus(options);
},
@@ -981,6 +1117,108 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
github.dispose?.();
});
test("subscription self-heal polls a resolved non-GitHub forge for PR status", async () => {
const forge = {
...createGitHubServiceStub(),
// A non-GitHub forge has no retained poll, so it uses the generic poll path.
retainCurrentPullRequestStatusPoll: undefined,
getCurrentPullRequestStatus: vi.fn(async () => createCurrentPullRequestStatus()),
};
const unregister = defaultForgeRegistry.register("gitlab-test", {
createService: () => forge,
matchesHost: (host) => host === "forge-self-heal.test",
});
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) =>
createCheckoutFacts(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-self-heal.test/acme/repo.git",
pullRequestLookupTarget: { headRef: "feature" },
}),
);
const getCheckoutStatus = vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-self-heal.test/acme/repo.git",
}),
);
const service = createService({
getCheckoutSnapshotFacts,
getCheckoutStatus,
});
const listener = vi.fn();
try {
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
await flushPromises();
await vi.advanceTimersByTimeAsync(60_000);
await flushPromises();
expect(forge.getCurrentPullRequestStatus).toHaveBeenCalledWith({
cwd: REPO_CWD,
headRef: "feature",
reason: "self-heal-forge-pr-status",
});
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
forge: expect.objectContaining({
forge: "gitlab-test",
pullRequest: expect.objectContaining({
title: "MR self-healed",
}),
}),
}),
);
subscription.unsubscribe();
} finally {
service.dispose();
unregister();
}
});
test("subscription cancels generic forge PR status self-heal polling after unsubscribe", async () => {
const forge = {
...createGitHubServiceStub(),
getCurrentPullRequestStatus: vi.fn(async () => createCurrentPullRequestStatus()),
};
const unregister = defaultForgeRegistry.register("gitea-test", {
createService: () => forge,
matchesHost: (host) => host === "forge-self-heal.test",
});
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) =>
createCheckoutFacts(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-self-heal.test/acme/repo.git",
pullRequestLookupTarget: { headRef: "feature" },
}),
);
const getCheckoutStatus = vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-self-heal.test/acme/repo.git",
}),
);
const service = createService({
getCheckoutSnapshotFacts,
getCheckoutStatus,
});
try {
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises();
subscription.unsubscribe();
await vi.advanceTimersByTimeAsync(60_000);
await flushPromises();
expect(forge.getCurrentPullRequestStatus).not.toHaveBeenCalled();
} finally {
service.dispose();
unregister();
}
});
test("subscription skips GitHub self-heal polling when the checkout has no GitHub remote", async () => {
const retainCurrentPullRequestStatusPoll = vi.fn(() => ({ unsubscribe: vi.fn() }));
const github = {
@@ -1755,7 +1993,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
service.dispose();
});
test("onWorkspaceStateMayHaveChanged preserves includeGitHub when a file watcher fires within the debounce window", async () => {
test("onWorkspaceStateMayHaveChanged preserves includeForge when a file watcher fires within the debounce window", async () => {
const github = createGitHubServiceStub();
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
@@ -3,7 +3,7 @@ import os from "node:os";
import path, { join } from "node:path";
import type { FSWatcher } from "node:fs";
import type pino from "pino";
import type { GitHubService } from "../services/github-service.js";
import type { ForgeService } from "../services/forge-service.js";
import type {
CheckoutSnapshotFacts,
CheckoutStatusGit,
@@ -30,7 +30,7 @@ function createSnapshot(
cwd: string,
overrides?: {
git?: Partial<WorkspaceGitRuntimeSnapshot["git"]>;
github?: Partial<WorkspaceGitRuntimeSnapshot["github"]>;
forge?: Partial<WorkspaceGitRuntimeSnapshot["forge"]>;
},
): WorkspaceGitRuntimeSnapshot {
const base: WorkspaceGitRuntimeSnapshot = {
@@ -50,7 +50,7 @@ function createSnapshot(
hasRemote: true,
diffStat: { additions: 1, deletions: 0 },
},
github: {
forge: {
featuresEnabled: true,
pullRequest: {
url: "https://github.com/acme/repo/pull/123",
@@ -64,27 +64,65 @@ function createSnapshot(
},
};
const featuresEnabled = overrides?.forge?.featuresEnabled ?? base.forge.featuresEnabled;
const authState =
overrides?.forge?.authState ?? (featuresEnabled ? "authenticated" : "no_remote");
const forgeName = resolveSnapshotForgeName(featuresEnabled, overrides);
return {
cwd,
git: {
...base.git,
...overrides?.git,
},
github: {
...base.github,
...overrides?.github,
pullRequest:
overrides?.github && "pullRequest" in overrides.github
? (overrides.github.pullRequest ?? null)
: base.github.pullRequest,
error:
overrides?.github && "error" in overrides.github
? (overrides.github.error ?? null)
: base.github.error,
forge: {
...base.forge,
...overrides?.forge,
featuresEnabled,
authState,
...(forgeName ? { forge: forgeName } : {}),
pullRequest: resolveSnapshotPullRequest(base, overrides),
error: resolveSnapshotError(base, overrides),
},
};
}
function hasForgeOverride(
overrides: { forge?: Partial<WorkspaceGitRuntimeSnapshot["forge"]> } | undefined,
key: keyof WorkspaceGitRuntimeSnapshot["forge"],
): boolean {
return Boolean(overrides?.forge && key in overrides.forge);
}
function resolveSnapshotForgeName(
featuresEnabled: boolean,
overrides: { forge?: Partial<WorkspaceGitRuntimeSnapshot["forge"]> } | undefined,
): string | undefined {
if (hasForgeOverride(overrides, "forge")) {
return overrides?.forge?.forge;
}
return featuresEnabled ? "github" : undefined;
}
function resolveSnapshotPullRequest(
base: WorkspaceGitRuntimeSnapshot,
overrides: { forge?: Partial<WorkspaceGitRuntimeSnapshot["forge"]> } | undefined,
): WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"] {
if (hasForgeOverride(overrides, "pullRequest")) {
return overrides?.forge?.pullRequest ?? null;
}
return base.forge.pullRequest;
}
function resolveSnapshotError(
base: WorkspaceGitRuntimeSnapshot,
overrides: { forge?: Partial<WorkspaceGitRuntimeSnapshot["forge"]> } | undefined,
): WorkspaceGitRuntimeSnapshot["forge"]["error"] {
if (hasForgeOverride(overrides, "error")) {
return overrides?.forge?.error ?? null;
}
return base.forge.error;
}
function createCheckoutStatus(
cwd: string,
overrides?: Partial<CheckoutStatusGit>,
@@ -137,6 +175,8 @@ function createPullRequestStatusResult(
headRefName: "feature",
isMerged: false,
},
authState: "authenticated",
featuresEnabled: true,
githubFeaturesEnabled: true,
...overrides,
};
@@ -173,11 +213,15 @@ function createDeferred<T>() {
return { promise, resolve, reject };
}
function createGitHubServiceStub(): GitHubService {
function createGitHubServiceStub(): ForgeService {
return {
listPullRequests: vi.fn(async () => []),
listIssues: vi.fn(async () => []),
searchIssuesAndPrs: vi.fn(async () => ({ items: [], githubFeaturesEnabled: true })),
searchIssuesAndPrs: vi.fn(async () => ({
items: [],
featuresEnabled: true,
githubFeaturesEnabled: true,
})),
getPullRequest: vi.fn(async () => ({
number: 1,
title: "PR",
@@ -189,6 +233,15 @@ function createGitHubServiceStub(): GitHubService {
labels: [],
})),
getPullRequestHeadRef: vi.fn(async () => "feature"),
getPullRequestCheckoutTarget: vi.fn(async ({ number }) => ({
number,
baseRefName: "main",
headRefName: "feature",
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
})),
getCurrentPullRequestStatus: vi.fn(async () => null),
createPullRequest: vi.fn(async () => ({
url: "https://github.com/acme/repo/pull/1",
@@ -196,6 +249,7 @@ function createGitHubServiceStub(): GitHubService {
})),
mergePullRequest: vi.fn(async () => ({ success: true })),
isAuthenticated: vi.fn(async () => true),
authProbeCanThrow: true,
invalidate: vi.fn(),
};
}
@@ -205,7 +259,7 @@ interface CreateServiceTestOptions {
getCheckoutSnapshotFacts?: ReturnType<typeof vi.fn>;
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
getPullRequestStatus?: ReturnType<typeof vi.fn>;
github?: GitHubService;
github?: ForgeService;
resolveAbsoluteGitDir?: ReturnType<typeof vi.fn>;
hasOriginRemote?: ReturnType<typeof vi.fn>;
runGitFetch?: ReturnType<typeof vi.fn>;
@@ -226,7 +280,7 @@ function buildDefaultTestServiceDeps() {
deletions: 0,
})),
getPullRequestStatus: vi.fn(async () => createPullRequestStatusResult()),
github: createGitHubServiceStub(),
forgeOverrides: { github: createGitHubServiceStub() },
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
hasOriginRemote: vi.fn(async () => false),
runGitFetch: vi.fn(async () => {}),
@@ -318,7 +372,7 @@ describe("WorkspaceGitServiceImpl", () => {
await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(
createSnapshot(REPO_CWD, {
github: {
forge: {
pullRequest: {
url: "https://github.com/acme/repo/pull/999",
title: "Ship runtime centralization",
@@ -335,6 +389,31 @@ describe("WorkspaceGitServiceImpl", () => {
service.dispose();
});
test("getSnapshot does not probe isAuthenticated for a forge adapter that never throws from it", async () => {
const gitlabIsAuthenticated = vi.fn(async () => false);
const gitlabStub: ForgeService = {
...createGitHubServiceStub(),
isAuthenticated: gitlabIsAuthenticated,
authProbeCanThrow: undefined,
};
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
const service = createService({
getCheckoutStatus: vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, { remoteUrl: "https://gitlab.com/acme/repo.git" }),
),
getPullRequestStatus,
forgeOverrides: { gitlab: gitlabStub },
});
await service.getSnapshot(REPO_CWD);
expect(gitlabIsAuthenticated).not.toHaveBeenCalled();
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
service.dispose();
});
test("getSnapshot keeps plain git classification when shortstat lookup fails", async () => {
const getCheckoutShortstat = vi.fn(async () => {
throw new Error(
@@ -562,7 +641,7 @@ describe("WorkspaceGitServiceImpl", () => {
const initialSnapshot = await service.getSnapshot(REPO_CWD);
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
expect(initialSnapshot.github.pullRequest?.title).toBe("Before refresh");
expect(initialSnapshot.forge.pullRequest?.title).toBe("Before refresh");
await service.getSnapshot(REPO_CWD, {
force: true,
@@ -574,7 +653,7 @@ describe("WorkspaceGitServiceImpl", () => {
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith(
createSnapshot(REPO_CWD, {
github: {
forge: {
pullRequest: {
url: "https://github.com/acme/repo/pull/123",
title: "After refresh",
@@ -654,7 +733,7 @@ describe("WorkspaceGitServiceImpl", () => {
aheadBehind: { ahead: 2, behind: 0 },
aheadOfOrigin: 2,
},
github: {
forge: {
featuresEnabled: false,
pullRequest: null,
},
@@ -5,6 +5,7 @@ import { LRUCache } from "lru-cache";
import pLimit from "p-limit";
import type pino from "pino";
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import type { CheckoutContext } from "../utils/checkout-git.js";
import {
type BranchCheckoutResolution,
@@ -17,21 +18,27 @@ import {
getCheckoutShortstat,
getCheckoutStatus,
getPullRequestStatus,
forgeAuthStateFromError,
hasOriginRemote,
listBranchSuggestions,
resolveRepositoryDefaultBranch,
resolveBranchCheckout,
resolveAbsoluteGitDir,
} from "../utils/checkout-git.js";
import type {
ForgeAuthState,
ForgeService,
ForgeSpecificStatusFacts,
PullRequestMergeable,
} from "../services/forge-service.js";
import { createForgeService } from "../services/forge-registry.js";
import {
createGitHubService,
type GitHubPullRequestStatusFacts,
type GitHubService,
type PullRequestMergeable,
} from "../services/github-service.js";
createForgeResolver,
type ForgeResolution,
type ForgeResolver,
} from "../services/forge-resolver.js";
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { resolveGitHubRemote, type GitHubRemoteIdentity } from "../utils/github-remote.js";
import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js";
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
import {
@@ -43,6 +50,7 @@ import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000;
const FORGE_PR_STATUS_POLL_INTERVAL_MS = 60_000;
const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000;
// Auxiliary reads may reuse cached values within this window; snapshots do not expire on read.
const WORKSPACE_GIT_AUXILIARY_READ_TTL_MS = 15_000;
@@ -78,12 +86,20 @@ export interface WorkspaceGitRuntimeSnapshot {
hasRemote: boolean;
diffStat: { additions: number; deletions: number } | null;
};
github: {
forge: {
featuresEnabled: boolean;
authState: ForgeAuthState;
/**
* Forge resolved for this workspace from its remote — including the per-host
* probe, so self-managed GitLab hosts (no "gitlab" in the name) are labeled
* correctly. The wire projection prefers this over the bare name heuristic.
*/
forge?: string;
pullRequest: {
number?: number;
repoOwner?: string;
repoName?: string;
projectPath?: string;
url: string;
title: string;
state: string;
@@ -101,7 +117,7 @@ export interface WorkspaceGitRuntimeSnapshot {
}>;
checksStatus?: "none" | "pending" | "success" | "failure";
reviewDecision?: "approved" | "changes_requested" | "pending" | null;
github?: GitHubPullRequestStatusFacts;
forgeSpecific?: ForgeSpecificStatusFacts;
} | null;
error: { message: string } | null;
};
@@ -120,6 +136,7 @@ export interface WorkspaceGitService {
cwd: string,
options?: WorkspaceGitSnapshotOptions,
): Promise<WorkspaceGitRuntimeSnapshot>;
resolveForge(cwd: string): Promise<ForgeResolution | null>;
getCheckoutDiff(
cwd: string,
options: CheckoutDiffCompare,
@@ -159,6 +176,7 @@ export interface WorkspaceGitService {
): Promise<{ repoRoot: string | null; unsubscribe: () => void }>;
scheduleRefreshForCwd(cwd: string): void;
onWorkspaceStateMayHaveChanged(cwd: string): void;
invalidateForge(cwd: string): void;
dispose(): void;
}
@@ -202,25 +220,25 @@ export type WorkspaceGitWorktreeInfo = PaseoWorktreeInfo;
export type WorkspaceGitSnapshotOptions =
| {
force?: false;
includeGitHub?: boolean;
includeForge?: boolean;
reason?: string;
}
| {
force: true;
includeGitHub?: boolean;
includeForge?: boolean;
reason: string;
};
interface WorkspaceGitRefreshRequest {
force: boolean;
includeGitHub: boolean;
includeForge: boolean;
reason: string;
notify: boolean;
}
interface ScheduledWorkspaceGitRefreshOptions {
force?: boolean;
includeGitHub?: boolean;
includeForge?: boolean;
reason?: string;
}
@@ -232,7 +250,7 @@ type WorkspaceGitRefreshState =
status: "in-flight";
promise: Promise<WorkspaceGitRuntimeSnapshot>;
force: boolean;
includeGitHub: boolean;
includeForge: boolean;
queued: WorkspaceGitRefreshRequest | null;
};
@@ -248,7 +266,12 @@ interface WorkspaceGitServiceDependencies {
resolveRepositoryDefaultBranch: typeof resolveRepositoryDefaultBranch;
listBranchSuggestions: typeof listBranchSuggestions;
listPaseoWorktrees: typeof listPaseoWorktrees;
github: GitHubService;
/**
* Adapter instances to bind by forge id instead of building from the registry
* — the injection seam for the daemon's shared GitHub adapter and for test
* fakes. Any forge not listed here is built (and cached once) by the registry.
*/
forgeOverrides?: Record<string, ForgeService>;
resolveAbsoluteGitDir: (cwd: string) => Promise<string | null>;
hasOriginRemote: (cwd: string) => Promise<boolean>;
runGitFetch: (cwd: string) => Promise<void>;
@@ -270,13 +293,13 @@ interface WorkspaceGitTarget {
debounceTimer: NodeJS.Timeout | null;
pendingDebounceRequest: WorkspaceGitRefreshRequest | null;
selfHealTimer: NodeJS.Timeout | null;
githubPollSubscription: { unsubscribe: () => void } | null;
githubPollKey: string | null;
forgePrStatusPollSubscription: { unsubscribe: () => void } | null;
forgePrStatusPollKey: string | null;
refreshState: WorkspaceGitRefreshState;
latestGit: WorkspaceGitRuntimeSnapshot["git"] | null;
latestGitLoadedAtMs: number | null;
latestGithub: WorkspaceGitRuntimeSnapshot["github"] | null;
latestGithubLoadedAtMs: number | null;
latestForge: WorkspaceGitRuntimeSnapshot["forge"] | null;
latestForgeLoadedAtMs: number | null;
latestSnapshot: WorkspaceGitRuntimeSnapshot | null;
latestSnapshotLoadedAtMs: number | null;
latestFacts: CheckoutSnapshotFacts | null;
@@ -285,7 +308,6 @@ interface WorkspaceGitTarget {
latestFingerprint: string | null;
lastShellOutAtMs: number | null;
repoGitRoot: string | null;
cachedGitHubRemote: { remoteUrl: string; identity: GitHubRemoteIdentity | null } | null;
observationSetupPromise: Promise<void> | null;
observationSetupComplete: boolean;
closed: boolean;
@@ -318,7 +340,7 @@ interface WorkspaceGitAuxiliaryReadCacheEntry<T> {
inFlight: Promise<T> | null;
}
interface WorkspaceGitHubPollTarget {
interface WorkspaceForgePrStatusPollTarget {
headRef: string;
headRepositoryOwner?: string;
}
@@ -336,7 +358,6 @@ function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies
resolveRepositoryDefaultBranch,
listBranchSuggestions,
listPaseoWorktrees,
github: createGitHubService(),
resolveAbsoluteGitDir,
hasOriginRemote,
runGitFetch,
@@ -356,6 +377,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private readonly paseoHome: string;
private readonly worktreesRoot: string | undefined;
private readonly deps: WorkspaceGitServiceDependencies;
private readonly forgeResolver: ForgeResolver;
private readonly snapshotUpdatedListeners = new Set<WorkspaceGitSnapshotUpdatedListener>();
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
private readonly repoTargets = new Map<string, RepoGitTarget>();
@@ -395,6 +417,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.paseoHome = options.paseoHome;
this.worktreesRoot = options.worktreesRoot;
this.deps = resolveWorkspaceGitServiceDeps(options.deps);
this.forgeResolver = createForgeResolver({
createService: (forge) => this.deps.forgeOverrides?.[forge] ?? createForgeService(forge),
});
}
resolveForge(cwd: string): Promise<ForgeResolution | null> {
return this.forgeResolver.resolve(resolve(cwd));
}
registerWorkspace(
@@ -655,7 +684,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const target = this.ensureWorkspaceTarget(cwd);
await this.refreshWorkspaceTarget(target, {
force: false,
includeGitHub: false,
includeForge: false,
reason: "refresh",
notify: true,
});
@@ -692,14 +721,23 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (!target || target.closed) {
return;
}
this.deps.github.invalidate({ cwd: normalizedCwd });
this.invalidateForge(normalizedCwd);
this.scheduleWorkspaceRefresh(target, {
force: true,
includeGitHub: true,
includeForge: true,
reason: "external-state-change",
});
}
/**
* Drop the resolved forge adapter's cached state for a cwd. Goes through the
* resolver so it targets the same adapter instance the poller reads — used by
* git mutations to force a fresh forge status on the next refresh.
*/
invalidateForge(cwd: string): void {
this.forgeResolver.invalidate(resolve(cwd));
}
dispose(): void {
for (const target of this.workspaceTargets.values()) {
this.closeWorkspaceTarget(target);
@@ -815,13 +853,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
debounceTimer: null,
pendingDebounceRequest: null,
selfHealTimer: null,
githubPollSubscription: null,
githubPollKey: null,
forgePrStatusPollSubscription: null,
forgePrStatusPollKey: null,
refreshState: { status: "idle" },
latestGit: null,
latestGitLoadedAtMs: null,
latestGithub: null,
latestGithubLoadedAtMs: null,
latestForge: null,
latestForgeLoadedAtMs: null,
latestSnapshot: null,
latestSnapshotLoadedAtMs: null,
latestFacts: null,
@@ -830,7 +868,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
latestFingerprint: null,
lastShellOutAtMs: null,
repoGitRoot: null,
cachedGitHubRemote: null,
observationSetupPromise: null,
observationSetupComplete: false,
closed: false,
@@ -847,7 +884,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
void this.refreshWorkspaceTarget(target, {
force: false,
includeGitHub: true,
includeForge: true,
reason: "initial",
notify: true,
});
@@ -1154,7 +1191,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.scheduleWorkspaceObservationSetup(target);
this.refreshWorkspaceTarget(target, {
force: false,
includeGitHub: false,
includeForge: false,
reason: "self-heal-git",
notify: true,
}).catch((error) => {
@@ -1166,67 +1203,162 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}, WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS);
}
this.updateGitHubPollForTarget(target);
this.updateForgePrStatusPollForTarget(target);
}
private updateGitHubPollForTarget(target: WorkspaceGitTarget): void {
private updateForgePrStatusPollForTarget(target: WorkspaceGitTarget): void {
if (target.listeners.size === 0) {
this.stopGitHubPollForTarget(target);
this.stopForgePrStatusPollForTarget(target);
return;
}
const git = target.latestGit;
if (!git || !this.deps.github.retainCurrentPullRequestStatusPoll) {
this.stopGitHubPollForTarget(target);
if (!git?.remoteUrl) {
this.stopForgePrStatusPollForTarget(target);
return;
}
const pollTarget = this.resolveGitHubPollTarget(target);
const resolution = this.forgeResolver.resolveFromRemoteUrl(git.remoteUrl);
if (!resolution) {
this.stopForgePrStatusPollForTarget(target);
return;
}
const pollTarget = this.resolveForgePrStatusPollTarget(target);
const remoteUrl = git.remoteUrl;
const hasGitHubRemote =
target.cachedGitHubRemote?.remoteUrl === remoteUrl &&
target.cachedGitHubRemote.identity !== null;
if (!pollTarget || remoteUrl === null || !hasGitHubRemote) {
this.stopGitHubPollForTarget(target);
if (!pollTarget) {
this.stopForgePrStatusPollForTarget(target);
return;
}
const pollKey = buildWorkspaceGitHubPollKey(remoteUrl, pollTarget);
if (target.githubPollKey === pollKey && target.githubPollSubscription) {
const pollKey = buildWorkspaceForgePrStatusPollKey({
forge: resolution.forge,
remoteUrl,
target: pollTarget,
});
if (target.forgePrStatusPollKey === pollKey && target.forgePrStatusPollSubscription) {
return;
}
this.stopGitHubPollForTarget(target);
target.githubPollKey = pollKey;
target.githubPollSubscription = this.deps.github.retainCurrentPullRequestStatusPoll({
cwd: target.cwd,
headRef: pollTarget.headRef,
...(pollTarget.headRepositoryOwner
? { headRepositoryOwner: pollTarget.headRepositoryOwner }
: {}),
onStatus: (status) => {
if (!this.isActiveObservedWorkspaceTarget(target)) {
return;
}
this.rememberGitHubSnapshot(target, buildGitHubSnapshotFromStatus(status), {
notify: true,
this.stopForgePrStatusPollForTarget(target);
target.forgePrStatusPollKey = pollKey;
if (resolution.service.retainCurrentPullRequestStatusPoll) {
target.forgePrStatusPollSubscription = resolution.service.retainCurrentPullRequestStatusPoll({
cwd: target.cwd,
headRef: pollTarget.headRef,
...(pollTarget.headRepositoryOwner
? { headRepositoryOwner: pollTarget.headRepositoryOwner }
: {}),
onStatus: (status) => {
if (!this.isActiveObservedWorkspaceTarget(target)) {
return;
}
this.rememberForgePrStatusSnapshot(
target,
buildForgeSnapshotFromStatus(status, resolution.forge),
{
notify: true,
},
);
},
onError: (error) => {
this.logger.warn(
{
err: error,
cwd: target.cwd,
forge: resolution.forge,
headRef: pollTarget.headRef,
headRepositoryOwner: pollTarget.headRepositoryOwner,
reason: "self-heal-forge-pr-status",
},
"Failed to run forge PR status self-heal refresh",
);
},
});
return;
}
target.forgePrStatusPollSubscription = this.retainGenericForgePrStatusPoll({
target,
forge: resolution.forge,
service: resolution.service,
pollTarget,
});
}
private retainGenericForgePrStatusPoll({
target,
forge,
service,
pollTarget,
}: {
target: WorkspaceGitTarget;
forge: string;
service: ForgeService;
pollTarget: WorkspaceForgePrStatusPollTarget;
}): { unsubscribe: () => void } {
let closed = false;
let timer: NodeJS.Timeout | null = null;
const schedule = () => {
if (closed) {
return;
}
timer = setTimeout(() => {
timer = null;
void poll();
}, FORGE_PR_STATUS_POLL_INTERVAL_MS);
};
const poll = async () => {
if (closed || !this.isActiveObservedWorkspaceTarget(target)) {
return;
}
try {
const status = await service.getCurrentPullRequestStatus({
cwd: target.cwd,
headRef: pollTarget.headRef,
...(pollTarget.headRepositoryOwner
? { headRepositoryOwner: pollTarget.headRepositoryOwner }
: {}),
reason: "self-heal-forge-pr-status",
});
},
onError: (error) => {
if (!closed && this.isActiveObservedWorkspaceTarget(target)) {
this.rememberForgePrStatusSnapshot(target, buildForgeSnapshotFromStatus(status, forge), {
notify: true,
});
}
} catch (error) {
this.logger.warn(
{
err: error,
cwd: target.cwd,
forge,
headRef: pollTarget.headRef,
headRepositoryOwner: pollTarget.headRepositoryOwner,
reason: "self-heal-github",
reason: "self-heal-forge-pr-status",
},
"Failed to run GitHub self-heal refresh",
"Failed to run forge PR status self-heal refresh",
);
} finally {
schedule();
}
};
schedule();
return {
unsubscribe: () => {
closed = true;
if (timer) {
clearTimeout(timer);
timer = null;
}
},
});
};
}
private resolveGitHubPollTarget(target: WorkspaceGitTarget): WorkspaceGitHubPollTarget | null {
private resolveForgePrStatusPollTarget(
target: WorkspaceGitTarget,
): WorkspaceForgePrStatusPollTarget | null {
const git = target.latestGit;
if (!git?.currentBranch) {
return null;
@@ -1243,10 +1375,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return { headRef: git.currentBranch };
}
private stopGitHubPollForTarget(target: WorkspaceGitTarget): void {
target.githubPollSubscription?.unsubscribe();
target.githubPollSubscription = null;
target.githubPollKey = null;
private stopForgePrStatusPollForTarget(target: WorkspaceGitTarget): void {
target.forgePrStatusPollSubscription?.unsubscribe();
target.forgePrStatusPollSubscription = null;
target.forgePrStatusPollKey = null;
}
private addWorkingTreeWatcher(
@@ -1478,7 +1610,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (target.refreshState.status === "in-flight") {
const needsForcedRefresh = request.force && !target.refreshState.force;
const needsGitHubRefresh =
request.force && request.includeGitHub && !target.refreshState.includeGitHub;
request.force && request.includeForge && !target.refreshState.includeForge;
if (needsForcedRefresh || needsGitHubRefresh) {
target.refreshState.queued = this.mergeRefreshRequests(target.refreshState.queued, request);
}
@@ -1499,7 +1631,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
status: "in-flight",
promise,
force: request.force,
includeGitHub: request.includeGitHub,
includeForge: request.includeForge,
queued: null,
};
@@ -1518,28 +1650,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const force = options?.force === true;
return {
force,
includeGitHub: options?.includeGitHub ?? true,
includeForge: options?.includeForge ?? true,
reason: options?.reason ?? defaultReason,
notify,
};
}
private async resolveGitHubRemoteForTarget(
target: WorkspaceGitTarget,
remoteUrl: string | null,
): Promise<GitHubRemoteIdentity | null> {
if (!remoteUrl) {
target.cachedGitHubRemote = null;
return null;
}
if (target.cachedGitHubRemote?.remoteUrl === remoteUrl) {
return target.cachedGitHubRemote.identity;
}
const identity = await resolveGitHubRemote({ remoteUrl });
target.cachedGitHubRemote = { remoteUrl, identity };
return identity;
}
private shouldThrottleNonForcedRefresh(
target: WorkspaceGitTarget,
): target is WorkspaceGitTarget & {
@@ -1557,7 +1673,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
): WorkspaceGitRefreshRequest {
return {
force: options?.force === true,
includeGitHub: options?.includeGitHub ?? false,
includeForge: options?.includeForge ?? false,
reason: options?.reason ?? "watch",
notify: true,
};
@@ -1573,11 +1689,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const force = pending.force || request.force;
const upgradesForce = request.force && !pending.force;
const upgradesGitHub = request.includeGitHub && !pending.includeGitHub;
const upgradesForge = request.includeForge && !pending.includeForge;
return {
force,
includeGitHub: pending.includeGitHub || request.includeGitHub,
reason: upgradesForce || upgradesGitHub ? request.reason : pending.reason,
includeForge: pending.includeForge || request.includeForge,
reason: upgradesForce || upgradesForge ? request.reason : pending.reason,
notify: pending.notify || request.notify,
};
}
@@ -1604,7 +1720,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
request = state.queued;
state.queued = null;
state.force = request.force;
state.includeGitHub = request.includeGitHub;
state.includeForge = request.includeForge;
}
return snapshot;
@@ -1615,8 +1731,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
request: WorkspaceGitRefreshRequest,
): Promise<WorkspaceGitRuntimeSnapshot> {
const facts = await this.refreshGitSnapshot(target, request);
if (request.includeGitHub) {
await this.refreshGitHubSnapshot(target, request, facts);
if (request.includeForge) {
await this.refreshForgeSnapshot(target, request, facts);
}
const snapshot = this.combineSnapshot(target);
@@ -1632,7 +1748,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target.lastShellOutAtMs = now.getTime();
const cwd = target.cwd;
const previousGitHubPollKey = this.getGitHubPollKey(target);
const previousForgePrStatusPollKey = this.getForgePrStatusPollKey(target);
const baseContext: CheckoutContext = {
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
@@ -1647,13 +1763,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (!checkoutStatus.isGit) {
target.latestGit = buildNotGitSnapshot(cwd).git;
target.latestGitLoadedAtMs = this.deps.now().getTime();
target.cachedGitHubRemote = null;
target.latestGithub = buildGitHubUnavailableSnapshot();
target.latestGithubLoadedAtMs = target.latestGitLoadedAtMs;
target.latestForge = buildForgeUnavailableSnapshot();
target.latestForgeLoadedAtMs = target.latestGitLoadedAtMs;
return facts;
}
await this.resolveGitHubRemoteForTarget(target, checkoutStatus.remoteUrl);
const diffStat = await this.deps
.getCheckoutShortstat(cwd, context, { force: request.force })
.catch(() => null);
@@ -1675,34 +1789,48 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
};
target.latestGitLoadedAtMs = this.deps.now().getTime();
if (previousGitHubPollKey !== this.getGitHubPollKey(target)) {
target.latestGithub = buildGitHubUnavailableSnapshot();
target.latestGithubLoadedAtMs = target.latestGitLoadedAtMs;
if (previousForgePrStatusPollKey !== this.getForgePrStatusPollKey(target)) {
target.latestForge = buildForgeUnavailableSnapshot();
target.latestForgeLoadedAtMs = target.latestGitLoadedAtMs;
}
return facts;
}
private async refreshGitHubSnapshot(
private async refreshForgeSnapshot(
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
facts: CheckoutSnapshotFacts,
): Promise<void> {
const githubRemote = target.cachedGitHubRemote?.identity ?? null;
const forceGitHub = request.force && request.includeGitHub;
if (forceGitHub) {
this.deps.github.invalidate({ cwd: target.cwd });
const remoteUrl = target.latestGit?.remoteUrl ?? null;
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) {
target.latestForge = buildUnresolvedRemoteForgeSnapshot(remoteUrl);
target.latestForgeLoadedAtMs = this.deps.now().getTime();
return;
}
const forgeService: ForgeService = resolution.service;
const forceForge = request.force && request.includeForge;
if (forceForge) {
forgeService.invalidate({ cwd: target.cwd });
}
target.latestGithub = await loadGitHubSnapshot({
const forgeSnapshot = await loadForgeSnapshot({
cwd: target.cwd,
githubRemote,
forgeService,
now: this.deps.now(),
deps: this.deps,
force: forceGitHub,
force: forceForge,
reason: request.reason,
facts,
});
target.latestGithubLoadedAtMs = this.deps.now().getTime();
// 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 };
target.latestForgeLoadedAtMs = this.deps.now().getTime();
}
private combineSnapshot(target: WorkspaceGitTarget): WorkspaceGitRuntimeSnapshot {
@@ -1713,40 +1841,44 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return {
cwd: target.cwd,
git: target.latestGit,
github: target.latestGithub ?? buildGitHubUnavailableSnapshot(),
forge: target.latestForge ?? buildForgeUnavailableSnapshot(),
};
}
private getGitHubPollKey(target: WorkspaceGitTarget): string | null {
private getForgePrStatusPollKey(target: WorkspaceGitTarget): string | null {
const git = target.latestGit;
if (!git?.currentBranch || !git.remoteUrl) {
return null;
}
const githubRemote = target.cachedGitHubRemote;
if (!githubRemote || githubRemote.remoteUrl !== git.remoteUrl || !githubRemote.identity) {
const resolution = this.forgeResolver.resolveFromRemoteUrl(git.remoteUrl);
if (!resolution) {
return null;
}
const pollTarget = this.resolveGitHubPollTarget(target);
const pollTarget = this.resolveForgePrStatusPollTarget(target);
if (!pollTarget) {
return null;
}
return buildWorkspaceGitHubPollKey(git.remoteUrl, pollTarget);
return buildWorkspaceForgePrStatusPollKey({
forge: resolution.forge,
remoteUrl: git.remoteUrl,
target: pollTarget,
});
}
private rememberGitHubSnapshot(
private rememberForgePrStatusSnapshot(
target: WorkspaceGitTarget,
github: WorkspaceGitRuntimeSnapshot["github"],
github: WorkspaceGitRuntimeSnapshot["forge"],
options?: { notify?: boolean },
): void {
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return;
}
target.latestGithub = github;
target.latestGithubLoadedAtMs = this.deps.now().getTime();
target.latestForge = github;
target.latestForgeLoadedAtMs = this.deps.now().getTime();
this.rememberSnapshot(target, this.combineSnapshot(target), {
notify: options?.notify,
forceEmit: false,
@@ -1760,7 +1892,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
): void {
target.latestSnapshot = snapshot;
if (target.listeners.size > 0) {
this.updateGitHubPollForTarget(target);
this.updateForgePrStatusPollForTarget(target);
}
const fingerprint = JSON.stringify(snapshot);
const fingerprintMatches = target.latestFingerprint === fingerprint;
@@ -1814,7 +1946,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
await this.refreshWorkspaceTarget(workspaceTarget, {
force: false,
includeGitHub: false,
includeForge: false,
reason: "repo-fetch",
notify: true,
});
@@ -1876,7 +2008,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
clearInterval(target.selfHealTimer);
target.selfHealTimer = null;
}
this.stopGitHubPollForTarget(target);
this.stopForgePrStatusPollForTarget(target);
for (const watcher of target.watchers) {
watcher.close();
@@ -1911,59 +2043,65 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
}
async function loadGitHubSnapshot(options: {
async function loadForgeSnapshot(options: {
cwd: string;
githubRemote: GitHubRemoteIdentity | null;
forgeService: ForgeService | null;
now: Date;
deps: Pick<WorkspaceGitServiceDependencies, "getPullRequestStatus" | "github">;
deps: Pick<WorkspaceGitServiceDependencies, "getPullRequestStatus">;
force?: boolean;
reason?: string;
facts?: CheckoutSnapshotFacts;
}): Promise<WorkspaceGitRuntimeSnapshot["github"]> {
if (!options.githubRemote) {
return {
featuresEnabled: false,
pullRequest: null,
error: null,
};
}): Promise<WorkspaceGitRuntimeSnapshot["forge"]> {
const forgeService = options.forgeService;
if (!forgeService) {
return buildForgeSnapshot("no_remote", null, null);
}
try {
await options.deps.github.isAuthenticated({ cwd: options.cwd });
} catch {
return {
featuresEnabled: false,
pullRequest: null,
error: null,
};
// GitHub's isAuthenticated throws the precise CLI-missing / auth error; GitLab's
// and Gitea's return false without throwing (the precise kind surfaces from
// the PR-status lookup below instead), so probing them here can't change the
// outcome and would just be a wasted CLI spawn on every refresh.
if (forgeService.authProbeCanThrow) {
try {
await forgeService.isAuthenticated({ cwd: options.cwd });
} catch (error) {
return buildForgeSnapshot(forgeAuthStateFromError(error), null, null);
}
}
try {
const result = await options.deps.getPullRequestStatus(
options.cwd,
options.deps.github,
forgeService,
{
force: options.force,
reason: options.reason,
},
{ facts: options.facts },
);
return {
featuresEnabled: true,
pullRequest: result.status,
error: null,
};
return buildForgeSnapshot(result.authState, result.status, null);
} catch (error) {
return {
featuresEnabled: true,
pullRequest: null,
error: {
message: error instanceof Error ? error.message : String(error),
},
};
// The auth probe succeeded, so a failure here is a command error, not an
// auth problem — surface it as an error while keeping features enabled.
return buildForgeSnapshot("authenticated", null, {
message: error instanceof Error ? error.message : String(error),
});
}
}
function buildForgeSnapshot(
authState: ForgeAuthState,
pullRequest: WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"],
error: WorkspaceGitRuntimeSnapshot["forge"]["error"],
): WorkspaceGitRuntimeSnapshot["forge"] {
return {
featuresEnabled: authState === "authenticated",
authState,
pullRequest,
error,
};
}
function parseWorkspaceGitStashList(
stdout: string,
options: { paseoOnly: boolean },
@@ -2018,30 +2156,51 @@ function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
hasRemote: false,
diffStat: null,
},
github: buildGitHubUnavailableSnapshot(),
forge: buildForgeUnavailableSnapshot(),
};
}
function buildGitHubUnavailableSnapshot(): WorkspaceGitRuntimeSnapshot["github"] {
return {
featuresEnabled: false,
pullRequest: null,
error: null,
};
function buildForgeUnavailableSnapshot(): WorkspaceGitRuntimeSnapshot["forge"] {
return buildForgeSnapshot("no_remote", null, null);
}
function buildGitHubSnapshotFromStatus(
status: WorkspaceGitRuntimeSnapshot["github"]["pullRequest"],
): WorkspaceGitRuntimeSnapshot["github"] {
return {
featuresEnabled: true,
pullRequest: status,
error: null,
};
/**
* Snapshot for a remote whose host matched no registered forge and no
* CLI-authenticated host. Deliberate choice: expose the hostname as the open
* `forge` id with `authState: "unauthenticated"`, because a self-hosted
* GitLab/Gitea becomes resolvable the moment its CLI is authenticated for
* that host — so "authenticate" is the actionable next step. The trade-off:
* a genuinely unsupported host (e.g. Bitbucket) also reads as a login
* problem; clients that want to distinguish can check the id against the
* forge registry.
*/
function buildUnresolvedRemoteForgeSnapshot(
remoteUrl: string | null,
): WorkspaceGitRuntimeSnapshot["forge"] {
const host = remoteUrl ? parseGitRemoteLocation(remoteUrl)?.host : null;
if (!host) {
return buildForgeUnavailableSnapshot();
}
return { ...buildForgeSnapshot("unauthenticated", null, null), forge: host };
}
function buildWorkspaceGitHubPollKey(remoteUrl: string, target: WorkspaceGitHubPollTarget): string {
return JSON.stringify([remoteUrl, target.headRef, target.headRepositoryOwner ?? null]);
function buildForgeSnapshotFromStatus(
status: WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"],
forge: string,
): WorkspaceGitRuntimeSnapshot["forge"] {
return { ...buildForgeSnapshot("authenticated", status, null), forge };
}
function buildWorkspaceForgePrStatusPollKey({
forge,
remoteUrl,
target,
}: {
forge: string;
remoteUrl: string;
target: WorkspaceForgePrStatusPollTarget;
}): string {
return JSON.stringify([forge, remoteUrl, target.headRef, target.headRepositoryOwner ?? null]);
}
async function runGitFetch(cwd: string): Promise<void> {
@@ -13,17 +13,30 @@ import {
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test, afterEach } from "vitest";
import type { GitHubService } from "../services/github-service.js";
import { getCheckoutStatus, pushCurrentBranch } from "../utils/checkout-git.js";
import type { ForgeService } from "../services/forge-service.js";
import { createGitLabService } from "../services/gitlab-service.js";
import {
getCheckoutSnapshotFacts,
getCheckoutStatus,
pushCurrentBranch,
} from "../utils/checkout-git.js";
import {
getPaseoWorktreeMetadataPath,
readPaseoWorktreeMetadata,
} from "../utils/worktree-metadata.js";
import { UnknownBranchError } from "../utils/worktree.js";
import { createWorktreeCore as createCoreWorktree } from "./worktree-core.js";
import { isPlatform } from "../test-utils/platform.js";
function createGitHubServiceStub(): GitHubService {
function createGitHubServiceStub(): ForgeService {
return {
listPullRequests: async () => [],
listIssues: async () => [],
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
searchIssuesAndPrs: async () => ({
items: [],
featuresEnabled: true,
githubFeaturesEnabled: true,
}),
getPullRequest: async ({ number }) => ({
number,
title: `PR ${number}`,
@@ -35,6 +48,25 @@ function createGitHubServiceStub(): GitHubService {
labels: [],
}),
getPullRequestHeadRef: async ({ number }) => `pr-${number}`,
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: `pr-${number}`,
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
}),
defaultCheckoutRefs: ({ changeRequestNumber }) => [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
],
buildPrLocalBranchName: ({ headRef, checkoutTarget }) => {
const normalized = checkoutTarget.headOwnerLogin?.trim().toLowerCase() ?? "";
const owner =
checkoutTarget.isCrossRepository && /^[a-z0-9-]+$/.test(normalized) ? normalized : null;
return owner ? `${owner}/${headRef}` : headRef;
},
supportsCrossRepoCheckoutWithoutRefs: true,
getCurrentPullRequestStatus: async () => null,
createPullRequest: async () => ({
number: 1,
@@ -46,11 +78,22 @@ function createGitHubServiceStub(): GitHubService {
};
}
function createCoreDeps(options?: { github?: GitHubService }) {
function createCoreDeps(options?: {
github?: ForgeService;
forge?: { forge: string; service: ForgeService };
}) {
return {
github: options?.github ?? createGitHubServiceStub(),
workspaceGitService: {
resolveRepoRoot: async (cwd: string) => cwd,
resolveForge: async () =>
options?.forge
? {
forge: options.forge.forge,
host: `${options.forge.forge}.example.com`,
service: options.forge.service,
}
: null,
},
resolveDefaultBranch: async () => "main",
};
@@ -162,6 +205,120 @@ function createSameRepoGitHubPrRemoteRepo(): {
return { tempDir, repoDir, remoteDir, paseoHome };
}
function createGitRepoWithOriginFeatureBranch(): {
tempDir: string;
repoDir: string;
paseoHome: string;
} {
const { tempDir, repoDir, paseoHome } = createGitRepo();
const featureBranch = "feature/gitlab-mr";
execFileSync("git", ["checkout", "-b", featureBranch], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "gitlab mr branch\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "gitlab mr branch"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
const remoteDir = path.join(tempDir, "origin.git");
execFileSync("git", ["clone", "--bare", repoDir, remoteDir], { stdio: "pipe" });
execFileSync("git", ["branch", "-D", featureBranch], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["fetch", "origin"], { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir, paseoHome };
}
function createGitLabMrRefOnlyRemoteRepo(): {
tempDir: string;
repoDir: string;
paseoHome: string;
} {
const { tempDir, repoDir, paseoHome } = createGitRepo();
const featureBranch = "feature/gitlab-mr";
execFileSync("git", ["checkout", "-b", featureBranch], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "gitlab mr branch\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "gitlab mr branch"], {
cwd: repoDir,
stdio: "pipe",
});
const mrHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, stdio: "pipe" })
.toString()
.trim();
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
const remoteDir = path.join(tempDir, "origin.git");
execFileSync("git", ["clone", "--bare", repoDir, remoteDir], { stdio: "pipe" });
execFileSync(
"git",
[`--git-dir=${remoteDir}`, "update-ref", "refs/merge-requests/14/head", mrHead],
{ stdio: "pipe" },
);
execFileSync(
"git",
[`--git-dir=${remoteDir}`, "update-ref", "-d", `refs/heads/${featureBranch}`],
{
stdio: "pipe",
},
);
execFileSync("git", ["branch", "-D", featureBranch], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["fetch", "origin"], { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir, paseoHome };
}
function createGitLabMrWithConflictingOriginBranchRepo(): {
tempDir: string;
repoDir: string;
paseoHome: string;
} {
const { tempDir, repoDir, paseoHome } = createGitRepo();
const featureBranch = "feature/gitlab-mr";
execFileSync("git", ["checkout", "-b", featureBranch], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "target repo branch with same name\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync(
"git",
["-c", "commit.gpgsign=false", "commit", "-m", "target branch with same name"],
{
cwd: repoDir,
stdio: "pipe",
},
);
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["checkout", "-b", "mr-head"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "authoritative merge request head\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "merge request head"], {
cwd: repoDir,
stdio: "pipe",
});
const mrHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, stdio: "pipe" })
.toString()
.trim();
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
const remoteDir = path.join(tempDir, "origin.git");
execFileSync("git", ["clone", "--bare", repoDir, remoteDir], { stdio: "pipe" });
execFileSync(
"git",
[`--git-dir=${remoteDir}`, "update-ref", "refs/merge-requests/14/head", mrHead],
{ stdio: "pipe" },
);
execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "-d", "refs/heads/mr-head"], {
stdio: "pipe",
});
execFileSync("git", ["branch", "-D", featureBranch], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-D", "mr-head"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["fetch", "origin"], { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir, paseoHome };
}
function createForkGitHubPrRemoteRepo(): {
tempDir: string;
repoDir: string;
@@ -221,6 +378,14 @@ function getBranchUpstream(cwd: string): string | null {
return result.status === 0 ? result.stdout.trim() : null;
}
function getGitConfigValue(cwd: string, key: string): string | null {
const result = spawnSync("git", ["config", "--get", key], {
cwd,
encoding: "utf8",
});
return result.status === 0 ? result.stdout.trim() : null;
}
describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
describe("createWorktreeCore", () => {
const cleanupPaths: string[] = [];
@@ -316,10 +481,13 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
);
expect(result.intent).toEqual({
kind: "checkout-github-pr",
githubPrNumber: 123,
kind: "checkout-change-request",
forge: "github",
changeRequestNumber: 123,
headRef: "feature/review-pr",
baseRefName: "main",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/123/head" }],
trackOriginHead: true,
});
expect(result.worktree.branchName).toBe("feature/review-pr");
});
@@ -442,14 +610,237 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
);
expect(result.intent).toEqual({
kind: "checkout-github-pr",
githubPrNumber: 123,
kind: "checkout-change-request",
forge: "github",
changeRequestNumber: 123,
headRef: "pr-123",
baseRefName: "main",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/123/head" }],
trackOriginHead: true,
});
expect(result.worktree.branchName).toBe("pr-123");
});
test("checks out a GitLab MR source branch through the resolved forge service", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepoWithOriginFeatureBranch();
cleanupPaths.push(tempDir);
const gitlab: ForgeService = {
...createGitHubServiceStub(),
// A non-GitHub forge does not expose GitHub's refs/pull capabilities;
// the shell falls back to fetching the head branch under refs/heads.
defaultCheckoutRefs: undefined,
buildPrLocalBranchName: undefined,
supportsCrossRepoCheckoutWithoutRefs: false,
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: "feature/gitlab-mr",
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
}),
};
const result = await createCoreWorktree(
{
cwd: repoDir,
action: "checkout",
githubPrNumber: 14,
paseoHome,
runSetup: false,
},
createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }),
);
const branch = execFileSync("git", ["branch", "--show-current"], {
cwd: result.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
expect(result.intent).toEqual({
kind: "checkout-change-request",
forge: "gitlab",
changeRequestNumber: 14,
headRef: "feature/gitlab-mr",
baseRefName: "main",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/heads/feature/gitlab-mr" }],
trackOriginHead: true,
});
expect(branch).toBe("feature/gitlab-mr");
expect(readFileSync(path.join(result.worktree.worktreePath, "README.md"), "utf8")).toBe(
"gitlab mr branch\n",
);
});
test("checks out a GitLab MR ref when the source branch is gone", async () => {
const { tempDir, repoDir, paseoHome } = createGitLabMrRefOnlyRemoteRepo();
cleanupPaths.push(tempDir);
const gitlab: ForgeService = {
...createGitHubServiceStub(),
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: "feature/gitlab-mr",
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/merge-requests/14/head" },
{ remoteName: "origin", remoteRef: "refs/heads/feature/gitlab-mr" },
],
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
}),
};
const result = await createCoreWorktree(
{
cwd: repoDir,
action: "checkout",
githubPrNumber: 14,
paseoHome,
runSetup: false,
},
createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }),
);
expect(result.intent).toMatchObject({
kind: "checkout-change-request",
forge: "gitlab",
changeRequestNumber: 14,
headRef: "feature/gitlab-mr",
});
expect(readFileSync(path.join(result.worktree.worktreePath, "README.md"), "utf8")).toBe(
"gitlab mr branch\n",
);
expect(getBranchUpstream(result.worktree.worktreePath)).toBeNull();
});
test("checks out the authoritative GitLab MR ref before a same-named origin branch", async () => {
const { tempDir, repoDir, paseoHome } = createGitLabMrWithConflictingOriginBranchRepo();
cleanupPaths.push(tempDir);
const gitlab = createGitLabService({
resolveGlabPath: async () => "/usr/bin/glab",
resolveRemoteUrl: async () => "git@gitlab.example.com:example-group/example-project.git",
runner: async (args) => {
if (args[0] === "mr" && args[1] === "view") {
return {
stdout: JSON.stringify({
iid: 14,
title: "Review actual merge request head",
web_url:
"https://gitlab.example.com/example-group/example-project/-/merge_requests/14",
state: "opened",
source_branch: "feature/gitlab-mr",
target_branch: "main",
source_project_id: 202,
target_project_id: 101,
}),
stderr: "",
};
}
throw new Error(`unexpected glab args: ${args.join(" ")}`);
},
});
const result = await createCoreWorktree(
{
cwd: repoDir,
action: "checkout",
githubPrNumber: 14,
paseoHome,
runSetup: false,
},
createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }),
);
expect(result.intent).toMatchObject({
kind: "checkout-change-request",
forge: "gitlab",
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/merge-requests/14/head" },
{ remoteName: "origin", remoteRef: "refs/heads/feature/gitlab-mr" },
],
});
expect(readFileSync(path.join(result.worktree.worktreePath, "README.md"), "utf8")).toBe(
"authoritative merge request head\n",
);
});
test("does not add a fallback push remote for a cross-repo MR without a push URL", async () => {
const { tempDir, repoDir, paseoHome } = createGitLabMrRefOnlyRemoteRepo();
cleanupPaths.push(tempDir);
const gitlab: ForgeService = {
...createGitHubServiceStub(),
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: "feature/gitlab-mr",
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/merge-requests/14/head" },
{ remoteName: "origin", remoteRef: "refs/heads/feature/gitlab-mr" },
],
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: true,
}),
};
await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "first-gitlab-mr",
action: "checkout",
githubPrNumber: 14,
paseoHome,
runSetup: false,
},
createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }),
);
const second = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "second-gitlab-mr",
action: "checkout",
githubPrNumber: 14,
paseoHome,
runSetup: false,
},
createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }),
);
const pushRemote = getGitConfigValue(
second.worktree.worktreePath,
`branch.${second.worktree.branchName}.pushRemote`,
);
const pushRefspec = getGitConfigValue(
second.worktree.worktreePath,
"remote.paseo-pr-14.push",
);
const pushRemoteUrl = getGitConfigValue(
second.worktree.worktreePath,
"remote.paseo-pr-14.url",
);
const metadata = readPaseoWorktreeMetadata(second.worktree.worktreePath);
const facts = await getCheckoutSnapshotFacts(second.worktree.worktreePath, { paseoHome });
expect(second.worktree.branchName).toBe("feature/gitlab-mr-1");
expect(pushRemote).toBeNull();
expect(pushRefspec).toBeNull();
expect(pushRemoteUrl).toBeNull();
expect(metadata?.changeRequestLookupTarget).toEqual({
headRef: "feature/gitlab-mr",
changeRequestNumber: 14,
});
expect(facts).toMatchObject({
isGit: true,
currentBranch: "feature/gitlab-mr-1",
pullRequestLookupTarget: { headRef: "feature/gitlab-mr" },
});
});
test("checks out a same-repo GitHub PR with valid origin tracking", async () => {
const { tempDir, repoDir, remoteDir, paseoHome } = createSameRepoGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
@@ -635,6 +1026,10 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
);
expect(second.worktree.branchName).toBe("daemon-shutdown-diagnostics-1");
expect(second.intent).toMatchObject({
kind: "checkout-change-request",
trackOriginHead: true,
});
expect(getBranchUpstream(second.worktree.worktreePath)).toBe(
"origin/daemon-shutdown-diagnostics",
);
@@ -645,6 +1040,68 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
expect(dedupedRemoteBranch.status).toBe(1);
});
test("derives a deduped PR lookup target from git config when metadata has no target", async () => {
const { tempDir, repoDir, remoteDir, paseoHome } = createSameRepoGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
execFileSync("git", ["remote", "set-url", "origin", `file://${remoteDir}`], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["remote", "set-url", "--push", "origin", remoteDir], {
cwd: repoDir,
stdio: "pipe",
});
const github = {
...createGitHubServiceStub(),
getPullRequestCheckoutTarget: async () => ({
number: 1790,
baseRefName: "main",
headRefName: "daemon-shutdown-diagnostics",
headOwnerLogin: "getpaseo",
headRepositorySshUrl: remoteDir,
headRepositoryUrl: remoteDir,
isCrossRepository: false,
}),
};
await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "first-pr-worktree",
action: "checkout",
githubPrNumber: 1790,
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
const second = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "second-pr-worktree",
action: "checkout",
githubPrNumber: 1790,
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
writeFileSync(
getPaseoWorktreeMetadataPath(second.worktree.worktreePath),
`${JSON.stringify({ version: 1, baseRefName: "main" }, null, 2)}\n`,
"utf8",
);
const facts = await getCheckoutSnapshotFacts(second.worktree.worktreePath, { paseoHome });
expect(second.worktree.branchName).toBe("daemon-shutdown-diagnostics-1");
expect(facts).toMatchObject({
isGit: true,
currentBranch: "daemon-shutdown-diagnostics-1",
pullRequestLookupTarget: { headRef: "daemon-shutdown-diagnostics" },
});
});
test("checks out a fork PR whose head branch collides with local main", async () => {
const { tempDir, repoDir, headRemoteDir, paseoHome } = createForkGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
@@ -722,10 +1179,13 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
expect(sourceBranch).toBe("main");
expect(result.intent).toEqual({
kind: "checkout-github-pr",
githubPrNumber: 526,
kind: "checkout-change-request",
forge: "github",
changeRequestNumber: 526,
headRef: "main",
headRepositoryOwner: "therainisme",
baseRefName: "main",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/526/head" }],
localBranchName: "therainisme/main",
pushRemoteUrl: headRemoteDir,
});
@@ -972,12 +1432,22 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
expect(second.worktree).toEqual(first.worktree);
});
test("uses an injectable GitHubService dependency for missing PR head refs", async () => {
test("uses an injectable ForgeService dependency for missing PR head refs", async () => {
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const headRefLookups: Array<{ cwd: string; number: number }> = [];
const github: GitHubService = {
const github: ForgeService = {
...createGitHubServiceStub(),
// No head ref on the checkout target forces the service lookup below.
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: "",
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
}),
getPullRequestHeadRef: async ({ cwd, number }) => {
headRefLookups.push({ cwd, number });
return "feature/from-service";
@@ -997,10 +1467,13 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
expect(headRefLookups).toEqual([{ cwd: repoDir, number: 123 }]);
expect(result.intent).toEqual({
kind: "checkout-github-pr",
githubPrNumber: 123,
kind: "checkout-change-request",
forge: "github",
changeRequestNumber: 123,
headRef: "feature/from-service",
baseRefName: "main",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/123/head" }],
trackOriginHead: true,
});
expect(result.worktree.branchName).toBe("feature/from-service");
});
+32 -6
View File
@@ -1,6 +1,6 @@
import { createNameId } from "mnemonic-id";
import type { GitHubService } from "../services/github-service.js";
import type { ForgeService } from "../services/forge-service.js";
import {
createWorktree,
resolveExistingWorktreeForSlug,
@@ -11,9 +11,10 @@ import {
import {
resolveWorktreeCreationIntent,
type ResolveWorktreeCreationIntentInput,
UnsupportedForgeCheckoutTargetError,
type WorktreeCreationIntent,
} from "./resolve-worktree-creation-intent.js";
import type { FirstAgentContext } from "@getpaseo/protocol/messages";
import type { ChangeRequestCheckoutSource, FirstAgentContext } from "@getpaseo/protocol/messages";
import type { WorkspaceGitService } from "./workspace-git-service.js";
export interface CreateWorktreeCoreInput {
@@ -22,6 +23,7 @@ export interface CreateWorktreeCoreInput {
branchName?: string;
refName?: string;
action?: "branch-off" | "checkout";
checkoutSource?: ChangeRequestCheckoutSource;
githubPrNumber?: number;
firstAgentContext?: FirstAgentContext;
paseoHome?: string;
@@ -30,8 +32,11 @@ export interface CreateWorktreeCoreInput {
}
export interface CreateWorktreeCoreDeps {
github: GitHubService;
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot" | "resolveDefaultBranch">;
github: ForgeService;
workspaceGitService?: Pick<
WorkspaceGitService,
"resolveRepoRoot" | "resolveDefaultBranch" | "resolveForge"
>;
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
}
@@ -59,11 +64,13 @@ export async function createWorktreeCore(
intentInput = {
action: "checkout",
refName: input.refName,
checkoutSource: input.checkoutSource,
githubPrNumber: input.githubPrNumber,
worktreeSlug: requestedWorktreeSlug,
};
} else if (input.githubPrNumber !== undefined) {
} else if (input.checkoutSource !== undefined || input.githubPrNumber !== undefined) {
intentInput = {
checkoutSource: input.checkoutSource,
githubPrNumber: input.githubPrNumber,
refName: input.refName,
worktreeSlug: requestedWorktreeSlug,
@@ -78,8 +85,10 @@ export async function createWorktreeCore(
};
}
const forge = await resolveForge(repoRoot, deps, intentInput);
const intent = await resolveWorktreeCreationIntent(intentInput, repoRoot, {
...deps,
forge: forge.forge,
forgeService: forge.service,
resolveDefaultBranch: (root) => resolveDefaultBranch(root, deps),
});
let normalizedSlug: string;
@@ -93,6 +102,7 @@ export async function createWorktreeCore(
normalizedSlug = requestedWorktreeSlug ?? normalizeWorktreeSlug(intent.branchName);
break;
}
case "checkout-change-request":
case "checkout-github-pr": {
normalizedSlug =
requestedWorktreeSlug ?? normalizeWorktreeSlug(intent.localBranchName ?? intent.headRef);
@@ -125,6 +135,22 @@ export async function createWorktreeCore(
};
}
async function resolveForge(
repoRoot: string,
deps: CreateWorktreeCoreDeps,
intentInput: ResolveWorktreeCreationIntentInput,
): Promise<{ forge: string; service: ForgeService }> {
const resolution = await deps.workspaceGitService?.resolveForge(repoRoot);
if (!resolution) {
if (intentInput.checkoutSource?.forge && intentInput.checkoutSource.forge !== "github") {
throw new UnsupportedForgeCheckoutTargetError(intentInput.checkoutSource.forge);
}
// No recognized remote: fall back to GitHub, the wire-default forge.
return { forge: "github", service: deps.github };
}
return { forge: resolution.forge, service: resolution.service };
}
async function resolveDefaultBranch(
repoRoot: string,
deps: CreateWorktreeCoreDeps,
@@ -33,7 +33,7 @@ import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
import type { GitHubService } from "../services/github-service.js";
import type { ForgeService } from "../services/forge-service.js";
import {
createPaseoWorktree as createPaseoWorktreeService,
type CreatePaseoWorktreeFn,
@@ -127,11 +127,15 @@ function createWorkflowForRequestTest(options: {
};
}
function createGitHubServiceStub(): GitHubService {
function createGitHubServiceStub(): ForgeService {
return {
listPullRequests: async () => [],
listIssues: async () => [],
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
searchIssuesAndPrs: async () => ({
items: [],
featuresEnabled: true,
githubFeaturesEnabled: true,
}),
getPullRequest: async ({ number }) => ({
number,
title: `PR ${number}`,
@@ -143,6 +147,25 @@ function createGitHubServiceStub(): GitHubService {
labels: [],
}),
getPullRequestHeadRef: async ({ number }) => `pr-${number}`,
defaultCheckoutRefs: ({ changeRequestNumber }) => [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
],
buildPrLocalBranchName: ({ headRef, checkoutTarget }) => {
const normalized = checkoutTarget.headOwnerLogin?.trim().toLowerCase() ?? "";
const owner =
checkoutTarget.isCrossRepository && /^[a-z0-9-]+$/.test(normalized) ? normalized : null;
return owner ? `${owner}/${headRef}` : headRef;
},
supportsCrossRepoCheckoutWithoutRefs: true,
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: `pr-${number}`,
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
}),
getCurrentPullRequestStatus: async () => null,
createPullRequest: async () => ({
number: 1,
@@ -268,7 +291,7 @@ function createPaseoWorktreeForTest(options: {
logger: createLogger(),
paseoHome: options.paseoHome,
deps: {
github: createGitHubServiceStub(),
forgeOverrides: { github: createGitHubServiceStub() },
},
});
@@ -1376,7 +1399,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
createPaseoWorktree,
checkoutExistingBranch,
createBranchFromBase,
github: { invalidate },
workspaceGitService: { invalidateForge: invalidate } as unknown as WorkspaceGitService,
},
{
provider: "codex",
@@ -1394,7 +1417,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
baseBranch: "main",
newBranchName: "feature-x",
});
expect(invalidate).toHaveBeenCalledWith({ cwd: "/tmp/repo" });
expect(invalidate).toHaveBeenCalledWith("/tmp/repo");
invalidate.mockClear();
@@ -1404,7 +1427,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
createPaseoWorktree,
checkoutExistingBranch,
createBranchFromBase,
github: { invalidate },
workspaceGitService: { invalidateForge: invalidate } as unknown as WorkspaceGitService,
},
{
provider: "codex",
@@ -1416,7 +1439,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
);
expect(checkoutExistingBranch).toHaveBeenCalledWith("/tmp/repo", "release");
expect(invalidate).toHaveBeenCalledWith({ cwd: "/tmp/repo" });
expect(invalidate).toHaveBeenCalledWith("/tmp/repo");
});
test("createPaseoWorktreeForTest forwards the default branch resolver for branch-off intents", async () => {
@@ -1625,7 +1648,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
message.type === "create_paseo_worktree_response",
);
expect(response?.payload.workspace).toBeNull();
expect(response?.payload.error).toBe('action "checkout" requires refName or githubPrNumber');
expect(response?.payload.error).toBe('action "checkout" requires refName or checkoutSource');
expect(response?.payload.errorCode).toBe("missing_checkout_target");
} finally {
rmSync(tempDir, { recursive: true, force: true });
+15 -4
View File
@@ -5,6 +5,7 @@ import type { AgentSessionConfig } from "./agent/agent-sdk-types.js";
import {
type GitSetupOptions,
type FirstAgentContext,
type ChangeRequestCheckoutSource,
type SessionInboundMessage,
type SessionOutboundMessage,
type WorkspaceSetupSnapshot,
@@ -22,7 +23,6 @@ import {
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { ServiceProxySubsystem } from "./service-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { GitHubService } from "../services/github-service.js";
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js";
import {
@@ -59,6 +59,7 @@ export interface NormalizedGitOptions {
requestedWorktreeSlug?: string;
refName?: string;
action?: "branch-off" | "checkout";
checkoutSource?: ChangeRequestCheckoutSource;
githubPrNumber?: number;
}
@@ -91,7 +92,6 @@ interface BuildAgentSessionConfigDependencies {
baseBranch: string;
newBranchName: string;
}) => Promise<void>;
github?: Pick<GitHubService, "invalidate">;
}
interface CreatePaseoWorktreeInBackgroundDependencies {
@@ -226,6 +226,7 @@ export async function buildAgentSessionConfig(
worktreeSlug: normalized.worktreeSlug,
refName: normalized.refName,
action: normalized.action,
checkoutSource: normalized.checkoutSource,
githubPrNumber: normalized.githubPrNumber,
firstAgentContext,
runSetup: false,
@@ -259,10 +260,10 @@ export async function buildAgentSessionConfig(
baseBranch,
newBranchName: normalized.newBranchName!,
});
dependencies.github?.invalidate({ cwd });
dependencies.workspaceGitService?.invalidateForge(cwd);
} else if (normalized.baseBranch) {
await dependencies.checkoutExistingBranch(cwd, normalized.baseBranch);
dependencies.github?.invalidate({ cwd });
dependencies.workspaceGitService?.invalidateForge(cwd);
}
return {
@@ -331,6 +332,13 @@ export function normalizeGitOptions(
const normalizedWorktreeSlug = requestedWorktreeSlug ?? normalizedBranchName;
const refName = merged.refName?.trim() || undefined;
const action = merged.action;
// COMPAT(githubPrNumber): added in v0.1.106, remove after 2026-12-28 once
// clients send checkoutSource.
const checkoutSource =
merged.checkoutSource ??
(merged.githubPrNumber
? ({ kind: "change_request", forge: "github", number: merged.githubPrNumber } as const)
: undefined);
const githubPrNumber = merged.githubPrNumber;
if (
@@ -339,6 +347,7 @@ export function normalizeGitOptions(
!baseBranch &&
!refName &&
!action &&
!checkoutSource &&
!githubPrNumber
) {
return null;
@@ -360,6 +369,7 @@ export function normalizeGitOptions(
requestedWorktreeSlug,
refName,
action,
checkoutSource,
githubPrNumber,
};
}
@@ -512,6 +522,7 @@ export async function handleCreatePaseoWorktreeRequest(
firstAgentContext: normalizeFirstAgentContext(request),
refName: request.refName,
action: request.action,
checkoutSource: request.checkoutSource,
githubPrNumber: request.githubPrNumber,
},
);
@@ -0,0 +1,178 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createCachedCliPathResolver,
createForgeCliRunner,
probeHostViaCliAuthStatus,
} from "./forge-cli-command.js";
import { isPlatform } from "../test-utils/platform.js";
describe.skipIf(isPlatform("win32"))("probeHostViaCliAuthStatus", () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "forge-cli-command-"));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function writeFakeCli(script: string): string {
const cliPath = join(tempDir, "forge-cli");
writeFileSync(cliPath, script);
chmodSync(cliPath, 0o755);
return cliPath;
}
it("recognizes a host when the CLI auth status succeeds", async () => {
const cli = writeFakeCli(`#!/bin/sh
if [ "$1" = "--version" ]; then
exit 0
fi
if [ "$1" = "auth" ] && [ "$2" = "status" ] && [ "$3" = "--hostname" ] && [ "$4" = "git.acme.internal" ]; then
exit 0
fi
exit 1
`);
await expect(
probeHostViaCliAuthStatus({
cli,
host: "git.acme.internal",
}),
).resolves.toBe(true);
});
it("rejects a host when the CLI auth status fails", async () => {
const cli = writeFakeCli(`#!/bin/sh
if [ "$1" = "--version" ]; then
exit 0
fi
exit 1
`);
await expect(
probeHostViaCliAuthStatus({
cli,
host: "git.acme.internal",
}),
).resolves.toBe(false);
});
it("rejects a host when the CLI is missing", async () => {
await expect(
probeHostViaCliAuthStatus({
cli: join(tempDir, "missing-cli"),
host: "git.acme.internal",
}),
).resolves.toBe(false);
});
});
describe.skipIf(isPlatform("win32"))("createForgeCliRunner", () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "forge-cli-runner-"));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function writeSlowCli(): string {
const cliPath = join(tempDir, "slow-cli");
writeFileSync(cliPath, "#!/bin/sh\nsleep 5\n");
chmodSync(cliPath, 0o755);
return cliPath;
}
class FakeCommandError extends Error {
readonly stderr: string;
constructor(params: { stderr: string }) {
super("command failed");
this.stderr = params.stderr;
}
}
it("kills a hung CLI invocation after the configured timeout", async () => {
const cli = writeSlowCli();
const runner = createForgeCliRunner({
binary: cli,
envOverlay: {},
timeoutMs: 100,
isAuthFailureText: () => false,
errorClasses: {
isAlreadyClassified: () => false,
isCommandError: (error): error is FakeCommandError => error instanceof FakeCommandError,
createAuthError: (stderr) => new Error(`auth: ${stderr}`),
createMissingError: () => new Error("missing"),
createCommandError: (params) => new FakeCommandError(params),
},
});
await expect(runner.run(["arg"], { cwd: tempDir })).rejects.toThrow();
});
it("classifies a timed-out invocation through normalizeError", async () => {
const cli = writeSlowCli();
const runner = createForgeCliRunner({
binary: cli,
envOverlay: {},
timeoutMs: 100,
isAuthFailureText: () => false,
errorClasses: {
isAlreadyClassified: () => false,
isCommandError: (error): error is FakeCommandError => error instanceof FakeCommandError,
createAuthError: (stderr) => new Error(`auth: ${stderr}`),
createMissingError: () => new Error("missing"),
createCommandError: (params) => new FakeCommandError(params),
},
});
const rawError = await runner.run(["arg"], { cwd: tempDir }).catch((error: unknown) => error);
const normalized = runner.normalizeError(rawError, { args: ["arg"], cwd: tempDir });
expect(normalized).toBeInstanceOf(FakeCommandError);
expect((normalized as FakeCommandError).stderr).toMatch(/timed out after 100ms/);
});
});
describe("createCachedCliPathResolver", () => {
it("resolves once and reuses the cached path on later calls", async () => {
let calls = 0;
const resolveCliPath = createCachedCliPathResolver(async () => {
calls += 1;
return "/usr/bin/forge-cli";
});
await expect(resolveCliPath()).resolves.toBe("/usr/bin/forge-cli");
await expect(resolveCliPath()).resolves.toBe("/usr/bin/forge-cli");
expect(calls).toBe(1);
});
it("retries on the next call after a miss instead of caching null forever", async () => {
let calls = 0;
const resolveCliPath = createCachedCliPathResolver(async () => {
calls += 1;
return calls === 1 ? null : "/usr/bin/forge-cli";
});
await expect(resolveCliPath()).resolves.toBeNull();
await expect(resolveCliPath()).resolves.toBe("/usr/bin/forge-cli");
expect(calls).toBe(2);
});
it("coalesces concurrent callers onto a single in-flight resolution", async () => {
let calls = 0;
const resolveCliPath = createCachedCliPathResolver(async () => {
calls += 1;
return "/usr/bin/forge-cli";
});
await Promise.all([resolveCliPath(), resolveCliPath(), resolveCliPath()]);
expect(calls).toBe(1);
});
});
@@ -0,0 +1,325 @@
import { z } from "zod";
import { findExecutable } from "../executable-resolution/executable-resolution.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { execCommand } from "../utils/spawn.js";
/**
* Timeout for the per-host CLI auth probe (`gh`/`glab`/`tea` auth status) used
* to detect a self-hosted forge. This intentionally has no anonymous HTTP
* fallback: a remote-derived host is trusted only when the CLI already knows it.
*/
export const CLI_AUTH_PROBE_TIMEOUT_MS = 10_000;
interface CommandFailureLike {
code?: string | number | null;
killed?: boolean;
stderr?: string | Buffer;
stdout?: string | Buffer;
message?: string;
}
interface CliCommandErrorShape extends Error {
stderr: string;
}
interface NormalizeCliCommandErrorOptions {
error: unknown;
args: string[];
cwd: string;
commandName: string;
timeoutMs?: number;
isAlreadyClassified: (error: unknown) => boolean;
isCommandError: (error: unknown) => error is CliCommandErrorShape;
isAuthFailureText: (text: string) => boolean;
createAuthError: (stderr: string) => Error;
createMissingError: () => Error;
createCommandError: (params: {
args: string[];
cwd: string;
exitCode: number | null;
stderr: string;
}) => Error;
}
interface ParseCliJsonOutputOptions<T> {
commandName: string;
args: string[];
cwd: string;
stdout: string;
schema: z.ZodType<T>;
createCommandError: NormalizeCliCommandErrorOptions["createCommandError"];
}
interface ProbeHostViaCliAuthStatusOptions {
cli: string;
host: string;
envOverlay?: Record<string, string>;
}
export interface ForgeCliRunnerOptions {
cwd: string;
envOverlay?: Record<string, string>;
}
export interface ForgeCliRunnerResult {
stdout: string;
stderr: string;
}
export type ForgeCliRunner = (
args: string[],
options: ForgeCliRunnerOptions,
) => Promise<ForgeCliRunnerResult>;
export interface ForgeCliRunnerErrorClasses {
isAlreadyClassified: (error: unknown) => boolean;
isCommandError: (error: unknown) => error is CliCommandErrorShape;
createAuthError: (stderr: string) => Error;
createMissingError: () => Error;
createCommandError: (params: ForgeCommandFailureParams) => Error;
}
export interface CreateForgeCliRunnerOptions {
binary: string;
envOverlay: Record<string, string>;
timeoutMs: number;
isAuthFailureText: (text: string) => boolean;
errorClasses: ForgeCliRunnerErrorClasses;
}
export interface ForgeCliRunnerFactory {
run: ForgeCliRunner;
normalizeError: (
error: unknown,
context: { args: string[]; cwd: string; timeoutMs?: number },
) => Error;
}
/**
* Shared shape behind the gh/glab/tea CLI adapters: an exec wrapper (10MB
* maxBuffer, per-call env overlay merged over the binary's defaults, a
* timeout) plus error normalization through the binary's own error-class
* trio. The three adapters differ only in the params passed here.
*/
export function createForgeCliRunner(options: CreateForgeCliRunnerOptions): ForgeCliRunnerFactory {
const run: ForgeCliRunner = (args, runOptions) =>
execCommand(options.binary, args, {
cwd: runOptions.cwd,
envOverlay: { ...options.envOverlay, ...runOptions.envOverlay },
maxBuffer: 10 * 1024 * 1024,
timeout: options.timeoutMs,
});
function normalizeError(
error: unknown,
context: { args: string[]; cwd: string; timeoutMs?: number },
): Error {
return normalizeCliCommandError({
error,
args: context.args,
cwd: context.cwd,
commandName: options.binary,
timeoutMs: context.timeoutMs ?? options.timeoutMs,
isAlreadyClassified: options.errorClasses.isAlreadyClassified,
isCommandError: options.errorClasses.isCommandError,
isAuthFailureText: options.isAuthFailureText,
createAuthError: options.errorClasses.createAuthError,
createMissingError: options.errorClasses.createMissingError,
createCommandError: options.errorClasses.createCommandError,
});
}
return { run, normalizeError };
}
export class ForgeCliMissingError extends Error {
readonly kind = "missing-cli";
}
export class ForgeAuthenticationError extends Error {
readonly kind = "auth-failure";
readonly stderr: string;
constructor(message: string, params: { stderr: string }) {
super(message);
this.stderr = params.stderr;
}
}
export interface ForgeCommandFailureParams {
args: string[];
cwd: string;
exitCode: number | null;
stderr: string;
}
export class ForgeCommandError extends Error {
readonly kind = "command-error";
readonly args: string[];
readonly cwd: string;
readonly exitCode: number | null;
readonly stderr: string;
constructor(label: { brand: string; binary: string }, params: ForgeCommandFailureParams) {
super(`${label.brand} CLI command failed: ${label.binary} ${params.args.join(" ")}`);
this.args = [...params.args];
this.cwd = params.cwd;
this.exitCode = params.exitCode;
this.stderr = params.stderr;
}
}
/**
* Memoize a CLI path resolver (`findExecutable`) for the lifetime of a service
* instance. A found path is cached permanently; a miss (or a rejected probe)
* evicts the cache so a CLI installed after the daemon started is picked up
* on the next call instead of requiring a restart. Concurrent callers during
* resolution share the same in-flight promise.
*/
export function createCachedCliPathResolver(
resolve: () => Promise<string | null>,
): () => Promise<string | null> {
let pending: Promise<string | null> | null = null;
return function resolveCliPath(): Promise<string | null> {
if (pending) {
return pending;
}
const current: Promise<string | null> = resolve()
.then((path) => {
if (path === null && pending === current) {
pending = null;
}
return path;
})
.catch((error: unknown) => {
if (pending === current) {
pending = null;
}
throw error;
});
pending = current;
return current;
};
}
export function bufferOrStringToString(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (Buffer.isBuffer(value)) {
return value.toString("utf8");
}
return "";
}
export function normalizeCliCommandError(options: NormalizeCliCommandErrorOptions): Error {
if (options.isAlreadyClassified(options.error)) {
return options.error as Error;
}
if (options.isCommandError(options.error)) {
if (options.isAuthFailureText(options.error.stderr)) {
return options.createAuthError(options.error.stderr);
}
return options.error as Error;
}
const failure = toCommandFailureLike(options.error);
if (failure.code === "ENOENT") {
return options.createMissingError();
}
const stderr = bufferOrStringToString(failure.stderr);
const message = failure.message ?? "";
if (options.isAuthFailureText(stderr) || options.isAuthFailureText(message)) {
return options.createAuthError(stderr);
}
if (failure.killed === true && options.timeoutMs !== undefined) {
return options.createCommandError({
args: options.args,
cwd: options.cwd,
exitCode: null,
stderr:
stderr ||
`${options.commandName} was terminated before completing (timed out after ${options.timeoutMs}ms or exceeded the output limit)`,
});
}
return options.createCommandError({
args: options.args,
cwd: options.cwd,
exitCode: typeof failure.code === "number" ? failure.code : null,
stderr: stderr || message,
});
}
export function parseCliJsonOutput<T>(options: ParseCliJsonOutputOptions<T>): T {
let data: unknown;
try {
data = JSON.parse(options.stdout);
} catch {
throw options.createCommandError({
args: options.args,
cwd: options.cwd,
exitCode: null,
stderr: `${options.commandName} did not return valid JSON (${options.stdout.length} bytes)`,
});
}
const parsed = options.schema.safeParse(data);
if (!parsed.success) {
throw options.createCommandError({
args: options.args,
cwd: options.cwd,
exitCode: null,
stderr: `${options.commandName} JSON did not match the expected schema: ${parsed.error.message}`,
});
}
return parsed.data;
}
export async function defaultResolveRemoteUrl(cwd: string): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], { cwd });
const url = stdout.trim();
return url.length > 0 ? url : null;
} catch {
return null;
}
}
export async function probeHostViaCliAuthStatus(
options: ProbeHostViaCliAuthStatusOptions,
): Promise<boolean> {
const cliPath = await findExecutable(options.cli);
if (!cliPath) {
return false;
}
try {
await execCommand(options.cli, ["auth", "status", "--hostname", options.host], {
envOverlay: options.envOverlay,
timeout: CLI_AUTH_PROBE_TIMEOUT_MS,
});
return true;
} catch {
return false;
}
}
function toCommandFailureLike(error: unknown): CommandFailureLike {
if (!error || typeof error !== "object") {
return { message: String(error) };
}
const record = error as Record<string, unknown>;
return {
code:
typeof record.code === "string" || typeof record.code === "number" || record.code === null
? record.code
: undefined,
killed: typeof record.killed === "boolean" ? record.killed : undefined,
stderr:
typeof record.stderr === "string" || Buffer.isBuffer(record.stderr)
? record.stderr
: undefined,
stdout:
typeof record.stdout === "string" || Buffer.isBuffer(record.stdout)
? record.stdout
: undefined,
message: typeof record.message === "string" ? record.message : undefined,
};
}
@@ -0,0 +1,132 @@
import { FORGE_IDS } from "@getpaseo/protocol/forge-manifest";
import { describe, expect, it, vi } from "vitest";
import { createForgeService, defaultForgeRegistry, ForgeRegistry } from "./forge-registry.js";
import { createGitHubService } from "./github-service.js";
describe("forge registry", () => {
it("builds the registered adapters", () => {
const github = createForgeService("github");
const gitlab = createForgeService("gitlab");
const gitea = createForgeService("gitea");
const forgejo = createForgeService("forgejo");
expect(github?.getCurrentPullRequestStatus).toBeTypeOf("function");
expect(gitlab?.getCurrentPullRequestStatus).toBeTypeOf("function");
expect(gitea?.getCurrentPullRequestStatus).toBeTypeOf("function");
expect(forgejo?.getCurrentPullRequestStatus).toBeTypeOf("function");
});
it("returns null for an unregistered forge", () => {
expect(createForgeService("bitbucket")).toBeNull();
});
it("keeps the built-in registry in sync with the forge manifest", () => {
expect([...defaultForgeRegistry.ids()].sort()).toEqual([...FORGE_IDS].sort());
});
it("knows which forges are registered", () => {
expect(defaultForgeRegistry.has("github")).toBe(true);
expect(defaultForgeRegistry.has("gitlab")).toBe(true);
expect(defaultForgeRegistry.has("gitea")).toBe(true);
expect(defaultForgeRegistry.has("forgejo")).toBe(true);
expect(defaultForgeRegistry.has("bitbucket")).toBe(false);
expect(defaultForgeRegistry.ids()).toEqual(
expect.arrayContaining(["github", "gitlab", "gitea", "forgejo"]),
);
});
it("registers a third-party adapter without changing the registry implementation", () => {
const unregister = defaultForgeRegistry.register("bitbucket", {
createService: createGitHubService,
matchesHost: (host) => host === "bitbucket.org",
});
try {
expect(defaultForgeRegistry.has("bitbucket")).toBe(true);
expect(createForgeService("bitbucket")?.getCurrentPullRequestStatus).toBeTypeOf("function");
} finally {
unregister();
}
expect(defaultForgeRegistry.has("bitbucket")).toBe(false);
});
it("lets adapters own heuristic and asynchronous host detection", async () => {
const registry = new ForgeRegistry([
[
"bitbucket",
{
createService: createGitHubService,
matchesHost: (host) => host === "bitbucket.org",
probeHost: async (host) => host === "git.acme.internal",
},
],
]);
expect(registry.matchHost("bitbucket.org")).toBe("bitbucket");
await expect(registry.probeHost("git.acme.internal")).resolves.toBe("bitbucket");
});
it("does not infer self-managed forges from host substrings", () => {
expect(defaultForgeRegistry.matchHost("gitea-forgejo.example.org")).toBeNull();
expect(defaultForgeRegistry.matchHost("gitlab.example.org")).toBeNull();
expect(defaultForgeRegistry.matchHost("notgitlab.example.org")).toBeNull();
});
it("treats a probe that throws as 'not this forge' rather than crashing detection", async () => {
const registry = new ForgeRegistry([
[
"flaky",
{
createService: createGitHubService,
probeHost: async () => {
throw new Error("CLI not installed");
},
},
],
[
"reachable",
{
createService: createGitHubService,
probeHost: async (host) => host === "git.acme.internal",
},
],
]);
await expect(registry.probeHost("git.acme.internal")).resolves.toBe("reachable");
await expect(registry.probeHost("git.unknown.internal")).resolves.toBeNull();
});
it("degrades ambiguous host detection to no forge instead of depending on registration order", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const registry = new ForgeRegistry([
[
"first",
{
createService: createGitHubService,
matchesHost: () => true,
probeHost: async () => true,
},
],
[
"second",
{
createService: createGitHubService,
matchesHost: () => true,
probeHost: async () => true,
},
],
]);
expect(registry.matchHost("git.acme.internal")).toBeNull();
await expect(registry.probeHost("git.acme.internal")).resolves.toBeNull();
expect(warn).toHaveBeenCalledWith(
expect.stringMatching(/Multiple forge adapters matched host/),
);
expect(warn).toHaveBeenCalledWith(
expect.stringMatching(/Multiple forge adapters recognized host/),
);
} finally {
warn.mockRestore();
}
});
});
@@ -0,0 +1,178 @@
import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest";
import { normalizeHost } from "@getpaseo/protocol/git-remote";
import { createGitHubService, probeGitHubHost } from "./github-service.js";
import type { ForgeService } from "./forge-service.js";
import { createGiteaService, resolveGiteaFamilyForge } from "./gitea-service.js";
import { createGitLabService, probeGitLabHost } from "./gitlab-service.js";
export type ForgeServiceFactory = () => ForgeService;
export interface ForgeAdapterRegistration {
createService: ForgeServiceFactory;
matchesHost?: (host: string) => boolean;
probeHost?: (host: string) => Promise<boolean>;
}
/**
* Open composition boundary for forge adapters. Resolver code depends only on
* these registration hooks, so a new adapter does not require another branch.
*/
export class ForgeRegistry {
readonly #adapters = new Map<string, ForgeAdapterRegistration>();
readonly #warnedAmbiguousHosts = new Set<string>();
constructor(entries: Iterable<readonly [string, ForgeAdapterRegistration]> = []) {
for (const [forge, adapter] of entries) {
this.register(forge, adapter);
}
}
register(forge: string, adapter: ForgeAdapterRegistration): () => void {
const normalizedForge = parseForgeId(forge);
if (!normalizedForge) {
throw new Error(`Invalid forge adapter id: ${forge}`);
}
if (this.#adapters.has(normalizedForge)) {
throw new Error(`Forge adapter already registered: ${normalizedForge}`);
}
this.#adapters.set(normalizedForge, adapter);
return () => {
if (this.#adapters.get(normalizedForge) === adapter) {
this.#adapters.delete(normalizedForge);
}
};
}
ids(): string[] {
return [...this.#adapters.keys()];
}
has(forge: string): boolean {
const normalizedForge = parseForgeId(forge);
return normalizedForge ? this.#adapters.has(normalizedForge) : false;
}
create(forge: string): ForgeService | null {
const normalizedForge = parseForgeId(forge);
if (!normalizedForge) {
return null;
}
const adapter = this.#adapters.get(normalizedForge);
return adapter ? adapter.createService() : null;
}
matchHost(host: string): string | null {
const matches: string[] = [];
for (const [forge, adapter] of this.#adapters) {
if (adapter.matchesHost?.(host)) {
matches.push(forge);
}
}
if (matches.length > 1) {
this.#warnAmbiguous("matched", host, matches);
return null;
}
return matches[0] ?? null;
}
async probeHost(host: string): Promise<string | null> {
const entries = [...this.#adapters];
// allSettled, not all: a third-party probe that throws means "not this
// forge", never a crash of the shared resolution path.
const settled = await Promise.allSettled(
entries.map(async ([, adapter]) =>
adapter.probeHost ? await adapter.probeHost(host) : false,
),
);
const matches = entries
.filter((_, index) => {
const result = settled[index];
return result.status === "fulfilled" && result.value === true;
})
.map(([forge]) => forge);
if (matches.length > 1) {
this.#warnAmbiguous("recognized", host, matches);
return null;
}
return matches[0] ?? null;
}
// Genuine ambiguity (two adapters both claiming a host) degrades to "no
// forge" rather than crashing the shared resolution path used by every
// workspace's PR-status poll; warn once per host so the misconfiguration
// is still visible.
#warnAmbiguous(verb: string, host: string, matches: string[]): void {
const key = `${verb}:${host}`;
if (this.#warnedAmbiguousHosts.has(key)) {
return;
}
this.#warnedAmbiguousHosts.add(key);
console.warn(`Multiple forge adapters ${verb} host ${host}: ${matches.join(", ")}`);
}
}
function parseForgeId(forge: string): string | null {
const normalized = forge.trim().toLowerCase();
return /^[a-z0-9][a-z0-9._-]*$/.test(normalized) ? normalized : null;
}
/**
* Build a host matcher from a forge's declared cloud hosts in the manifest, so
* the registry never hardcodes a host list. Returns undefined for forges with
* no cloud hosts (recognized only by runtime probe, e.g. Forgejo).
*/
function matchesCloudHost(forgeId: string): ((host: string) => boolean) | undefined {
const hosts = getForgeDefinition(forgeId)?.cloudHosts;
if (!hosts || hosts.length === 0) {
return undefined;
}
const normalized = new Set(hosts.map(normalizeHost));
return (host) => normalized.has(normalizeHost(host));
}
export const defaultForgeRegistry = new ForgeRegistry([
// GitHub Enterprise Server is recognized at runtime by probeHost, exactly like
// self-hosted GitLab/Gitea: github.com short-circuits via matchHost, so the
// probe only runs on non-cloud hosts. The PR-status poll gates on the resolver
// alone (no cloud-identity check), so a probed GHES host polls normally.
[
"github",
{
createService: createGitHubService,
matchesHost: matchesCloudHost("github"),
probeHost: probeGitHubHost,
},
],
[
"gitlab",
{
createService: createGitLabService,
matchesHost: matchesCloudHost("gitlab"),
probeHost: probeGitLabHost,
},
],
[
"gitea",
{
createService: createGiteaService,
matchesHost: matchesCloudHost("gitea"),
probeHost: async (host) => (await resolveGiteaFamilyForge(host)) === "gitea",
},
],
[
"forgejo",
{
createService: createGiteaService,
probeHost: async (host) => (await resolveGiteaFamilyForge(host)) === "forgejo",
},
],
["codeberg", { createService: createGiteaService, matchesHost: matchesCloudHost("codeberg") }],
]);
export function createForgeService(forge: string): ForgeService | null {
return defaultForgeRegistry.create(forge);
}
export function probeRegisteredForgeHost(host: string): Promise<string | null> {
return defaultForgeRegistry.probeHost(host);
}
@@ -0,0 +1,376 @@
import { describe, expect, it, vi } from "vitest";
import { createForgeService } from "./forge-registry.js";
import { createForgeResolver, forgeForHost, parseRemoteHost } from "./forge-resolver.js";
function createSshHostnameResolver(hostnameByAlias: Record<string, string | null>) {
return vi.fn(async (host: string): Promise<string | null> => {
return hostnameByAlias[host] ?? null;
});
}
async function resolveSshHostnameAsLiteralHost(host: string): Promise<string | null> {
return host;
}
describe("parseRemoteHost", () => {
it("parses ssh and https remotes", () => {
expect(parseRemoteHost("git@github.com:owner/repo.git")).toBe("github.com");
expect(parseRemoteHost("git@gitlab.example.com:group/sub/repo.git")).toBe("gitlab.example.com");
expect(parseRemoteHost("https://GitLab.Example.Com./group/repo.git")).toBe(
"gitlab.example.com",
);
expect(parseRemoteHost("not a url")).toBeNull();
});
});
describe("forgeForHost", () => {
it("maps public registered forge hosts without resolver-specific branches", () => {
expect(forgeForHost("github.com")).toBe("github");
expect(forgeForHost("gitlab.com")).toBe("gitlab");
expect(forgeForHost("gitea.com")).toBe("gitea");
expect(forgeForHost("codeberg.org")).toBe("codeberg");
});
it("returns null for hosts with no known adapter", () => {
expect(forgeForHost("example.com")).toBeNull();
expect(forgeForHost("bitbucket.org")).toBeNull();
expect(forgeForHost("gitlab.example.com")).toBeNull();
expect(forgeForHost("forgejo.example.org")).toBeNull();
expect(forgeForHost("notgitlab.example.org")).toBeNull();
});
});
describe("createForgeResolver", () => {
it("resolves a github.com remote to the github forge", async () => {
const resolver = createForgeResolver({
resolveRemoteUrl: async () => "git@github.com:owner/repo.git",
});
const resolution = await resolver.resolve("/repo");
expect(resolution).toMatchObject({ forge: "github", host: "github.com" });
expect(resolution?.service.getCurrentPullRequestStatus).toBeTypeOf("function");
});
it("resolves a self-managed GitLab remote through the per-host probe", async () => {
const probeForge = vi.fn(async () => "gitlab");
const resolver = createForgeResolver({
resolveRemoteUrl: async () => "git@gitlab.example.com:example-group/example-project.git",
probeForge,
resolveSshHostname: resolveSshHostnameAsLiteralHost,
});
const resolution = await resolver.resolve("/repo");
expect(resolution).toMatchObject({ forge: "gitlab", host: "gitlab.example.com" });
expect(probeForge).toHaveBeenCalledWith("gitlab.example.com");
});
it("resolves an SSH alias that maps to github.com", async () => {
const probeForge = vi.fn(async () => {
throw new Error("github cloud alias should not probe CLI auth");
});
const resolveSshHostname = createSshHostnameResolver({ "github-work": "github.com" });
const resolver = createForgeResolver({
resolveRemoteUrl: async () => "git@github-work:acme/repo.git",
probeForge,
resolveSshHostname,
});
await expect(resolver.resolve("/repo")).resolves.toMatchObject({
forge: "github",
host: "github.com",
});
expect(resolveSshHostname).toHaveBeenCalledWith("github-work");
expect(probeForge).not.toHaveBeenCalled();
});
it("probes the resolved host for an SSH alias to a self-managed forge", async () => {
const probeForge = vi.fn(async () => "gitlab");
const resolveSshHostname = createSshHostnameResolver({
"gitlab-work": "gitlab.example.com",
});
const resolver = createForgeResolver({
resolveRemoteUrl: async () => "git@gitlab-work:example-group/example-project.git",
probeForge,
resolveSshHostname,
});
await expect(resolver.resolve("/repo")).resolves.toMatchObject({
forge: "gitlab",
host: "gitlab.example.com",
});
expect(probeForge).toHaveBeenCalledWith("gitlab.example.com");
});
it("lets the sync resolver reuse an SSH-alias forge discovered by an async resolve", async () => {
const remoteUrl = "git@github-work:acme/repo.git";
const resolver = createForgeResolver({
resolveRemoteUrl: async () => remoteUrl,
probeForge: async () => null,
resolveSshHostname: createSshHostnameResolver({ "github-work": "github.com" }),
});
expect(resolver.resolveFromRemoteUrl(remoteUrl)).toBeNull();
await resolver.resolve("/repo");
expect(resolver.resolveFromRemoteUrl(remoteUrl)).toMatchObject({ forge: "github" });
});
it("lets the sync resolver reuse an SSH-alias forge discovered through the probe", async () => {
const remoteUrl = "git@gitlab-work:example-group/example-project.git";
const resolver = createForgeResolver({
resolveRemoteUrl: async () => remoteUrl,
probeForge: async () => "gitlab",
resolveSshHostname: createSshHostnameResolver({ "gitlab-work": "gitlab.example.com" }),
});
expect(resolver.resolveFromRemoteUrl(remoteUrl)).toBeNull();
await resolver.resolve("/repo");
expect(resolver.resolveFromRemoteUrl(remoteUrl)).toMatchObject({ forge: "gitlab" });
});
it("returns null when an SSH alias cannot be resolved to a forge host", async () => {
const probeForge = vi.fn(async () => null);
const resolveSshHostname = createSshHostnameResolver({ "github-work": null });
const resolver = createForgeResolver({
resolveRemoteUrl: async () => "git@github-work:acme/repo.git",
probeForge,
resolveSshHostname,
});
await expect(resolver.resolve("/repo")).resolves.toBeNull();
});
it("does not use SSH hostname resolution for non-SSH remotes", async () => {
const probeForge = vi.fn(async () => null);
const resolveSshHostname = createSshHostnameResolver({ "github-work": "github.com" });
const resolver = createForgeResolver({
resolveRemoteUrl: async () => "https://github-work/acme/repo.git",
probeForge,
resolveSshHostname,
});
await expect(resolver.resolve("/repo")).resolves.toBeNull();
expect(resolveSshHostname).not.toHaveBeenCalled();
});
it("resolves Gitea and Codeberg remotes to their registered top-level forges", async () => {
const gitea = createForgeResolver({
resolveRemoteUrl: async () => "https://gitea.com/example/repo.git",
});
const codeberg = createForgeResolver({
resolveRemoteUrl: async () => "git@codeberg.org:example/repo.git",
});
await expect(gitea.resolve("/gitea")).resolves.toMatchObject({
forge: "gitea",
host: "gitea.com",
});
await expect(codeberg.resolve("/codeberg")).resolves.toMatchObject({
forge: "codeberg",
host: "codeberg.org",
});
});
it("does not classify an overlapping gitea-forgejo hostname without a probe", async () => {
const probeForge = vi.fn(async () => null);
const resolver = createForgeResolver({
resolveRemoteUrl: async () => "git@gitea-forgejo.example.org:example/repo.git",
probeForge,
resolveSshHostname: resolveSshHostnameAsLiteralHost,
});
await expect(resolver.resolve("/repo")).resolves.toBeNull();
expect(probeForge).toHaveBeenCalledWith("gitea-forgejo.example.org");
});
it("returns null when the cwd has no origin remote", async () => {
const resolver = createForgeResolver({ resolveRemoteUrl: async () => null });
expect(await resolver.resolve("/repo")).toBeNull();
});
it("degrades to no forge when the host probe throws instead of crashing resolution", async () => {
const probeForge = vi.fn(async () => {
throw new Error("registry probe blew up");
});
const resolver = createForgeResolver({
resolveRemoteUrl: async () => "git@git.acme.internal:team/repo.git",
probeForge,
resolveSshHostname: resolveSshHostnameAsLiteralHost,
});
await expect(resolver.resolve("/repo")).resolves.toBeNull();
expect(probeForge).toHaveBeenCalledWith("git.acme.internal");
});
it("reuses one adapter instance per forge across resolutions", async () => {
let built = 0;
const probeForge = vi.fn(async () => "gitlab");
const resolver = createForgeResolver({
resolveRemoteUrl: async () => "git@gitlab.example.com:group/repo.git",
probeForge,
resolveSshHostname: resolveSshHostnameAsLiteralHost,
createService: (forge) => {
built += 1;
return createForgeService(forge);
},
});
const first = await resolver.resolve("/a");
const second = await resolver.resolve("/b");
expect(built).toBe(1);
expect(probeForge).toHaveBeenCalledTimes(1);
expect(first?.service).toBe(second?.service);
});
it("memoizes the remote-url resolution per cwd", async () => {
const resolveRemoteUrl = vi.fn(async () => "git@github.com:owner/repo.git");
const resolver = createForgeResolver({ resolveRemoteUrl });
await resolver.resolve("/repo");
await resolver.resolve("/repo");
expect(resolveRemoteUrl).toHaveBeenCalledTimes(1);
await resolver.resolve("/other-repo");
expect(resolveRemoteUrl).toHaveBeenCalledTimes(2);
});
it("re-resolves the remote url for a cwd after invalidate()", async () => {
const resolveRemoteUrl = vi.fn(async () => "git@github.com:owner/repo.git");
const resolver = createForgeResolver({ resolveRemoteUrl });
await resolver.resolve("/repo");
resolver.invalidate("/repo");
await resolver.resolve("/repo");
expect(resolveRemoteUrl).toHaveBeenCalledTimes(2);
});
it("keeps serving the cached remote url until the TTL expires", async () => {
let currentTime = 0;
let remoteUrl = "git@github.com:owner/repo.git";
const resolver = createForgeResolver({
resolveRemoteUrl: async () => remoteUrl,
now: () => currentTime,
});
await expect(resolver.resolve("/repo")).resolves.toMatchObject({ forge: "github" });
// A remote changed outside Paseo (`git remote set-url` from a terminal)
// does not go through invalidate(), so the cache must still serve the
// stale value until its TTL expires.
remoteUrl = "git@gitlab.com:owner/repo.git";
currentTime += 59_000;
await expect(resolver.resolve("/repo")).resolves.toMatchObject({ forge: "github" });
currentTime += 2_000;
await expect(resolver.resolve("/repo")).resolves.toMatchObject({ forge: "gitlab" });
});
it("re-resolves the remote url immediately after invalidate(), before the TTL expires", async () => {
let currentTime = 0;
let remoteUrl = "git@github.com:owner/repo.git";
const resolver = createForgeResolver({
resolveRemoteUrl: async () => remoteUrl,
now: () => currentTime,
});
await expect(resolver.resolve("/repo")).resolves.toMatchObject({ forge: "github" });
remoteUrl = "git@gitlab.com:owner/repo.git";
currentTime += 1_000;
resolver.invalidate("/repo");
await expect(resolver.resolve("/repo")).resolves.toMatchObject({ forge: "gitlab" });
});
it("detects a self-managed GitLab host with no name hint via the per-host probe", async () => {
const probeForge = vi.fn(async () => "gitlab");
const resolver = createForgeResolver({
resolveRemoteUrl: async () => "git@git.acme.internal:team/repo.git",
probeForge,
resolveSshHostname: resolveSshHostnameAsLiteralHost,
});
const resolution = await resolver.resolve("/repo");
expect(resolution).toMatchObject({ forge: "gitlab", host: "git.acme.internal" });
expect(probeForge).toHaveBeenCalledWith("git.acme.internal");
});
it("skips the probe when the name heuristic already resolves the host", async () => {
const probeForge = vi.fn(async () => null);
const resolver = createForgeResolver({
resolveRemoteUrl: async () => "git@github.com:owner/repo.git",
probeForge,
});
const resolution = await resolver.resolve("/repo");
expect(resolution).toMatchObject({ forge: "github", host: "github.com" });
expect(probeForge).not.toHaveBeenCalled();
});
it.each([
["github", "git@github.com:owner/repo.git", "github.com"],
["gitlab", "git@gitlab.com:group/repo.git", "gitlab.com"],
["gitea", "git@gitea.com:owner/repo.git", "gitea.com"],
["codeberg", "git@codeberg.org:owner/repo.git", "codeberg.org"],
])("resolves the %s cloud host without probing CLI auth", async (forge, remoteUrl, host) => {
const probeForge = vi.fn(async () => {
throw new Error("cloud host should not probe");
});
const resolver = createForgeResolver({
resolveRemoteUrl: async () => remoteUrl,
probeForge,
});
await expect(resolver.resolve("/repo")).resolves.toMatchObject({ forge, host });
expect(probeForge).not.toHaveBeenCalled();
});
it("returns null and probes a foreign host only once", async () => {
const probeForge = vi.fn(async () => null);
const resolver = createForgeResolver({
resolveRemoteUrl: async () => "git@bitbucket.org:owner/repo.git",
probeForge,
resolveSshHostname: resolveSshHostnameAsLiteralHost,
});
expect(await resolver.resolve("/a")).toBeNull();
expect(await resolver.resolve("/b")).toBeNull();
expect(probeForge).toHaveBeenCalledTimes(1);
});
it("lets the synchronous resolveFromRemoteUrl reuse a probed forge", async () => {
const url = "git@git.acme.internal:team/repo.git";
const probeForge = vi.fn(async () => "gitlab");
const resolver = createForgeResolver({
resolveRemoteUrl: async () => url,
probeForge,
resolveSshHostname: resolveSshHostnameAsLiteralHost,
});
expect(resolver.resolveFromRemoteUrl(url)).toBeNull();
await resolver.resolve("/repo");
expect(resolver.resolveFromRemoteUrl(url)).toMatchObject({
forge: "gitlab",
host: "git.acme.internal",
});
expect(probeForge).toHaveBeenCalledTimes(1);
});
it("coalesces concurrent probes of the same host into a single probe", async () => {
let resolveProbe: ((forge: string | null) => void) | undefined;
const probeForge = vi.fn(
() =>
new Promise<string | null>((resolve) => {
resolveProbe = resolve;
}),
);
const url = "git@git.acme.internal:team/repo.git";
const resolver = createForgeResolver({
probeForge,
resolveSshHostname: resolveSshHostnameAsLiteralHost,
});
const first = resolver.resolveFromRemoteUrlAsync(url);
const second = resolver.resolveFromRemoteUrlAsync(url);
await vi.waitFor(() => expect(probeForge).toHaveBeenCalledTimes(1));
expect(resolveProbe).toBeTypeOf("function");
resolveProbe?.("gitlab");
const [a, b] = await Promise.all([first, second]);
expect(a).toMatchObject({ forge: "gitlab", host: "git.acme.internal" });
expect(b).toMatchObject({ forge: "gitlab", host: "git.acme.internal" });
expect(probeForge).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,257 @@
import { LRUCache } from "lru-cache";
import { parseGitRemoteLocation, type GitRemoteLocation } from "@getpaseo/protocol/git-remote";
import { resolveSshHostname, type SshHostnameResolver } from "../utils/ssh-hostname.js";
import { defaultResolveRemoteUrl } from "./forge-cli-command.js";
import type { ForgeService } from "./forge-service.js";
import {
createForgeService,
defaultForgeRegistry,
probeRegisteredForgeHost,
} from "./forge-registry.js";
export interface ForgeResolution {
/** Registered forge id, e.g. "github" or "gitlab". */
forge: string;
/** Remote host the cwd resolves to, e.g. "github.com" or "gitlab.example.com". */
host: string;
/** Adapter for {@link forge}, shared across resolutions of the same forge. */
service: ForgeService;
}
/** Probe a host for a forge id when the name heuristic is inconclusive. */
export type ForgeHostProbe = (host: string) => Promise<string | null>;
export interface CreateForgeResolverOptions {
resolveRemoteUrl?: (cwd: string) => Promise<string | null>;
createService?: (forge: string) => ForgeService | null;
probeForge?: ForgeHostProbe;
resolveSshHostname?: SshHostnameResolver;
now?: () => number;
}
export interface ForgeResolver {
/** Resolve the forge for a working directory, or null when none applies. */
resolve(cwd: string): Promise<ForgeResolution | null>;
/** Resolve from a known origin remote URL using only the name heuristic + probe cache. */
resolveFromRemoteUrl(remoteUrl: string | null): ForgeResolution | null;
/** Resolve from a known origin remote URL, running the per-host probe when needed. */
resolveFromRemoteUrlAsync(remoteUrl: string | null): Promise<ForgeResolution | null>;
/**
* Invalidate every cached adapter's state for a cwd. Routed through the
* resolver so invalidation hits the same adapter instance the poller reads,
* regardless of which forge backs the workspace.
*/
invalidate(cwd: string): void;
}
// A positive probe (host IS a known forge) is cached permanently; a negative one
// expires so a CLI installed/authenticated later is picked up without a restart.
const NEGATIVE_PROBE_TTL_MS = 60_000;
// Cap the per-resolver caches: session handlers resolve request-supplied cwds,
// so unbounded Maps would grow for the daemon's lifetime. Distinct hosts/cwds
// are few in practice; LRU eviction keeps the working set without a leak.
const FORGE_RESOLVER_CACHE_MAX = 512;
export function parseRemoteHost(url: string): string | null {
return parseGitRemoteLocation(url)?.host ?? null;
}
/** Map a remote host through the matchers owned by registered adapters. */
export function forgeForHost(host: string): string | null {
return defaultForgeRegistry.matchHost(host);
}
export function createForgeResolver(options: CreateForgeResolverOptions = {}): ForgeResolver {
const resolveRemoteUrl = options.resolveRemoteUrl ?? defaultResolveRemoteUrl;
const create = options.createService ?? createForgeService;
const probeForge = options.probeForge ?? probeRegisteredForgeHost;
const resolveSsh = options.resolveSshHostname ?? resolveSshHostname;
const now = options.now ?? Date.now;
const services = new Map<string, ForgeService>();
// Cache the per-host probe result so the synchronous resolveFromRemoteUrl can
// reuse a forge discovered by an earlier async resolve. Positive results are
// permanent; negative ones expire (NEGATIVE_PROBE_TTL_MS) so a CLI installed
// or authenticated later is picked up without a daemon restart.
const probedForgeByHost = new LRUCache<
string,
{ forge: string | null; expiresAt: number | null }
>({ max: FORGE_RESOLVER_CACHE_MAX });
// Coalesce concurrent probes of the same host so "never re-probe" holds under
// concurrency: callers racing on the same host await one shared probe.
const inFlightProbes = new Map<string, Promise<string | null>>();
// resolveRemoteUrl spawns `git config` — memoize per cwd so repeated resolve()
// calls (the PR-status poll hits this every cycle) don't re-spawn it. TTL'd
// (not permanent, unlike a positive host probe) so a remote added/changed
// from outside Paseo — `git remote add`/`set-url` run in a terminal — is
// still picked up within a cycle instead of staying pinned forever; a Paseo
// git mutation still busts it immediately via invalidate(cwd).
const remoteUrlByCwd = new LRUCache<
string,
{ promise: Promise<string | null>; expiresAt: number }
>({ max: FORGE_RESOLVER_CACHE_MAX });
function resolveRemoteUrlCached(cwd: string): Promise<string | null> {
const cached = remoteUrlByCwd.get(cwd);
if (cached && cached.expiresAt > now()) {
return cached.promise;
}
const pending = resolveRemoteUrl(cwd).catch((error: unknown) => {
if (remoteUrlByCwd.get(cwd)?.promise === pending) {
remoteUrlByCwd.delete(cwd);
}
throw error;
});
remoteUrlByCwd.set(cwd, { promise: pending, expiresAt: now() + NEGATIVE_PROBE_TTL_MS });
return pending;
}
function readFreshProbe(host: string): string | null | undefined {
const entry = probedForgeByHost.get(host);
if (!entry) {
return undefined;
}
if (entry.expiresAt !== null && Date.now() >= entry.expiresAt) {
probedForgeByHost.delete(host);
return undefined;
}
return entry.forge;
}
function buildResolution(forge: string, host: string): ForgeResolution | null {
let service = services.get(forge);
if (!service) {
const created = create(forge);
if (!created) {
return null;
}
service = created;
services.set(forge, service);
}
return { forge, host, service };
}
async function probeHostForge(host: string): Promise<string | null> {
const cached = readFreshProbe(host);
if (cached !== undefined) {
return cached;
}
const existing = inFlightProbes.get(host);
if (existing) {
return existing;
}
const pending = (async () => {
try {
// A caller-injected probe that throws degrades to "no forge" and is
// cached negatively, so a transient failure is retried after the TTL.
let probed: string | null;
try {
probed = await probeForge(host);
} catch {
probed = null;
}
probedForgeByHost.set(host, {
forge: probed,
expiresAt: probed === null ? Date.now() + NEGATIVE_PROBE_TTL_MS : null,
});
return probed;
} finally {
inFlightProbes.delete(host);
}
})();
inFlightProbes.set(host, pending);
return pending;
}
function resolveFromRemoteUrl(remoteUrl: string | null): ForgeResolution | null {
if (!remoteUrl) {
return null;
}
const host = parseRemoteHost(remoteUrl);
if (!host) {
return null;
}
const forge = forgeForHost(host) ?? readFreshProbe(host) ?? null;
if (!forge) {
return null;
}
return buildResolution(forge, host);
}
async function resolveFromRemoteUrlAsync(
remoteUrl: string | null,
): Promise<ForgeResolution | null> {
if (!remoteUrl) {
return null;
}
const location = parseGitRemoteLocation(remoteUrl);
if (!location) {
return null;
}
const directForge = forgeForHost(location.host);
if (directForge) {
return buildResolution(directForge, location.host);
}
const resolved = await resolveHostForProbe(location);
if (resolved.forge) {
rememberAliasForge(location.host, resolved.host, resolved.forge);
return buildResolution(resolved.forge, resolved.host);
}
const forge = await probeHostForge(resolved.host);
rememberAliasForge(location.host, resolved.host, forge);
if (!forge) {
return null;
}
return buildResolution(forge, resolved.host);
}
// Seed the probe cache under the raw SSH-alias host too: the synchronous
// resolveFromRemoteUrl (PR-status poll gating) sees the alias, not the
// ssh -G resolved host, and would otherwise never find the forge.
function rememberAliasForge(aliasHost: string, resolvedHost: string, forge: string | null): void {
if (aliasHost === resolvedHost) {
return;
}
probedForgeByHost.set(aliasHost, {
forge,
expiresAt: forge === null ? Date.now() + NEGATIVE_PROBE_TTL_MS : null,
});
}
async function resolveHostForProbe(
location: GitRemoteLocation,
): Promise<{ host: string; forge?: string }> {
if (location.transport !== "scp" && location.transport !== "ssh") {
return { host: location.host };
}
const resolvedHost = await resolveSsh(location.host);
if (!resolvedHost) {
return { host: location.host };
}
const resolvedForge = forgeForHost(resolvedHost);
if (resolvedForge) {
return { host: resolvedHost, forge: resolvedForge };
}
return { host: resolvedHost };
}
function invalidate(cwd: string): void {
remoteUrlByCwd.delete(cwd);
for (const service of services.values()) {
service.invalidate({ cwd });
}
}
return {
resolveFromRemoteUrl,
resolveFromRemoteUrlAsync,
invalidate,
async resolve(cwd: string): Promise<ForgeResolution | null> {
return resolveFromRemoteUrlAsync(await resolveRemoteUrlCached(cwd));
},
};
}
@@ -0,0 +1,525 @@
import type { ForgeSearchKind } from "@getpaseo/protocol/messages";
export type ForgeSearchRequestKind = ForgeSearchKind | "github-issue" | "github-pr" | "pr";
export function normalizeForgeSearchKinds(
kinds: readonly ForgeSearchRequestKind[] | undefined,
): ForgeSearchKind[] {
if (!kinds) return ["issue", "change_request"];
return kinds.map((kind) => {
// COMPAT(githubSearchKind): added in v0.1.106, remove with the legacy
// github_search_request RPC after 2026-12-28.
if (kind === "github-issue") return "issue";
if (kind === "github-pr" || kind === "pr") return "change_request";
return kind;
});
}
export interface PullRequestSummary {
number: number;
title: string;
url: string;
state: string;
body: string | null;
projectPath?: string;
baseRefName: string;
headRefName: string;
labels: string[];
updatedAt: string;
}
export interface PullRequestCheckoutTarget {
number: number;
baseRefName: string;
headRefName: string;
checkoutRefs?: PullRequestCheckoutRef[];
headOwnerLogin: string | null;
headRepositorySshUrl: string | null;
headRepositoryUrl: string | null;
isCrossRepository: boolean;
}
export interface PullRequestCheckoutRef {
remoteName?: string;
remoteRef: string;
}
export interface IssueSummary {
number: number;
title: string;
url: string;
state: string;
body: string | null;
projectPath?: string;
labels: string[];
updatedAt: string;
}
export type PullRequestCheckStatus = "pending" | "success" | "failure" | "cancelled" | "skipped";
export interface PullRequestCheck {
name: string;
status: PullRequestCheckStatus;
url: string | null;
workflow?: string;
duration?: string;
checkRunId?: number;
workflowRunId?: number;
}
export type PullRequestChecksStatus = "none" | "pending" | "success" | "failure";
export type PullRequestReviewDecision = "approved" | "changes_requested" | "pending" | null;
export type PullRequestMergeable = "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
export function computeChecksStatus(checks: PullRequestCheck[]): PullRequestChecksStatus {
if (checks.length === 0) {
return "none";
}
if (checks.some((check) => check.status === "failure")) {
return "failure";
}
if (checks.some((check) => check.status === "pending")) {
return "pending";
}
return "success";
}
/**
* Why a forge's PR/MR features are (un)available for a workspace. Replaces the
* lossy "authenticated yes/no" boolean so the UI can offer the precise next step
* (install the CLI vs sign in) instead of a single generic dead-end. "no_remote"
* covers anything where the feature simply does not apply (no resolvable forge
* remote, or no branch to look up). "error" is reserved for non-auth failures
* where the UI should show the actual error instead of setup guidance.
*/
export type ForgeAuthState =
| "authenticated"
| "unauthenticated"
| "cli_missing"
| "no_remote"
| "error";
/**
* Open envelope for a forge's native merge facts on the neutral PR status.
* Per-adapter modules own their typed fact interfaces and guards; shared server
* code only requires the runtime facts-family tag.
*/
export type ForgeSpecificStatusFacts = { forge: string } & Record<string, unknown>;
export interface CurrentPullRequestStatus {
number?: number;
repoOwner?: string;
repoName?: string;
/**
* The forge's full project path (e.g. nested GitLab namespaces like
* `group/subgroup/repo`). Adapters that can report it precisely set it here;
* otherwise consumers fall back to deriving it from owner/name.
*/
projectPath?: string;
url: string;
title: string;
state: string;
baseRefName: string;
headRefName: string;
isMerged: boolean;
isDraft?: boolean;
mergeable: PullRequestMergeable;
checks: PullRequestCheck[];
checksStatus: PullRequestChecksStatus;
reviewDecision: PullRequestReviewDecision;
forgeSpecific?: ForgeSpecificStatusFacts;
}
export type PullRequestTimelineReviewState = "approved" | "changes_requested" | "commented";
interface PullRequestTimelineItemBase {
id: string;
author: string;
authorUrl: string | null;
avatarUrl: string | null;
body: string;
createdAt: number;
url: string;
}
export type PullRequestTimelineItem =
| (PullRequestTimelineItemBase & {
kind: "review";
reviewState: PullRequestTimelineReviewState;
})
| (PullRequestTimelineItemBase & {
kind: "comment";
reviewId?: string;
threadId?: string;
threadIsResolved?: boolean;
location?: PullRequestTimelineCommentLocation;
});
export interface PullRequestTimelineCommentLocation {
path: string;
line?: number;
startLine?: number;
threadId?: string;
isResolved?: boolean;
isOutdated?: boolean;
}
export type PullRequestTimelineErrorKind = "not_found" | "forbidden" | "unknown";
export interface PullRequestTimelineError {
kind: PullRequestTimelineErrorKind;
message: string;
}
export interface PullRequestTimeline {
prNumber: number;
repoOwner: string;
repoName: string;
items: PullRequestTimelineItem[];
truncated: boolean;
error: PullRequestTimelineError | null;
}
export interface PullRequestCreateResult {
url: string;
number: number;
}
export type PullRequestMergeMethod = "merge" | "squash" | "rebase";
export interface PullRequestCommandStatus {
mergeable?: PullRequestMergeable;
forgeSpecific?: ForgeSpecificStatusFacts;
}
export interface MergePullRequestOptions {
cwd: string;
prNumber: number;
mergeMethod: PullRequestMergeMethod;
status?: PullRequestCommandStatus | null;
}
export interface EnablePullRequestAutoMergeOptions {
cwd: string;
prNumber: number;
mergeMethod: PullRequestMergeMethod;
status?: PullRequestCommandStatus | null;
}
export interface DisablePullRequestAutoMergeOptions {
cwd: string;
prNumber: number;
status?: PullRequestCommandStatus | null;
}
export interface PullRequestMergeResult {
success: true;
}
export interface PullRequestAutoMergeResult {
success: true;
}
export type ForgeReadOptions =
| {
force?: false;
reason?: string;
}
| {
force: true;
reason: string;
};
export type ListPullRequestsOptions = {
cwd: string;
query?: string;
limit?: number;
} & ForgeReadOptions;
export type ListIssuesOptions = {
cwd: string;
query?: string;
limit?: number;
} & ForgeReadOptions;
export type GetPullRequestOptions = {
cwd: string;
number: number;
} & ForgeReadOptions;
export type GetPullRequestTimelineOptions = {
cwd: string;
prNumber: number;
repoOwner: string;
repoName: string;
} & ForgeReadOptions;
export type GetCheckDetailsOptions = {
cwd: string;
/**
* GitHub-only: the GitHub adapter addresses the check run by owner/name. The
* GitLab adapter resolves the project from the cwd's remote and ignores these.
*/
repoOwner?: string;
repoName?: string;
/**
* Check-run id. Optional because some checks are addressed only by
* workflowRunId (Gitea Actions runs carry no check-run id). Callers pass at
* least one of checkRunId/workflowRunId.
*/
checkRunId?: number;
workflowRunId?: number;
/**
* GitLab-only: the change request iid. GitLab routes the fetch to the change
* request's head pipeline (`glab ci get --merge-request`), so a fork/detached
* MR pipeline living in the source project resolves instead of 404ing against
* the checkout's target project. GitHub ignores it.
*/
changeRequestNumber?: number;
} & ForgeReadOptions;
export interface CheckAnnotation {
path?: string;
startLine?: number;
endLine?: number;
annotationLevel?: string;
message?: string;
title?: string;
rawDetails?: string;
}
export interface CheckFailedJob {
jobId: number;
name: string;
status?: string | null;
conclusion?: string | null;
url?: string | null;
completedAt?: string;
logTail?: string;
logTruncated?: boolean;
}
/**
* Normalized lifecycle of a CI job/pipeline, neutral across forges. Adapters
* map their forge's raw status strings onto this; readers keep the raw string
* too (see {@link PipelineJob.rawStatus}) for display fidelity.
*/
export type PipelineJobStatus =
| "success"
| "failed"
| "running"
| "pending"
| "canceled"
| "skipped"
| "manual"
| "created"
| "unknown";
export interface PipelineJob {
id: number;
name: string;
stage: string;
status: PipelineJobStatus;
rawStatus: string;
url: string | null;
allowFailure: boolean;
durationSeconds: number | null;
}
export interface PipelineStage {
name: string;
status: PipelineJobStatus;
jobs: PipelineJob[];
}
/**
* A CI pipeline as a stage → job tree. Forges that model CI as a pipeline
* (GitLab) populate this; forges that model it as flat check runs (GitHub)
* leave {@link CheckDetails.pipeline} undefined and use the check-run fields.
*/
export interface PipelineDetails {
id: number;
status: PipelineJobStatus;
rawStatus: string;
url: string | null;
ref: string | null;
sha: string | null;
stages: PipelineStage[];
}
export interface CheckDetails {
checkRunId: number;
workflowRunId?: number | null;
name: string;
status?: string | null;
conclusion?: string | null;
url?: string | null;
detailsUrl?: string | null;
output?: {
title?: string | null;
summary?: string | null;
text?: string | null;
} | null;
annotations: CheckAnnotation[];
failedJobs: CheckFailedJob[];
truncated: boolean;
/**
* Structured pipeline (stages → jobs) for forges that model CI as a pipeline.
* GitLab populates it; GitHub leaves it undefined and keeps using the flat
* check-run fields above.
*/
pipeline?: PipelineDetails | null;
}
export interface SearchResult {
items: Array<{
kind: "issue" | "change_request";
forge?: string;
number: number;
title: string;
url: string;
state: string;
body: string | null;
labels: string[];
projectPath?: string;
baseRefName?: string | null;
headRefName?: string | null;
updatedAt?: string;
}>;
featuresEnabled: boolean;
authState: ForgeAuthState;
/**
* COMPAT(githubFeaturesEnabled): added in v0.1.106, remove after 2026-12-28.
*/
githubFeaturesEnabled?: boolean;
}
export function createUnavailableSearchResult(
authState: Exclude<ForgeAuthState, "authenticated">,
): SearchResult {
return {
items: [],
featuresEnabled: false,
authState,
githubFeaturesEnabled: false,
};
}
export type SearchIssuesAndPrsOptions = {
cwd: string;
query: string;
limit?: number;
kinds?: ForgeSearchRequestKind[];
} & ForgeReadOptions;
export interface CreatePullRequestOptions {
cwd: string;
title: string;
head: string;
base: string;
body?: string;
}
export interface ForgeService {
listPullRequests(options: ListPullRequestsOptions): Promise<PullRequestSummary[]>;
listIssues(options: ListIssuesOptions): Promise<IssueSummary[]>;
getPullRequest(options: GetPullRequestOptions): Promise<PullRequestSummary>;
getPullRequestHeadRef(options: GetPullRequestOptions): Promise<string>;
getPullRequestCheckoutTarget(options: GetPullRequestOptions): Promise<PullRequestCheckoutTarget>;
/**
* Refs to fetch for a change-request checkout when the resolved checkout
* target carries none. Adapters that expose a universal change-request head
* ref (GitHub's refs/pull/N/head) return it here; others let the shell fall
* back to the head branch.
*/
defaultCheckoutRefs?(params: {
changeRequestNumber: number;
headRef: string;
}): PullRequestCheckoutRef[];
/**
* Local branch name for a checked-out change request when the adapter
* disambiguates cross-repository heads (GitHub prefixes the fork owner).
* Returns undefined to keep the head ref name as-is.
*/
buildPrLocalBranchName?(params: {
headRef: string;
checkoutTarget: PullRequestCheckoutTarget;
}): string | undefined;
/**
* True when the adapter can fetch a cross-repository change-request head with
* no explicit refs on the checkout target (GitHub via refs/pull/N/head).
* Others reject a cross-repo checkout that carries no refs.
*/
supportsCrossRepoCheckoutWithoutRefs?: boolean;
getCurrentPullRequestStatus(
options: {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
} & ForgeReadOptions,
): Promise<CurrentPullRequestStatus | null>;
getPullRequestTimeline(options: GetPullRequestTimelineOptions): Promise<PullRequestTimeline>;
getCheckDetails(options: GetCheckDetailsOptions): Promise<CheckDetails>;
searchIssuesAndPrs(options: SearchIssuesAndPrsOptions): Promise<SearchResult>;
createPullRequest(options: CreatePullRequestOptions): Promise<PullRequestCreateResult>;
mergePullRequest(options: MergePullRequestOptions): Promise<PullRequestMergeResult>;
enablePullRequestAutoMerge(
options: EnablePullRequestAutoMergeOptions,
): Promise<PullRequestAutoMergeResult>;
disablePullRequestAutoMerge(
options: DisablePullRequestAutoMergeOptions,
): Promise<PullRequestAutoMergeResult>;
/**
* Check whether the adapter can authenticate against the forge for `cwd`.
* An adapter picks exactly one of two contracts:
*
* - Resolve to `false` on any auth failure (missing CLI, expired token,
* unreachable host, ...) and never reject. This is the default contract;
* most adapters should implement it this way.
* - Reject with a classified error distinguishing the failure kind (e.g.
* "CLI not installed" vs "not logged in") instead of collapsing
* everything to `false`. An adapter that does this MUST also set
* {@link authProbeCanThrow} to `true`, or callers have no way to know the
* rejection is meaningful and will treat it as an unhandled error.
*/
isAuthenticated(options: { cwd: string } & ForgeReadOptions): Promise<boolean>;
/**
* True when isAuthenticated() throws a classified error (missing CLI /
* auth failure) on failure instead of just resolving to false. Callers that
* want the precise failure kind before falling back to a PR-status lookup
* (which can't distinguish "unauthenticated" from "no PR found") should
* only probe isAuthenticated() when this is set — otherwise the call
* cannot change the outcome and is pure overhead.
*/
authProbeCanThrow?: boolean;
retainCurrentPullRequestStatusPoll?(options: {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
onStatus?: (status: CurrentPullRequestStatus | null) => void;
onError?: (error: unknown) => void;
}): { unsubscribe: () => void };
invalidate(options: { cwd: string }): void;
dispose?(): void;
}
/** Parse an optional ISO timestamp to epoch ms, 0 when absent or unparseable. */
export function parseOptionalTime(value: string | null | undefined): number {
if (!value) {
return 0;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
/** Stable timeline ordering shared by every adapter: by createdAt, then id. */
export function compareTimelineItems(
left: PullRequestTimelineItem,
right: PullRequestTimelineItem,
): number {
if (left.createdAt !== right.createdAt) {
return left.createdAt - right.createdAt;
}
return left.id.localeCompare(right.id);
}
@@ -0,0 +1,17 @@
import type { ForgeSpecificStatusFacts } from "./forge-service.js";
export interface GiteaStatusFacts {
mergeable: boolean;
hasMerged: boolean;
ciStatus: string | null;
}
export type GiteaForgeSpecificStatusFacts = ForgeSpecificStatusFacts & {
forge: "gitea";
} & GiteaStatusFacts;
export function isGiteaStatusFacts(
facts: ForgeSpecificStatusFacts | null | undefined,
): facts is GiteaForgeSpecificStatusFacts {
return facts?.forge === "gitea";
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
import type { ForgeSpecificStatusFacts } from "./forge-service.js";
export interface GitHubPullRequestStatusFacts {
mergeStateStatus: string | null;
autoMergeRequest: {
enabledAt: string | null;
mergeMethod: string | null;
enabledBy: string | null;
} | null;
viewerCanEnableAutoMerge: boolean;
viewerCanDisableAutoMerge: boolean;
viewerCanMergeAsAdmin: boolean;
viewerCanUpdateBranch: boolean;
repository: {
autoMergeAllowed: boolean;
mergeCommitAllowed: boolean;
squashMergeAllowed: boolean;
rebaseMergeAllowed: boolean;
viewerDefaultMergeMethod: string | null;
};
isMergeQueueEnabled: boolean;
isInMergeQueue: boolean;
}
export type GitHubForgeSpecificStatusFacts = ForgeSpecificStatusFacts & {
forge: "github";
} & GitHubPullRequestStatusFacts;
export function isGitHubPullRequestStatusFacts(
facts: ForgeSpecificStatusFacts | null | undefined,
): facts is GitHubForgeSpecificStatusFacts {
return facts?.forge === "github";
}
@@ -1,7 +1,7 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { delimiter, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
GitHubAuthenticationError,
@@ -9,11 +9,12 @@ import {
GitHubCommandError,
computeGithubNextInterval,
createGitHubService,
resolveGitHubRepo,
type GitHubCommandRunner,
type GitHubCommandRunnerOptions,
type GitHubCurrentPullRequestStatus,
type CurrentPullRequestStatus,
type GitHubPullRequestStatusFacts,
} from "./github-service.js";
import { isPlatform } from "../test-utils/platform.js";
import { CheckoutPrStatusResponseSchema } from "@getpaseo/protocol/messages";
const EXPECTED_GITHUB_FAST_POLL_MS = 20_000;
@@ -77,6 +78,12 @@ function createScriptedRunner(steps: RunnerStep[]): TestRunner {
};
}
async function flushMicrotasks(): Promise<void> {
for (let i = 0; i < 10; i++) {
await Promise.resolve();
}
}
function createDeferredRunner(): TestRunner {
const calls: RunnerCall[] = [];
let resolveNext: ((stdout: string) => void) | null = null;
@@ -98,6 +105,99 @@ function createDeferredRunner(): TestRunner {
};
}
interface FakeGitHubCliFixture {
cwd: string;
logPath: string;
dispose: () => void;
}
function createFakeGitHubCliFixture(input: {
remoteUrl: string;
authStatusSucceeds?: boolean;
authStatusMode?: "success" | "unauthenticated" | "transient-once";
}): FakeGitHubCliFixture {
const previousPath = process.env.PATH;
const previousLog = process.env.GH_TEST_LOG;
const tempDir = mkdtempSync(join(tmpdir(), "github-service-gh-host-"));
const repoDir = join(tempDir, "repo");
const binDir = join(tempDir, "bin");
const logPath = join(tempDir, "gh.log");
const authStatusMode =
input.authStatusMode ?? (input.authStatusSucceeds === true ? "success" : "unauthenticated");
const authStatusStatePath = join(tempDir, "auth-status-state");
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["-C", repoDir, "remote", "add", "origin", input.remoteUrl], {
stdio: "pipe",
});
mkdirSync(binDir, { recursive: true });
const ghPath = join(binDir, "gh");
writeFileSync(
ghPath,
`#!/bin/sh
AUTH_STATUS_MODE=${JSON.stringify(authStatusMode)}
AUTH_STATUS_STATE=${JSON.stringify(authStatusStatePath)}
printf '%s|%s\\n' "$*" "\${GH_HOST:-}" >> "$GH_TEST_LOG"
if [ "$1" = "--version" ]; then
echo "gh version test"
exit 0
fi
if [ "$1" = "auth" ] && [ "$2" = "status" ] && [ "$3" = "--hostname" ]; then
case "$AUTH_STATUS_MODE" in
success)
exit 0
;;
transient-once)
if [ ! -f "$AUTH_STATUS_STATE" ]; then
touch "$AUTH_STATUS_STATE"
echo "request timed out" >&2
exit 2
fi
exit 0
;;
*)
echo "gh auth login required" >&2
exit 1
;;
esac
fi
if [ "$1" = "repo" ] && [ "$2" = "view" ]; then
echo '{"owner":{"login":"acme"},"name":"repo"}'
exit 0
fi
if [ "$1" = "api" ]; then
echo '{"url":"https://github.acme.internal/acme/repo/pull/7","number":7}'
exit 0
fi
echo "unexpected gh args: $*" >&2
exit 1
`,
);
chmodSync(ghPath, 0o755);
process.env.PATH = [binDir, previousPath].filter(Boolean).join(delimiter);
process.env.GH_TEST_LOG = logPath;
return {
cwd: repoDir,
logPath,
dispose: () => {
if (previousPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = previousPath;
}
if (previousLog === undefined) {
delete process.env.GH_TEST_LOG;
} else {
process.env.GH_TEST_LOG = previousLog;
}
rmSync(tempDir, { recursive: true, force: true });
},
};
}
function readFakeGitHubCliLog(logPath: string): string[] {
return readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean);
}
function currentPullRequestJson(overrides: Record<string, unknown> = {}): string {
return JSON.stringify({
number: 42,
@@ -140,8 +240,8 @@ function currentPullRequestGithubFactsJson(overrides: Record<string, unknown> =
}
function createCurrentPullRequestStatus(
overrides: Partial<GitHubCurrentPullRequestStatus> = {},
): GitHubCurrentPullRequestStatus {
overrides: Partial<CurrentPullRequestStatus> = {},
): CurrentPullRequestStatus {
return {
number: 42,
repoOwner: "acme",
@@ -162,9 +262,10 @@ function createCurrentPullRequestStatus(
}
function githubStatusFacts(
overrides: Partial<NonNullable<GitHubCurrentPullRequestStatus["github"]>> = {},
): NonNullable<GitHubCurrentPullRequestStatus["github"]> {
overrides: Partial<GitHubPullRequestStatusFacts> = {},
): GitHubPullRequestStatusFacts & { forge: "github" } {
return {
forge: "github",
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
@@ -348,7 +449,7 @@ function pullRequestTimelineJson(overrides: Record<string, unknown> = {}): strin
});
}
describe("GitHubService", () => {
describe("ForgeService", () => {
beforeEach(() => {
vi.useFakeTimers();
});
@@ -373,7 +474,7 @@ describe("GitHubService", () => {
prNumber: 42,
mergeMethod,
status: createCurrentPullRequestStatus({
github: githubStatusFacts(),
forgeSpecific: githubStatusFacts(),
}),
}),
).resolves.toEqual({ success: true });
@@ -419,7 +520,7 @@ describe("GitHubService", () => {
prNumber: 42,
mergeMethod: "squash",
status: createCurrentPullRequestStatus({
github: githubStatusFacts({ mergeStateStatus }),
forgeSpecific: githubStatusFacts({ mergeStateStatus }),
}),
}),
).rejects.toThrow("ready for direct merge");
@@ -443,7 +544,7 @@ describe("GitHubService", () => {
prNumber: 42,
mergeMethod: "squash",
status: createCurrentPullRequestStatus({
github: githubStatusFacts(overrides),
forgeSpecific: githubStatusFacts(overrides),
}),
}),
).rejects.toThrow("merge queue");
@@ -463,7 +564,7 @@ describe("GitHubService", () => {
prNumber: 42,
mergeMethod: "squash",
status: createCurrentPullRequestStatus({
github: githubStatusFacts({
forgeSpecific: githubStatusFacts({
autoMergeRequest: {
enabledAt: "2026-05-13T12:00:00Z",
mergeMethod: "SQUASH",
@@ -489,7 +590,7 @@ describe("GitHubService", () => {
prNumber: 42,
mergeMethod: "squash",
status: createCurrentPullRequestStatus({
github: githubStatusFacts({
forgeSpecific: githubStatusFacts({
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
@@ -521,7 +622,7 @@ describe("GitHubService", () => {
prNumber: 42,
mergeMethod,
status: createCurrentPullRequestStatus({
github: githubStatusFacts({
forgeSpecific: githubStatusFacts({
mergeStateStatus: "BLOCKED",
viewerCanEnableAutoMerge: true,
repository: {
@@ -556,7 +657,7 @@ describe("GitHubService", () => {
cwd: "/tmp/repo",
prNumber: 42,
status: createCurrentPullRequestStatus({
github: githubStatusFacts({
forgeSpecific: githubStatusFacts({
autoMergeRequest: {
enabledAt: "2026-05-13T12:00:00Z",
mergeMethod: "SQUASH",
@@ -614,6 +715,7 @@ describe("GitHubService", () => {
number: 526,
baseRefName: "main",
headRefName: "main",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/526/head" }],
headOwnerLogin: "therainisme",
headRepositorySshUrl: "git@github.com:therainisme/paseo.git",
headRepositoryUrl: "https://github.com/therainisme/paseo",
@@ -632,6 +734,28 @@ describe("GitHubService", () => {
expect(runner.calls[1]?.args).toContain("number=526");
});
it("populates repoOwner/repoName from a GitHub Enterprise PR URL", async () => {
const runner = createRunner([
currentPullRequestJson({ url: "https://github.acme.internal/acme/repo/pull/42" }),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 0,
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
});
expect(status).toMatchObject({
repoOwner: "acme",
repoName: "repo",
url: "https://github.acme.internal/acme/repo/pull/42",
});
});
it("polls PR status at fast cadence while checks are pending", async () => {
let now = 0;
const runner = createRunner([
@@ -1639,6 +1763,7 @@ describe("GitHubService", () => {
ttlMs: 1_000,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
now: () => 100,
});
const request = {
@@ -1649,7 +1774,7 @@ describe("GitHubService", () => {
};
const staleRequest = service.getPullRequestTimeline(request);
await Promise.resolve();
await flushMicrotasks();
expect(runner.calls).toHaveLength(1);
service.invalidate({ cwd: "/repo" });
@@ -1673,7 +1798,7 @@ describe("GitHubService", () => {
expect(stale.items.at(-1)?.body).toBe("Stale pre-invalidation result");
const freshRequest = service.getPullRequestTimeline(request);
await Promise.resolve();
await flushMicrotasks();
expect(runner.calls).toHaveLength(2);
runner.resolveNext(
pullRequestTimelineJson({
@@ -1862,13 +1987,13 @@ describe("GitHubService", () => {
now: () => 100,
});
const first = await service.getGitHubCheckDetails({
const first = await service.getCheckDetails({
cwd: "/repo",
repoOwner: "acme",
repoName: "repo",
checkRunId: 12345,
});
const second = await service.getGitHubCheckDetails({
const second = await service.getCheckDetails({
cwd: "/repo",
repoOwner: "acme",
repoName: "repo",
@@ -1959,7 +2084,7 @@ describe("GitHubService", () => {
now: () => 100,
});
const details = await service.getGitHubCheckDetails({
const details = await service.getCheckDetails({
cwd: "/repo",
repoOwner: "acme",
repoName: "repo",
@@ -2019,7 +2144,7 @@ describe("GitHubService", () => {
now: () => 100,
});
const details = await service.getGitHubCheckDetails({
const details = await service.getCheckDetails({
cwd: "/repo",
repoOwner: "acme",
repoName: "repo",
@@ -2057,7 +2182,7 @@ describe("GitHubService", () => {
now: () => 100,
});
const details = await service.getGitHubCheckDetails({
const details = await service.getCheckDetails({
cwd: "/repo",
repoOwner: "acme",
repoName: "repo",
@@ -2167,7 +2292,8 @@ describe("GitHubService", () => {
},
],
checksStatus: "pending",
github: {
forgeSpecific: {
forge: "github",
mergeStateStatus: "BLOCKED",
autoMergeRequest: null,
viewerCanEnableAutoMerge: true,
@@ -2588,6 +2714,7 @@ describe("GitHubService", () => {
headRefName: "feature",
isMerged: false,
},
featuresEnabled: true,
githubFeaturesEnabled: true,
error: null,
requestId: "req-old",
@@ -2626,6 +2753,7 @@ describe("GitHubService", () => {
checksStatus: "success",
reviewDecision: "pending",
},
featuresEnabled: true,
githubFeaturesEnabled: true,
error: null,
requestId: "req-new",
@@ -2633,6 +2761,7 @@ describe("GitHubService", () => {
});
expect(newDaemonResponse.payload.status).toEqual({
forge: "github",
number: 42,
url: "https://github.com/acme/repo/pull/42",
title: "New daemon payload",
@@ -2701,12 +2830,13 @@ describe("GitHubService", () => {
ttlMs: 1_000,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
now: () => 100,
});
const first = service.listPullRequests({ cwd: "/repo", query: "bug", limit: 10 });
const second = service.listPullRequests({ cwd: "/repo", query: "bug", limit: 10 });
await Promise.resolve();
await flushMicrotasks();
runner.resolveNext(pullRequestJson("Shared result"));
await expect(Promise.all([first, second])).resolves.toEqual([
@@ -2818,6 +2948,32 @@ describe("GitHubService", () => {
});
});
it("throws a typed command error for malformed JSON output", async () => {
const runner = createRunner(["not-json"]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
await expect(service.listPullRequests({ cwd: "/repo" })).rejects.toMatchObject({
kind: "command-error",
args: [
"pr",
"list",
"--search",
"",
"--json",
"number,title,url,state,body,labels,baseRefName,headRefName,updatedAt",
"--limit",
"20",
],
cwd: "/repo",
exitCode: null,
stderr: "gh did not return valid JSON (8 bytes)",
});
});
it("searches GitHub issues and PRs", async () => {
const runner = createRunner([issueJson("Issue title"), searchPullRequestJson("PR title")]);
const service = createGitHubService({
@@ -2829,10 +2985,12 @@ describe("GitHubService", () => {
await expect(
service.searchIssuesAndPrs({ cwd: "/repo", query: "cache", limit: 5 }),
).resolves.toEqual({
featuresEnabled: true,
authState: "authenticated",
githubFeaturesEnabled: true,
items: [
{
kind: "pr",
kind: "change_request",
number: 123,
title: "PR title",
url: "https://github.com/acme/repo/pull/123",
@@ -2926,6 +3084,84 @@ describe("GitHubService", () => {
]);
});
it("does not treat an unrelated tracker URL as a GitHub issue/PR number", async () => {
const runner = createRunner([issueJson("Issue title"), searchPullRequestJson("PR title")]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
now: () => 100,
});
await service.searchIssuesAndPrs({
cwd: "/repo",
query: "https://gitlab.com/getpaseo/paseo/issues/793",
limit: 5,
});
expect(runner.calls.map((call) => call.args)).toEqual([
[
"issue",
"list",
"--search",
"https://gitlab.com/getpaseo/paseo/issues/793",
"--json",
"number,title,url,state,body,labels,updatedAt",
"--limit",
"5",
],
[
"pr",
"list",
"--search",
"https://gitlab.com/getpaseo/paseo/issues/793",
"--json",
"number,title,url,state,body,labels,baseRefName,headRefName,updatedAt",
"--limit",
"5",
],
]);
});
it("treats a GitHub Enterprise issue/PR URL as a search for that number", async () => {
const runner = createRunner([issueJson("Issue title"), searchPullRequestJson("PR title")]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => "github.acme.internal",
now: () => 100,
});
await service.searchIssuesAndPrs({
cwd: "/repo",
query: "https://github.acme.internal/getpaseo/paseo/pull/793",
limit: 5,
});
expect(runner.calls.map((call) => call.args)).toEqual([
[
"issue",
"list",
"--search",
"793",
"--json",
"number,title,url,state,body,labels,updatedAt",
"--limit",
"5",
],
[
"pr",
"list",
"--search",
"793",
"--json",
"number,title,url,state,body,labels,baseRefName,headRefName,updatedAt",
"--limit",
"5",
],
]);
});
it("searches only GitHub PRs when the search kinds request excludes issues", async () => {
const runner = createRunner([searchPullRequestJson("PR title")]);
const service = createGitHubService({
@@ -2942,10 +3178,12 @@ describe("GitHubService", () => {
kinds: ["github-pr"],
}),
).resolves.toEqual({
featuresEnabled: true,
authState: "authenticated",
githubFeaturesEnabled: true,
items: [
{
kind: "pr",
kind: "change_request",
number: 123,
title: "PR title",
url: "https://github.com/acme/repo/pull/123",
@@ -3030,12 +3268,13 @@ describe("GitHubService", () => {
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
now: () => 100,
});
const first = service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
const second = service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
await Promise.resolve();
await flushMicrotasks();
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
runner.resolveNext(currentPullRequestJson());
@@ -3064,32 +3303,298 @@ describe("GitHubService", () => {
headRef: "feature/fork",
force: true,
} as never),
).rejects.toThrow("GitHubService forced read requires a reason");
).rejects.toThrow("ForgeService forced read requires a reason");
});
it("type: force true requires a reason", () => {
// @ts-expect-error force: true requires reason
const invalid: GitHubReadOptions = { force: true };
const valid: GitHubReadOptions = { force: true, reason: "test" };
const invalid: ForgeReadOptions = { force: true };
const valid: ForgeReadOptions = { force: true, reason: "test" };
expect(invalid.force).toBe(true);
expect(valid.reason).toBe("test");
});
it("resolves GitHub repos from the origin remote URL", async () => {
vi.useRealTimers();
const cwd = mkdtempSync(join(tmpdir(), "github-service-repo-"));
it("resolves the repo slug from the workspace when creating a pull request", async () => {
const runner = createRunner([
JSON.stringify({ owner: { login: "acme" }, name: "repo" }),
JSON.stringify({ url: "https://github.com/acme/repo/pull/7", number: 7 }),
]);
const service = createGitHubService({
runner: runner.runner,
resolveRepoHost: async () => null,
});
try {
execFileSync("git", ["init", "-b", "main"], { cwd, stdio: "ignore" });
execFileSync("git", ["remote", "add", "origin", "git@github.com:getpaseo/paseo.git"], {
cwd,
stdio: "ignore",
await expect(
service.createPullRequest({
cwd: "/tmp/repo",
title: "Add thing",
head: "feature",
base: "main",
}),
).resolves.toEqual({ url: "https://github.com/acme/repo/pull/7", number: 7 });
expect(runner.calls[0]?.args).toEqual(["repo", "view", "--json", "owner,name,parent"]);
expect(runner.calls[1]?.args).toEqual([
"api",
"-X",
"POST",
"repos/acme/repo/pulls",
"-f",
"title=Add thing",
"-f",
"head=feature",
"-f",
"base=main",
]);
});
it("routes gh calls to the resolved GitHub Enterprise host via GH_HOST", async () => {
const runner = createRunner([
JSON.stringify({ owner: { login: "acme" }, name: "repo" }),
JSON.stringify({ url: "https://github.acme.internal/acme/repo/pull/7", number: 7 }),
]);
const service = createGitHubService({
runner: runner.runner,
resolveRepoHost: async () => "github.acme.internal",
});
await expect(
service.createPullRequest({
cwd: "/tmp/repo",
title: "Add thing",
head: "feature",
base: "main",
}),
).resolves.toEqual({ url: "https://github.acme.internal/acme/repo/pull/7", number: 7 });
expect(runner.calls[1]?.args).toEqual([
"api",
"-X",
"POST",
"repos/acme/repo/pulls",
"-f",
"title=Add thing",
"-f",
"head=feature",
"-f",
"base=main",
]);
// GH_HOST is injected on every gh call (the slug lookup and the POST alike).
expect(runner.calls[0]?.envOverlay).toMatchObject({ GH_HOST: "github.acme.internal" });
expect(runner.calls[1]?.envOverlay).toMatchObject({ GH_HOST: "github.acme.internal" });
});
it.skipIf(isPlatform("win32"))(
"routes default gh calls to GitHub Enterprise only after host auth succeeds",
async () => {
vi.useRealTimers();
const fixture = createFakeGitHubCliFixture({
remoteUrl: "https://github.acme.internal/acme/repo.git",
authStatusSucceeds: true,
});
try {
const service = createGitHubService();
await expect(resolveGitHubRepo(cwd)).resolves.toBe("getpaseo/paseo");
await expect(
service.createPullRequest({
cwd: fixture.cwd,
title: "Add thing",
head: "feature",
base: "main",
}),
).resolves.toEqual({ url: "https://github.acme.internal/acme/repo/pull/7", number: 7 });
expect(readFakeGitHubCliLog(fixture.logPath)).toEqual(
expect.arrayContaining([
"auth status --hostname github.acme.internal|",
"repo view --json owner,name,parent|github.acme.internal",
"api -X POST repos/acme/repo/pulls -f title=Add thing -f head=feature -f base=main|github.acme.internal",
]),
);
} finally {
fixture.dispose();
}
},
);
it.skipIf(isPlatform("win32"))(
"retries GitHub Enterprise host probing after a transient auth-status failure",
async () => {
vi.useRealTimers();
const fixture = createFakeGitHubCliFixture({
remoteUrl: "https://github.acme.internal/acme/repo.git",
authStatusMode: "transient-once",
});
try {
const service = createGitHubService();
await expect(
service.createPullRequest({
cwd: fixture.cwd,
title: "Add thing",
head: "feature",
base: "main",
}),
).rejects.toThrow("Unable to verify GitHub Enterprise host github.acme.internal");
await expect(
service.createPullRequest({
cwd: fixture.cwd,
title: "Add thing",
head: "feature",
base: "main",
}),
).resolves.toEqual({ url: "https://github.acme.internal/acme/repo/pull/7", number: 7 });
const log = readFakeGitHubCliLog(fixture.logPath);
expect(
log.filter((line) => line === "auth status --hostname github.acme.internal|"),
).toHaveLength(2);
expect(log).toEqual(
expect.arrayContaining([
"repo view --json owner,name,parent|github.acme.internal",
"api -X POST repos/acme/repo/pulls -f title=Add thing -f head=feature -f base=main|github.acme.internal",
]),
);
} finally {
fixture.dispose();
}
},
);
it.skipIf(isPlatform("win32"))(
"fails instead of routing gh calls to github.com for an unauthenticated Enterprise host",
async () => {
vi.useRealTimers();
const fixture = createFakeGitHubCliFixture({
remoteUrl: "https://github.acme.internal/acme/repo.git",
authStatusSucceeds: false,
});
try {
const service = createGitHubService();
// An unauthenticated Enterprise host must fail, not silently proceed
// against github.com (the default when GH_HOST is unset).
await expect(
service.createPullRequest({
cwd: fixture.cwd,
title: "Add thing",
head: "feature",
base: "main",
}),
).rejects.toThrow(/authentication failed/i);
const log = readFakeGitHubCliLog(fixture.logPath);
expect(log).toContain("auth status --hostname github.acme.internal|");
// It threw at host resolution, before running any api/graphql call.
expect(log.some((line) => line.startsWith("api ") || line.startsWith("graphql"))).toBe(
false,
);
} finally {
fixture.dispose();
}
},
);
it.skipIf(isPlatform("win32"))("does not probe or route github.com through GH_HOST", async () => {
vi.useRealTimers();
const fixture = createFakeGitHubCliFixture({
remoteUrl: "https://github.com/acme/repo.git",
authStatusSucceeds: false,
});
try {
const service = createGitHubService();
await expect(
service.createPullRequest({
cwd: fixture.cwd,
title: "Add thing",
head: "feature",
base: "main",
}),
).resolves.toEqual({ url: "https://github.acme.internal/acme/repo/pull/7", number: 7 });
const log = readFakeGitHubCliLog(fixture.logPath);
expect(log.some((line) => line.startsWith("auth status --hostname "))).toBe(false);
expect(log.every((line) => line.endsWith("|"))).toBe(true);
} finally {
rmSync(cwd, { recursive: true, force: true });
fixture.dispose();
}
});
it("caches definitive null repo host resolutions", async () => {
const runner = createRunner(["[]", "[]"]);
let hostResolutions = 0;
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => {
hostResolutions += 1;
return null;
},
});
await service.listPullRequests({ cwd: "/repo", query: "one", limit: 1 });
await service.listPullRequests({ cwd: "/repo", query: "two", limit: 1 });
expect(hostResolutions).toBe(1);
expect(runner.calls.every((call) => call.envOverlay?.GH_HOST === undefined)).toBe(true);
});
it("re-resolves a null repo host after the TTL expires", async () => {
const runner = createRunner(["[]", "[]"]);
let hostResolutions = 0;
let nowMs = 0;
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => nowMs,
resolveRepoHost: async () => {
hostResolutions += 1;
return hostResolutions === 1 ? null : "ghe.example.com";
},
});
await service.listPullRequests({ cwd: "/repo", query: "one", limit: 1 });
expect(runner.calls[0]?.envOverlay?.GH_HOST).toBeUndefined();
nowMs = 60_001;
await service.listPullRequests({ cwd: "/repo", query: "two", limit: 1 });
expect(hostResolutions).toBe(2);
expect(runner.calls[1]?.envOverlay).toMatchObject({ GH_HOST: "ghe.example.com" });
});
it("re-resolves the host after invalidation when the remote changes", async () => {
const runner = createRunner([]);
let hostCall = 0;
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => (hostCall++ === 0 ? "host-a.internal" : "host-b.internal"),
});
await service.listPullRequests({ cwd: "/repo", query: "x", limit: 1 });
expect(runner.calls[0]?.envOverlay).toMatchObject({ GH_HOST: "host-a.internal" });
service.invalidate({ cwd: "/repo" });
await service.listPullRequests({ cwd: "/repo", query: "x", limit: 1 });
expect(runner.calls[1]?.envOverlay).toMatchObject({ GH_HOST: "host-b.internal" });
});
it("throws when the workspace repository cannot be resolved for pull request creation", async () => {
const runner = createRunner([JSON.stringify({})]);
const service = createGitHubService({ runner: runner.runner });
await expect(
service.createPullRequest({
cwd: "/tmp/repo",
title: "Add thing",
head: "feature",
base: "main",
}),
).rejects.toThrow("Unable to resolve GitHub repository for pull request creation");
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
import type { ForgeSpecificStatusFacts } from "./forge-service.js";
export interface GitLabStatusFacts {
detailedMergeStatus: string | null;
/**
* Legacy `merge_status` (pre-15.6 GitLab), used as the direct-merge readiness
* signal when `detailedMergeStatus` is absent on older self-managed instances.
*/
mergeStatus: string | null;
hasConflicts: boolean;
blockingDiscussionsResolved: boolean;
approvalsRequired: number;
approvalsGiven: number;
pipelineStatus: string | null;
/**
* Id of the MR's head pipeline, used to fetch the full pipeline (stages ->
* jobs) on demand. Null when the MR has no pipeline yet.
*/
pipelineId: number | null;
pipelineUrl: string | null;
mergeWhenPipelineSucceeds: boolean;
}
// Client-side twin: packages/app/src/git/forges/gitlab.ts
// (GITLAB_ACTIVE_PIPELINE_STATUSES / isPipelineActiveStatus). Duplicated because
// the app can't depend on the server package; keep the two lists in sync by hand
// if GitLab adds a new active pipeline status.
export const GITLAB_ACTIVE_PIPELINE_STATUSES = [
"created",
"waiting_for_resource",
"preparing",
"pending",
"running",
"scheduled",
] as const;
export type GitLabActivePipelineStatus = (typeof GITLAB_ACTIVE_PIPELINE_STATUSES)[number];
export const GITLAB_ACTIVE_PIPELINE_STATUS_SET = new Set<string>(GITLAB_ACTIVE_PIPELINE_STATUSES);
export type GitLabForgeSpecificStatusFacts = ForgeSpecificStatusFacts & {
forge: "gitlab";
} & GitLabStatusFacts;
export function isGitLabStatusFacts(
facts: ForgeSpecificStatusFacts | null | undefined,
): facts is GitLabForgeSpecificStatusFacts {
return facts?.forge === "gitlab";
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+111 -11
View File
@@ -17,6 +17,7 @@ import {
__resetPullRequestStatusCacheForTests,
__setPullRequestStatusCacheTtlForTests,
commitAll,
createPullRequest,
getCachedCheckoutShortstat,
getCheckoutSnapshotFacts,
getCurrentBranch,
@@ -42,12 +43,10 @@ import {
warmCheckoutShortstatInBackground,
} from "./checkout-git.js";
import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js";
import {
GitHubCommandError,
GitHubCliMissingError,
type GitHubCurrentPullRequestStatus,
type GitHubService,
} from "../services/github-service.js";
import { createForgeResolver } from "../services/forge-resolver.js";
import { GitHubCommandError, GitHubCliMissingError } from "../services/github-service.js";
import type { CurrentPullRequestStatus, ForgeService } from "../services/forge-service.js";
import { TeaAuthenticationError, TeaCliMissingError } from "../services/gitea-service.js";
import {
createWorktree as createWorktreePrimitive,
type CreateWorktreeOptions,
@@ -102,9 +101,9 @@ function sleep(ms: number): Promise<void> {
}
function createGitHubServiceForStatus(
status: GitHubCurrentPullRequestStatus | null,
status: CurrentPullRequestStatus | null,
options?: { onStatus?: () => void },
): GitHubService {
): ForgeService {
return {
listPullRequests: async () => [],
listIssues: async () => [],
@@ -120,6 +119,15 @@ function createGitHubServiceForStatus(
labels: [],
}),
getPullRequestHeadRef: async () => "feature",
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: "feature",
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
}),
getCurrentPullRequestStatus: async () => {
options?.onStatus?.();
return status;
@@ -134,7 +142,7 @@ function createGitHubServiceForStatus(
};
}
function createPullRequestStatus(overrides?: Partial<GitHubCurrentPullRequestStatus>) {
function createPullRequestStatus(overrides?: Partial<CurrentPullRequestStatus>) {
return {
url: "https://github.com/getpaseo/paseo/pull/123",
title: "Ship feature",
@@ -156,12 +164,12 @@ interface RequestedPullRequestTarget {
interface RecordingPullRequestTargetsOptions {
requestedTargets: RequestedPullRequestTarget[];
statusOverrides?: Partial<GitHubCurrentPullRequestStatus>;
statusOverrides?: Partial<CurrentPullRequestStatus>;
}
function createGitHubServiceRecordingPullRequestTargets(
options: RecordingPullRequestTargetsOptions,
): GitHubService {
): ForgeService {
const github = createGitHubServiceForStatus(null);
github.getCurrentPullRequestStatus = async (request) => {
options.requestedTargets.push({
@@ -245,6 +253,60 @@ describe("checkout git utilities", () => {
);
});
it("creates a pull request via the given adapter without resolving a repo slug in the shell", async () => {
setupRemoteTrackingMain(repoDir, tempDir);
let adapterReached = false;
const adapter = createGitHubServiceForStatus(null);
adapter.createPullRequest = async () => {
adapterReached = true;
return { url: "https://gitlab.com/group/proj/-/merge_requests/7", number: 7 };
};
const result = await createPullRequest(
repoDir,
{ title: "Add thing", body: "desc", base: "main" },
adapter,
);
expect(adapterReached).toBe(true);
expect(result).toEqual({
url: "https://gitlab.com/group/proj/-/merge_requests/7",
number: 7,
});
});
it("resolves a gitlab remote through the resolver and creates via the gitlab adapter", async () => {
// origin is a real local bare repo so the push succeeds; the resolver reads a
// gitlab remote URL (injected) so it selects the gitlab adapter from the host.
setupRemoteTrackingMain(repoDir, tempDir);
let adapterReached = false;
const adapter = createGitHubServiceForStatus(null);
adapter.createPullRequest = async () => {
adapterReached = true;
return { url: "https://gitlab.com/group/proj/-/merge_requests/9", number: 9 };
};
const resolver = createForgeResolver({
resolveRemoteUrl: async () => "git@gitlab.com:group/proj.git",
createService: (forge) => (forge === "gitlab" ? adapter : null),
});
const resolution = await resolver.resolve(repoDir);
expect(resolution).toMatchObject({ forge: "gitlab", host: "gitlab.com" });
expect(resolution?.service).toBe(adapter);
const result = await createPullRequest(
repoDir,
{ title: "Add thing", body: "desc", base: "main" },
resolution?.service,
);
expect(adapterReached).toBe(true);
expect(result).toEqual({
url: "https://gitlab.com/group/proj/-/merge_requests/9",
number: 9,
});
});
it("returns null for getCurrentBranch in a repo with no commits", async () => {
const emptyRepo = join(tempDir, "empty-repo");
mkdirSync(emptyRepo, { recursive: true });
@@ -2199,6 +2261,7 @@ const x = 1;
expect(status).toEqual({
githubFeaturesEnabled: true,
authState: "authenticated",
status: {
number: 123,
url: "https://github.com/getpaseo/paseo/pull/123",
@@ -2593,6 +2656,7 @@ const x = 1;
expect(fresh.status?.url).toContain("/pull/123");
expect(cleared).toEqual({
githubFeaturesEnabled: true,
authState: "authenticated",
status: null,
});
expect(callCount).toBe(2);
@@ -2601,6 +2665,42 @@ const x = 1;
}
});
it("maps missing Gitea CLI PR status lookups to cli_missing auth state", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://gitea.example.com/acme/repo.git"], {
cwd: repoDir,
});
const service = createGitHubServiceForStatus(null);
service.getCurrentPullRequestStatus = async () => {
throw new TeaCliMissingError();
};
await expect(getPullRequestStatus(repoDir, service)).resolves.toEqual({
status: null,
authState: "cli_missing",
githubFeaturesEnabled: false,
});
});
it("maps Gitea authentication PR status failures to unauthenticated auth state", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://gitea.example.com/acme/repo.git"], {
cwd: repoDir,
});
const service = createGitHubServiceForStatus(null);
service.getCurrentPullRequestStatus = async () => {
throw new TeaAuthenticationError({ stderr: "401 unauthorized" });
};
await expect(getPullRequestStatus(repoDir, service)).resolves.toEqual({
status: null,
authState: "unauthenticated",
githubFeaturesEnabled: false,
});
});
it("dedupes concurrent PR status lookups for the same cwd", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
+116 -59
View File
@@ -7,21 +7,19 @@ import type { Logger } from "pino";
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
import { parseGitHubRepoFromRemote } from "../server/workspace-git-metadata.js";
import {
GitHubAuthenticationError,
GitHubCliMissingError,
GitHubCommandError,
createGitHubService,
resolveGitHubRepo,
type GitHubCurrentPullRequestStatus,
type GitHubPullRequestStatusFacts,
type GitHubService,
type PullRequestMergeable,
} from "../services/github-service.js";
import { GitHubCommandError, createGitHubService } from "../services/github-service.js";
import type {
CurrentPullRequestStatus,
ForgeAuthState,
ForgeService,
ForgeSpecificStatusFacts,
PullRequestMergeable,
} from "../services/forge-service.js";
import { ForgeAuthenticationError, ForgeCliMissingError } from "../services/forge-cli-command.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { runGitCommand } from "./run-git-command.js";
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
import { readPaseoWorktreeMetadata } from "./worktree-metadata.js";
import { type PaseoWorktreeMetadata, readPaseoWorktreeMetadata } from "./worktree-metadata.js";
const READ_ONLY_GIT_ENV = {
GIT_OPTIONAL_LOCKS: "0",
} as const;
@@ -1626,6 +1624,45 @@ function buildPullRequestLookupTargetFromPushConfig(
};
}
function buildPullRequestLookupTargetFromMetadata(
metadata: PaseoWorktreeMetadata | null,
): PullRequestStatusLookupTarget | null {
const target = metadata?.changeRequestLookupTarget;
if (!target) {
return null;
}
return {
headRef: target.headRef,
...(target.headRepositoryOwner ? { headRepositoryOwner: target.headRepositoryOwner } : {}),
};
}
function buildInitialPullRequestLookupTarget(input: {
currentBranch: string | null;
metadata: PaseoWorktreeMetadata | null;
branchRemoteName: string | null;
branchMergeRef: string | null;
branchRemoteUrl: string | null;
originRemoteUrl: string | null;
resolvedBaseRef: string | null;
}): PullRequestStatusLookupTarget | null {
if (!input.currentBranch) {
return null;
}
return (
buildPullRequestLookupTargetFromMetadata(input.metadata) ??
buildPullRequestLookupTargetFromBranchConfig({
currentBranch: input.currentBranch,
branchRemoteName: input.branchRemoteName,
branchMergeRef: input.branchMergeRef,
branchRemoteUrl: input.branchRemoteUrl,
originRemoteUrl: input.originRemoteUrl,
resolvedBaseRef: input.resolvedBaseRef,
})
);
}
async function resolvePullRequestLookupTargetFromPushConfig(
cwd: string,
currentBranch: string,
@@ -1671,9 +1708,10 @@ export async function getCheckoutSnapshotFacts(
return { isGit: false };
}
const storedBaseRef = inspected.paseoWorktree.isPaseoOwnedWorktree
? readPaseoWorktreeBaseRef(inspected.paseoWorktree.worktreeRoot)
const paseoWorktreeMetadata = inspected.paseoWorktree.isPaseoOwnedWorktree
? readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot)
: null;
const storedBaseRef = paseoWorktreeMetadata?.baseRefName ?? null;
const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd));
const mainRepoRoot = await getMainRepoRootFromCommonDir(
cwd,
@@ -1707,16 +1745,15 @@ export async function getCheckoutSnapshotFacts(
]);
}
}
let pullRequestLookupTarget = inspected.currentBranch
? buildPullRequestLookupTargetFromBranchConfig({
currentBranch: inspected.currentBranch,
branchRemoteName,
branchMergeRef,
branchRemoteUrl,
originRemoteUrl: inspected.remoteUrl,
resolvedBaseRef,
})
: null;
let pullRequestLookupTarget = buildInitialPullRequestLookupTarget({
currentBranch: inspected.currentBranch,
metadata: paseoWorktreeMetadata,
branchRemoteName,
branchMergeRef,
branchRemoteUrl,
originRemoteUrl: inspected.remoteUrl,
resolvedBaseRef,
});
if (
inspected.currentBranch &&
pullRequestLookupTarget?.headRef === inspected.currentBranch &&
@@ -3100,7 +3137,7 @@ async function detectAndThrowMergeFromBaseConflict(
}
}
export async function pullCurrentBranch(cwd: string, github?: GitHubService): Promise<void> {
export async function pullCurrentBranch(cwd: string, forgeService?: ForgeService): Promise<void> {
await requireGitRepo(cwd);
const currentBranch = await getCurrentBranch(cwd);
if (!currentBranch || currentBranch === "HEAD") {
@@ -3112,14 +3149,14 @@ export async function pullCurrentBranch(cwd: string, github?: GitHubService): Pr
}
try {
await runGitCommand(["pull"], { cwd, timeout: 120_000 });
github?.invalidate({ cwd });
forgeService?.invalidate({ cwd });
} catch (error) {
await abortGitPullConflictState(cwd);
throw error;
}
}
export async function pushCurrentBranch(cwd: string, github?: GitHubService): Promise<void> {
export async function pushCurrentBranch(cwd: string, forgeService?: ForgeService): Promise<void> {
await requireGitRepo(cwd);
const currentBranch = await getCurrentBranch(cwd);
if (!currentBranch || currentBranch === "HEAD") {
@@ -3132,7 +3169,7 @@ export async function pushCurrentBranch(cwd: string, github?: GitHubService): Pr
{ cwd, timeout: 120_000 },
);
await refreshCurrentBranchTrackedRefAfterPush(cwd, currentBranch, configuredPushTarget);
github?.invalidate({ cwd });
forgeService?.invalidate({ cwd });
return;
}
@@ -3142,7 +3179,7 @@ export async function pushCurrentBranch(cwd: string, github?: GitHubService): Pr
["push", "-u", upstreamTarget.remoteName, `HEAD:refs/heads/${upstreamTarget.headRef}`],
{ cwd, timeout: 120_000 },
);
github?.invalidate({ cwd });
forgeService?.invalidate({ cwd });
return;
}
@@ -3151,7 +3188,7 @@ export async function pushCurrentBranch(cwd: string, github?: GitHubService): Pr
throw new Error("Remote 'origin' is not configured.");
}
await runGitCommand(["push", "-u", "origin", currentBranch], { cwd, timeout: 120_000 });
github?.invalidate({ cwd });
forgeService?.invalidate({ cwd });
}
async function getCurrentBranchConfiguredPushTarget(
@@ -3266,6 +3303,7 @@ export interface PullRequestStatus {
number?: number;
repoOwner?: string;
repoName?: string;
projectPath?: string;
url: string;
title: string;
state: string;
@@ -3277,14 +3315,41 @@ export interface PullRequestStatus {
checks?: PullRequestCheck[];
checksStatus?: ChecksStatus;
reviewDecision?: ReviewDecision;
github?: GitHubPullRequestStatusFacts;
forgeSpecific?: ForgeSpecificStatusFacts;
}
export interface PullRequestStatusResult {
status: PullRequestStatus | null;
/** Why forge features are (un)available — drives the onboarding callout. */
authState: ForgeAuthState;
/** Kept in sync with {@link authState} for back-compat; true iff authenticated. */
githubFeaturesEnabled: boolean;
}
function buildPullRequestStatusResult(
status: PullRequestStatus | null,
authState: ForgeAuthState,
): PullRequestStatusResult {
return { status, authState, githubFeaturesEnabled: authState === "authenticated" };
}
/** True for the CLI-missing / authentication errors of any supported forge. */
export function isForgeAuthError(error: unknown): boolean {
return error instanceof ForgeCliMissingError || error instanceof ForgeAuthenticationError;
}
/**
* Map a forge CLI failure to an auth state. A missing-CLI error means the user
* must install the tool; anything else surfaced as an auth probe failure means
* they must sign in.
*/
export function forgeAuthStateFromError(error: unknown): ForgeAuthState {
if (error instanceof ForgeCliMissingError) {
return "cli_missing";
}
return "unauthenticated";
}
export interface PullRequestCheck {
name: string;
status: "success" | "failure" | "pending" | "skipped" | "cancelled";
@@ -3300,14 +3365,10 @@ export type ReviewDecision = "approved" | "changes_requested" | "pending" | null
export async function createPullRequest(
cwd: string,
options: CreatePullRequestOptions,
github: GitHubService = createGitHubService(),
forgeService: ForgeService = createGitHubService(),
context?: CheckoutContext,
): Promise<{ url: string; number: number }> {
await requireGitRepo(cwd);
const repo = await resolveGitHubRepo(cwd);
if (!repo) {
throw new Error("Unable to determine GitHub repo from git remote");
}
const head = options.head ?? (await getCurrentBranch(cwd));
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
@@ -3323,23 +3384,28 @@ export async function createPullRequest(
throw new Error(`Base ref mismatch: expected ${base}, got ${options.base}`);
}
// The push deliberately happens before the adapter resolves the target
// repository: slug resolution is adapter-internal (e.g. `gh repo view`, which
// handles GHES and renamed repos), and the RPC path only reaches here after
// the forge resolver has already matched the origin remote to a forge. If the
// adapter still fails after the push, retrying is safe — the non-force push
// of the head branch is idempotent.
await runGitCommand(["push", "-u", "origin", head], { cwd, timeout: 120_000 });
const result = await github.createPullRequest({
const result = await forgeService.createPullRequest({
cwd,
repo,
title: options.title,
body: options.body,
head,
base: normalizedBase,
});
github.invalidate({ cwd });
forgeService.invalidate({ cwd });
return result;
}
export async function getPullRequestStatus(
cwd: string,
github: GitHubService = createGitHubService(),
forgeService: ForgeService = createGitHubService(),
options?: CheckoutReadCacheOptions,
context?: CheckoutContext,
): Promise<PullRequestStatusResult> {
@@ -3356,7 +3422,7 @@ export async function getPullRequestStatus(
}
}
const lookup = getPullRequestStatusUncached(cwd, github, options, context)
const lookup = getPullRequestStatusUncached(cwd, forgeService, options, context)
.then((status) => {
pullRequestStatusCache.set(cacheKey, status);
rememberPullRequestStatus(cacheKey, status);
@@ -3381,54 +3447,45 @@ export async function getPullRequestStatus(
async function getPullRequestStatusUncached(
cwd: string,
github: GitHubService,
forgeService: ForgeService,
options?: CheckoutReadCacheOptions,
context?: CheckoutContext,
): Promise<PullRequestStatusResult> {
if (context?.facts?.isGit === false) {
return {
status: null,
githubFeaturesEnabled: false,
};
return buildPullRequestStatusResult(null, "no_remote");
}
if (!context?.facts?.isGit) {
await requireGitRepo(cwd);
}
const head = context?.facts?.isGit ? context.facts.currentBranch : await getCurrentBranch(cwd);
if (!head) {
return {
status: null,
githubFeaturesEnabled: false,
};
return buildPullRequestStatusResult(null, "no_remote");
}
try {
const lookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head, context);
let status: GitHubCurrentPullRequestStatus | null;
let status: CurrentPullRequestStatus | null;
if (options?.force) {
const reason = options.reason;
if (!reason) {
throw new Error("Forced PR status read requires a reason");
}
status = await github.getCurrentPullRequestStatus({
status = await forgeService.getCurrentPullRequestStatus({
cwd,
...lookupTarget,
force: true,
reason,
});
} else {
status = await github.getCurrentPullRequestStatus({
status = await forgeService.getCurrentPullRequestStatus({
cwd,
...lookupTarget,
reason: options?.reason,
});
}
return {
status,
githubFeaturesEnabled: true,
};
return buildPullRequestStatusResult(status, "authenticated");
} catch (error) {
if (error instanceof GitHubCliMissingError || error instanceof GitHubAuthenticationError) {
return { status: null, githubFeaturesEnabled: false };
if (isForgeAuthError(error)) {
return buildPullRequestStatusResult(null, forgeAuthStateFromError(error));
}
throw error;
}
@@ -1,109 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseGitHubRemoteUrl, resolveGitHubRemote } from "./github-remote.js";
function createSshHostnameResolver(hostnameByAlias: Record<string, string | null>) {
return async (host: string): Promise<string | null> => {
return hostnameByAlias[host] ?? null;
};
}
describe("parseGitHubRemoteUrl", () => {
it.each([
["https://github.com/acme/repo.git", { owner: "acme", name: "repo", repo: "acme/repo" }],
["http://github.com/acme/repo.git", { owner: "acme", name: "repo", repo: "acme/repo" }],
["git@github.com:acme/repo.git", { owner: "acme", name: "repo", repo: "acme/repo" }],
["ssh://git@github.com/acme/repo.git", { owner: "acme", name: "repo", repo: "acme/repo" }],
["ssh://git@ssh.github.com/acme/repo.git", { owner: "acme", name: "repo", repo: "acme/repo" }],
])("parses direct GitHub remotes: %s", (remoteUrl, expected) => {
expect(parseGitHubRemoteUrl(remoteUrl)).toEqual(expected);
});
it("returns null for non-GitHub remotes", () => {
expect(parseGitHubRemoteUrl("git@gitlab.com:acme/repo.git")).toBeNull();
});
it("returns null for SSH aliases before hostname resolution", () => {
expect(parseGitHubRemoteUrl("git@github-work:acme/repo.git")).toBeNull();
});
});
describe("resolveGitHubRemote", () => {
it("resolves an SSH alias that maps to github.com", async () => {
await expect(
resolveGitHubRemote({
remoteUrl: "git@github-work:acme/repo.git",
resolveSshHostname: createSshHostnameResolver({ "github-work": "github.com" }),
}),
).resolves.toEqual({ owner: "acme", name: "repo", repo: "acme/repo" });
});
it("resolves a dotted SSH alias that maps to github.com", async () => {
await expect(
resolveGitHubRemote({
remoteUrl: "git@mindnexus.github.com:JakubMindNexus/postline.git",
resolveSshHostname: createSshHostnameResolver({
"mindnexus.github.com": "github.com",
}),
}),
).resolves.toEqual({
owner: "JakubMindNexus",
name: "postline",
repo: "JakubMindNexus/postline",
});
});
it("resolves an SSH alias that maps to ssh.github.com", async () => {
await expect(
resolveGitHubRemote({
remoteUrl: "ssh://git@github-work/acme/repo.git",
resolveSshHostname: createSshHostnameResolver({ "github-work": "ssh.github.com" }),
}),
).resolves.toEqual({ owner: "acme", name: "repo", repo: "acme/repo" });
});
it("returns null when an SSH alias resolves to a non-GitHub host", async () => {
await expect(
resolveGitHubRemote({
remoteUrl: "git@github-work:acme/repo.git",
resolveSshHostname: createSshHostnameResolver({ "github-work": "gitlab.com" }),
}),
).resolves.toBeNull();
});
it("does not use SSH hostname resolution for HTTPS remotes", async () => {
let resolverCalls = 0;
await expect(
resolveGitHubRemote({
remoteUrl: "https://github.com/acme/repo.git",
resolveSshHostname: async () => {
resolverCalls += 1;
return "github.com";
},
}),
).resolves.toEqual({ owner: "acme", name: "repo", repo: "acme/repo" });
expect(resolverCalls).toBe(0);
});
it.each([
"git@-oProxyCommand=evil:acme/repo.git",
"ssh://git@-oProxyCommand=evil/acme/repo.git",
"git@host with space:acme/repo.git",
])("rejects malformed hostnames without invoking the SSH resolver: %s", async (remoteUrl) => {
let resolverCalls = 0;
await expect(
resolveGitHubRemote({
remoteUrl,
resolveSshHostname: async (host) => {
resolverCalls += 1;
return host;
},
}),
).resolves.toBeNull();
expect(resolverCalls).toBe(0);
});
});
@@ -1,41 +1,29 @@
import {
isGitHubHost,
normalizeHost,
parseGitHubRemoteIdentity,
parseGitRemoteLocation,
type GitHubRemoteIdentity as ResolvedGitHubRemoteIdentity,
} from "@getpaseo/protocol/git-remote";
import { normalizeHost } from "@getpaseo/protocol/git-remote";
import { findExecutable } from "../executable-resolution/executable-resolution.js";
import { execCommand } from "./spawn.js";
export type SshHostnameResolver = (host: string) => Promise<string | null>;
const SSH_HOSTNAME_RESOLVE_TIMEOUT_MS = 5_000;
let sshExecutableLookup: Promise<string | null> | null = null;
const sshHostnameResolutionCache = new Map<string, Promise<string | null>>();
export type SshHostnameResolver = (host: string) => Promise<string | null>;
export { parseGitHubRemoteUrl, type GitHubRemoteIdentity } from "@getpaseo/protocol/git-remote";
export async function resolveGitHubRemote(input: {
remoteUrl: string;
resolveSshHostname?: SshHostnameResolver;
}): Promise<ResolvedGitHubRemoteIdentity | null> {
const location = parseGitRemoteLocation(input.remoteUrl);
if (!location) return null;
if (isGitHubHost(location.host)) return parseGitHubRemoteIdentity(location.path);
if (location.transport !== "scp" && location.transport !== "ssh") return null;
const resolve = input.resolveSshHostname ?? resolveSshHostname;
const resolvedHost = await resolve(location.host);
if (!resolvedHost || !isGitHubHost(resolvedHost)) return null;
return parseGitHubRemoteIdentity(location.path);
}
/**
* Resolve an SSH host alias (e.g. `github-work` from ~/.ssh/config) to its
* configured HostName via `ssh -G`. Results are cached for the daemon's
* lifetime; a host that isn't an alias resolves to itself.
*/
export async function resolveSshHostname(host: string): Promise<string | null> {
const normalized = normalizeHost(host);
if (!normalized) return null;
if (!normalized) {
return null;
}
const cached = sshHostnameResolutionCache.get(normalized);
if (cached) return cached;
if (cached) {
return cached;
}
const resolution = runSshHostnameLookup(normalized);
sshHostnameResolutionCache.set(normalized, resolution);
@@ -45,12 +33,15 @@ export async function resolveSshHostname(host: string): Promise<string | null> {
async function runSshHostnameLookup(host: string): Promise<string | null> {
sshExecutableLookup ??= findExecutable("ssh");
const sshPath = await sshExecutableLookup;
if (!sshPath) return null;
if (!sshPath) {
return null;
}
try {
const { stdout } = await execCommand(sshPath, ["-G", host], {
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
maxBuffer: 1024 * 1024,
timeout: SSH_HOSTNAME_RESOLVE_TIMEOUT_MS,
});
return parseSshHostname(stdout);
} catch {
@@ -61,11 +52,17 @@ async function runSshHostnameLookup(host: string): Promise<string | null> {
function parseSshHostname(stdout: string): string | null {
for (const line of stdout.split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed) continue;
if (!trimmed) {
continue;
}
const [key, value] = trimmed.split(/\s+/u);
if (key?.toLowerCase() !== "hostname") continue;
if (key?.toLowerCase() !== "hostname") {
continue;
}
const normalized = normalizeHost(value ?? "");
if (normalized) return normalized;
if (normalized) {
return normalized;
}
}
return null;
}
+37 -10
View File
@@ -1,15 +1,23 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
import { isAbsolute, join, resolve } from "path";
import { z } from "zod";
const ChangeRequestLookupTargetSchema = z.object({
headRef: z.string().min(1),
headRepositoryOwner: z.string().min(1).optional(),
changeRequestNumber: z.number().int().positive().optional(),
});
const PaseoWorktreeMetadataV1Schema = z.object({
version: z.literal(1),
baseRefName: z.string().min(1),
changeRequestLookupTarget: ChangeRequestLookupTargetSchema.optional(),
});
const PaseoWorktreeMetadataV2Schema = z.object({
version: z.literal(2),
baseRefName: z.string().min(1),
changeRequestLookupTarget: ChangeRequestLookupTargetSchema.optional(),
firstAgentBranchAutoName: z
.discriminatedUnion("status", [
z.object({
@@ -36,6 +44,9 @@ const PaseoWorktreeMetadataSchema = z.union([
]);
export type PaseoWorktreeMetadata = z.infer<typeof PaseoWorktreeMetadataSchema>;
export type PaseoWorktreeChangeRequestLookupTarget = z.infer<
typeof ChangeRequestLookupTargetSchema
>;
function getGitDirForWorktreeRoot(worktreeRoot: string): string {
const gitPath = join(worktreeRoot, ".git");
@@ -77,7 +88,10 @@ export function normalizeBaseRefName(input: string): string {
export function writePaseoWorktreeMetadata(
worktreeRoot: string,
options: { baseRefName: string },
options: {
baseRefName: string;
changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget;
},
): void {
const baseRefName = normalizeBaseRefName(options.baseRefName);
if (baseRefName === "HEAD") {
@@ -90,10 +104,14 @@ export function writePaseoWorktreeMetadata(
throw new Error(`Invalid base branch: ${baseRefName}`);
}
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true });
const metadata: PaseoWorktreeMetadata = { version: 1, baseRefName };
writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
const metadata: PaseoWorktreeMetadata = {
version: 1,
baseRefName,
...(options.changeRequestLookupTarget
? { changeRequestLookupTarget: options.changeRequestLookupTarget }
: {}),
};
writePaseoWorktreeMetadataFile(worktreeRoot, metadata);
}
export function writePaseoWorktreeRuntimeMetadata(
@@ -109,11 +127,12 @@ export function writePaseoWorktreeRuntimeMetadata(
throw new Error("Cannot persist worktree runtime metadata: missing base metadata");
}
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true });
const next: PaseoWorktreeMetadata = {
version: 2,
baseRefName: current.baseRefName,
...(current.changeRequestLookupTarget
? { changeRequestLookupTarget: current.changeRequestLookupTarget }
: {}),
...(current.version === 2 && current.firstAgentBranchAutoName
? { firstAgentBranchAutoName: current.firstAgentBranchAutoName }
: {}),
@@ -121,7 +140,7 @@ export function writePaseoWorktreeRuntimeMetadata(
worktreePort: options.worktreePort,
},
};
writeFileSync(metadataPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
writePaseoWorktreeMetadataFile(worktreeRoot, next);
}
export function writePaseoWorktreeFirstAgentBranchAutoNameMetadata(
@@ -141,6 +160,9 @@ export function writePaseoWorktreeFirstAgentBranchAutoNameMetadata(
writePaseoWorktreeMetadataFile(worktreeRoot, {
version: 2,
baseRefName: current.baseRefName,
...(current.changeRequestLookupTarget
? { changeRequestLookupTarget: current.changeRequestLookupTarget }
: {}),
firstAgentBranchAutoName: {
status: "pending",
placeholderBranchName,
@@ -161,6 +183,9 @@ export function markPaseoWorktreeFirstAgentBranchAutoNameAttempted(
const next: PaseoWorktreeMetadata = {
version: 2,
baseRefName: current.baseRefName,
...(current.changeRequestLookupTarget
? { changeRequestLookupTarget: current.changeRequestLookupTarget }
: {}),
firstAgentBranchAutoName: {
status: "attempted",
placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName,
@@ -207,5 +232,7 @@ function writePaseoWorktreeMetadataFile(
): void {
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true });
writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
const tempPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
writeFileSync(tempPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
renameSync(tempPath, metadataPath);
}
+83 -16
View File
@@ -23,6 +23,7 @@ export {
import { PaseoConfigSchema, type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
import {
normalizeBaseRefName,
type PaseoWorktreeChangeRequestLookupTarget,
readPaseoWorktreeMetadata,
readPaseoWorktreeRuntimePort,
writePaseoWorktreeMetadata,
@@ -159,14 +160,33 @@ export interface WorktreeRootOptions {
worktreesRoot?: string;
}
export interface WorktreeCheckoutRef {
remoteName?: string;
remoteRef: string;
}
export type WorktreeSource =
| { kind: "branch-off"; baseBranch: string; branchName: string }
| { kind: "checkout-branch"; branchName: string }
| {
kind: "checkout-change-request";
forge: string;
changeRequestNumber: number;
headRef: string;
headRepositoryOwner?: string;
baseRefName: string;
checkoutRefs?: WorktreeCheckoutRef[];
localBranchName?: string;
pushRemoteUrl?: string;
trackOriginHead?: boolean;
}
| {
kind: "checkout-github-pr";
githubPrNumber: number;
headRef: string;
headRepositoryOwner?: string;
baseRefName: string;
checkoutRefs?: WorktreeCheckoutRef[];
localBranchName?: string;
pushRemoteUrl?: string;
trackOriginHead?: boolean;
@@ -1216,7 +1236,12 @@ export const createWorktree = async ({
});
}
writePaseoWorktreeMetadata(worktreePath, { baseRefName: sourcePlan.metadataBaseRefName });
writePaseoWorktreeMetadata(worktreePath, {
baseRefName: sourcePlan.metadataBaseRefName,
...(sourcePlan.changeRequestLookupTarget
? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget }
: {}),
});
// If paseo.json exists in the main repo but wasn't checked into the worktree
// (e.g. uncommitted on first-time setup), seed the worktree with it so setup
@@ -1254,6 +1279,7 @@ interface ResolveWorktreeSourcePlanOptions {
interface WorktreeSourcePlan {
branchName: string;
metadataBaseRefName: string;
changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget;
addArguments: string[];
pushRemote?: {
name: string;
@@ -1311,24 +1337,23 @@ async function resolveWorktreeSourcePlan({
addArguments: [source.branchName],
};
}
case "checkout-change-request":
case "checkout-github-pr": {
const localBranchCandidate = source.localBranchName ?? source.headRef;
await validateExistingWorktreeBranchName(cwd, localBranchCandidate);
const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate);
const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName);
await runGitCommand(
[
"fetch",
"origin",
`refs/pull/${source.githubPrNumber}/head:refs/heads/${localBranchName}`,
"--force",
const changeRequestNumber =
source.kind === "checkout-github-pr" ? source.githubPrNumber : source.changeRequestNumber;
await fetchWorktreeCheckoutRefs({
cwd,
localBranchName,
checkoutRefs: source.checkoutRefs ?? [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
],
{
cwd,
timeout: 120_000,
},
);
const trackingRemote = source.trackOriginHead
});
const shouldTrackOriginHead = source.trackOriginHead === true;
const trackingRemote = shouldTrackOriginHead
? await tryFetchWorktreeTrackingRemote({
cwd,
remoteName: "origin",
@@ -1337,18 +1362,18 @@ async function resolveWorktreeSourcePlan({
: undefined;
const remotePlan: Pick<WorktreeSourcePlan, "pushRemote" | "trackingRemote"> = {};
if (source.pushRemoteUrl) {
const remoteName = `paseo-pr-${source.githubPrNumber}`;
const remoteName = `paseo-pr-${changeRequestNumber}`;
remotePlan.pushRemote = {
name: remoteName,
url: source.pushRemoteUrl,
headRef: source.headRef,
track: true,
};
} else if (source.trackOriginHead && localBranchName !== source.headRef) {
} else if (shouldTrackOriginHead && localBranchName !== source.headRef) {
const originUrl = await getWorktreeRemotePushUrl(cwd, "origin");
if (originUrl) {
remotePlan.pushRemote = {
name: `paseo-pr-${source.githubPrNumber}`,
name: `paseo-pr-${changeRequestNumber}`,
url: originUrl,
headRef: source.headRef,
track: false,
@@ -1362,6 +1387,13 @@ async function resolveWorktreeSourcePlan({
return {
branchName: localBranchName,
metadataBaseRefName: normalizedBaseRefName,
changeRequestLookupTarget: {
headRef: source.headRef,
...(source.headRepositoryOwner
? { headRepositoryOwner: source.headRepositoryOwner }
: {}),
changeRequestNumber,
},
addArguments: [localBranchName],
...remotePlan,
};
@@ -1414,6 +1446,41 @@ async function configureWorktreePushRemote(options: {
}
}
async function fetchWorktreeCheckoutRefs(options: {
cwd: string;
localBranchName: string;
checkoutRefs: WorktreeCheckoutRef[];
}): Promise<void> {
let lastResult:
| Awaited<ReturnType<typeof runGitCommand>>
| { stderr: string; stdout: string; exitCode: number | null }
| null = null;
for (const checkoutRef of options.checkoutRefs) {
lastResult = await runGitCommand(
[
"fetch",
checkoutRef.remoteName ?? "origin",
`+${checkoutRef.remoteRef}:refs/heads/${options.localBranchName}`,
"--force",
],
{
cwd: options.cwd,
timeout: 120_000,
acceptExitCodes: [0, 1, 128],
},
);
if (lastResult.exitCode === 0) {
return;
}
}
const attemptedRefs = options.checkoutRefs
.map((checkoutRef) => `${checkoutRef.remoteName ?? "origin"} ${checkoutRef.remoteRef}`)
.join(", ");
throw new Error(
`Unable to fetch change request refs for worktree branch ${options.localBranchName}: ${attemptedRefs}${lastResult?.stderr ? `\n${lastResult.stderr}` : ""}`,
);
}
async function tryFetchWorktreeTrackingRemote(options: {
cwd: string;
remoteName: string;