feat(forge): pluggable forge abstraction + GitLab and Gitea/Forgejo/Codeberg (#1913)
* refactor(forge): forge-neutral foundation (GitHub-only) Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged. - Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry. - GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved. - forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client). - createPullRequest reports typed CLI/auth errors instead of a generic message. - forge-resolver host/remote caches are LRU-bounded. - Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com. - Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology. - Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs. * feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals. - threadIsResolved is part of the neutral timeline item. - Pipeline load failures show an error instead of an empty section. - Manual pipeline jobs render as pending. - Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request). * feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg) One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts. - CI status aggregates commit statuses and Actions runs together. - Gitea's terminal "warning" state maps to failure on server and client. - Gitea Actions check details are reachable from the PR pane by workflowRunId. * refactor(forge): localize compatibility handling * test(forge): expect normalized GitLab facts --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
04d1ebdce0
commit
a8ebd390fa
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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"], {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+29
-32
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user