Compare commits

...
Author SHA1 Message Date
Mohamed Boudra b28aafa1df fix(forges): isolate repository identity by remote
Revalidate pull request sources when a checkout changes forge servers.
2026-11-02 07:32:03 +01:00
Mohamed Boudra 5feb68da4f fix(workspaces): preserve pull requests across checkout changes
Keep branch associations when the local commit changes and resolve transferred source identities before matching. Wait for the composer to accept input before queueing in browser tests.
2026-11-02 07:18:48 +01:00
Mohamed Boudra 24ef4c48cc fix(forges): retain pull requests after repository renames
Compare the head repository with the forge-resolved identity when its configured path has moved.
2026-11-02 06:55:23 +01:00
Mohamed Boudra a7d5f99a1c fix(forges): retain transferred forks in crowded searches
Resolve the head repository before choosing the bounded fallback so transferred forks keep their pull request association.
2026-11-02 06:42:09 +01:00
Mohamed Boudra f1e262a142 fix(forges): preserve pull request repository identity
Treat bounded discovery as inconclusive and resolve redirected repositories before selecting requests.
2026-11-02 06:35:23 +01:00
Mohamed Boudra dc7ade5264 fix(workspaces): retain merged pull request status
A workspace can still represent a merged pull request after its checkout advances. Match the repository and branch for display, and require a fresh exact-head match before automatic archive.
2026-11-02 06:24:00 +01:00
da8d58bfee fix(plugins): preserve agent message provenance in timeline transforms (#8492)
Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 05:04:23 +00:00
Mohamed Boudra 7c2812dde6 fix(app): add hosts without leaving the current screen (#8512)
* fix(app): add hosts without leaving the current screen

A retained Settings route replayed its addHost query after appearance remounts. Own the connection flow outside screen lifetimes and let each consumer choose its completion navigation.

* fix(app): release the project dialog before adding a host

* fix(app): size the QR scanner viewport on wide layouts

* test(app): align creation and desktop setup with in-place host flow
2026-11-02 11:07:48 +07:00
Mohamed Boudra b474beedce Homepage shows an agent building a plugin live (#8515)
* feat(website): show agents building plugins on the homepage

The plugins section now leads with the workflow instead of the API list:
you ask an agent for a panel and it appears, without a restart. It plays
as a timeline (prompt, tool calls, result) and then slides in the panel
the agent built, followed by the 400+ community plugins and their
categories. It moves above the automation section.

Mock tool calls on the homepage now match the app's collapsed timeline
rows: same labels and icons, muted text in the UI font, and Paseo MCP
tools named by action ("Create agent"). The hero pills are now nouns.

* fix(website): show the finished plugin demo when motion is reduced

The demo staged its items and slid the panel across its width even for visitors who ask for reduced motion. It now starts in its finished state for them, matching the hero mockup.
2026-11-02 10:50:12 +07:00
e2f0779379 Render Markdown tables written directly below prose (#8485)
* fix(app): render tables immediately after paragraphs

Insert a paragraph break before GFM tables adjacent to prose so MD4C
recognizes them, preserving quote/list containers and line endings.

* fix(app): preserve table headers during source adaptation

Replace the preceding line ending when inserting a table separator.
This keeps the separator before the header's source range, so entity
and local image edits cannot consume the paragraph break.

---------

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: thsrite <thsrite@gmail.com>
Co-authored-by: Woosuk Nam <me@suk.kr>
2026-11-02 03:30:52 +00:00
paseo-bot[bot] a166a3317a test(terminal): check original processes after protocol cleanup (#8502)
Startup launches another worker after closing obsolete protocols, so a reused numeric PID does not prove the original process survived. Check the recorded birth identities and require cleanup to be confirmed.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 03:01:36 +00:00
60 changed files with 4395 additions and 803 deletions
-8
View File
@@ -406,10 +406,7 @@
{
"files": [
"packages/app/src/app/_layout.tsx",
"packages/app/src/app/pair-scan.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-host-method-modal.tsx",
"packages/app/src/components/add-host-modal.tsx",
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/agent-status-dot.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
@@ -428,7 +425,6 @@
"packages/app/src/components/hosts/host-picker.tsx",
"packages/app/src/components/icons/paseo-logo.tsx",
"packages/app/src/components/left-sidebar.tsx",
"packages/app/src/components/pair-link-modal.tsx",
"packages/app/src/components/plan-card.tsx",
"packages/app/src/components/provider-diagnostic-sheet.tsx",
"packages/app/src/components/question-form-card.tsx",
@@ -543,14 +539,10 @@
"packages/app/src/agent-skills/selection-sheet.tsx",
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
"packages/app/src/agent-stream/view.tsx",
"packages/app/src/app/pair-scan.tsx",
"packages/app/src/changelog/internal/changelog-sheet.tsx",
"packages/app/src/command-center/command-center.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-host-method-modal.tsx",
"packages/app/src/components/add-host-modal.tsx",
"packages/app/src/components/add-project-flow.tsx",
"packages/app/src/components/add-remote-ssh-host-modal.tsx",
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/app-diagnostic-sheet.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
+4 -4
View File
@@ -35,7 +35,7 @@ The authored interface ramp uses a 14px base. New native installs default to 15p
Weight has three tiers, applied by role:
- **Screen titles** — the title in app chrome — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. The New workspace hero is the only larger product title; it uses `fontSize["2xl"]` (`packages/app/src/screens/new-workspace-screen.tsx`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx:19-23`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
@@ -102,7 +102,7 @@ Five primitives. The pick is determined by option count, the need to search, and
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
@@ -114,7 +114,7 @@ Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value
## 7. Density and rhythm
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx`).
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
@@ -278,7 +278,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
| Form modal (label + input fields, primary + cancel) | `packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
+6
View File
@@ -58,6 +58,12 @@ restore the same workspace and could repeat the crash.
## App-Wide Route Hops
Keep Add host in `hosts/add-host`, opened through `useAddHost()`. It is an in-place
flow; callers choose what happens after success. Settings opts into the new host’s
Overview. Do not send callers to Settings with an `addHost` query parameter: a
retained route replays that request when appearance changes remount its content.
The flow provider and sheet host stay outside appearance keys so open drafts survive.
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
branch on its current route.
+20 -11
View File
@@ -97,19 +97,28 @@ Register the adapter in `defaultForgeRegistry` with:
- `matchesHost` from manifest `cloudHosts`
- `probeHost` when self-hosted/Enterprise detection is supported
Current change-request lookup uses two identities deliberately:
Match a change request using the head repository and branch. Within the bounded
candidate window, prefer an open request, otherwise select the most recently
updated terminal request, breaking ties by number. A full inconclusive window is
a failed discovery, so a poll retains its previous status. Gitea does not expose
a source-branch filter: after its bounded open window it also examines one recent
all-state page. An older open request outside both windows cannot take precedence
over a recent terminal request on a cold lookup. When a workspace already shows
an open request, revalidate it before replacing it with another terminal request.
A reused branch can display a historical request
until a new one opens. Display does not depend on checkout HEAD: the checkout can
be behind the remote PR after its branch is updated during merge.
- An open PR/MR belongs to the checkout when its head branch and head repository
match. Its remote head SHA may differ because the checkout can be ahead,
behind, or contain commits that have not been pushed yet.
- A merged or closed PR/MR belongs to the checkout only when its recorded head
SHA exactly matches the checkout's current `HEAD`. Branch names are reusable;
selecting the newest terminal request by branch alone can silently attach an
old promotion or feature request to new work.
For mixed SSH and HTTPS ports on one host, require the source repository's native
clone URL to match the normalized checkout endpoint. Different HTTP ports remain
distinct forge instances. Unknown clone URLs do not establish identity.
Thread the checkout head SHA through adapter cache and poll identities as well
as the lookup itself. Otherwise a commit made on the same branch can inherit the
previous commit's cached terminal status until the cache expires.
Auto-archive has a narrower contract. It requires a fresh, observed open-to-merged
transition and an exact match between the checkout HEAD and the forge's recorded
PR head, a successful upstream safety read, and the workspace safety checks. A
successful read reporting no upstream is distinct from a failed read. Keep
checkout HEAD in cache and poll identities so a checkout change refreshes those
safety facts.
Cloud hosts in the manifest are a bounded public-host list, not a self-host
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
@@ -5,6 +5,7 @@ import path from "node:path";
import { test, expect } from "../support/fixtures";
import {
addProjectFlow,
chooseAddHostFromProjectFlow,
addProjectFlowBack,
addProjectFlowHost,
addProjectFlowInput,
@@ -19,6 +20,7 @@ import {
} from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
disconnectAllHostsAndReload,
addConnectedHostAndReload,
addConnectedHostsAndReload,
addOfflineHostAndReload,
@@ -69,6 +71,19 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
test.describe("Add Project command-center flow", () => {
test.describe.configure({ timeout: 180_000 });
test("Add host replaces the project dialog when no hosts are connected", async ({ page }) => {
await gotoAppShell(page);
await disconnectAllHostsAndReload(page);
await openAddProjectHostSelection(page);
const originalUrl = page.url();
await chooseAddHostFromProjectFlow(page);
await expect(addProjectFlow(page)).toBeHidden();
await expect(
page.getByRole("button", { name: "Direct connection", exact: true }),
).toBeVisible();
await expect(page).toHaveURL(originalUrl);
});
test("method selection shows the daemon's available project sources without search", async ({
page,
}) => {
@@ -294,10 +294,17 @@ test("creates, renames, copies, and deletes entries through the file explorer",
await entry("renamed.txt").click({ button: "right" });
const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/);
const deleteLabelColor = await deleteAction
.getByText("Delete", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor);
await expect
.poll(async () => {
const labelColor = await deleteAction
.getByText("Delete", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
const iconColor = await deleteAction
.locator("svg")
.evaluate((element) => getComputedStyle(element).stroke);
return labelColor !== "" && iconColor === labelColor;
})
.toBe(true);
const cancelledConfirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => {
const message = dialog.message();
@@ -122,10 +122,14 @@ test.describe("New workspace entry points", () => {
});
const hostTrigger = page.getByTestId("host-picker-trigger");
await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 });
const originalUrl = page.url();
await hostTrigger.click();
await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible();
await page.getByTestId("new-workspace-host-picker-add-host").click();
await expect(page).toHaveURL(/\/settings\/general\?addHost=/);
await expect(
page.getByRole("button", { name: "Direct connection", exact: true }),
).toBeVisible();
await expect(page).toHaveURL(originalUrl);
} finally {
await seeded.cleanup();
}
@@ -1,3 +1,8 @@
import {
openGlobalNewWorkspaceComposer,
fillNewWorkspaceDraft,
expectNewWorkspaceDraft,
} from "../support/helpers/new-workspace";
import { readFile, mkdir, rename, rm } from "node:fs/promises";
import path from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -273,3 +278,71 @@ test("restart recovery host controls persist the opt-out and custom prompt", asy
await expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
await editRestartRecoveryPrompt(page, "Resume working.");
});
test("sidebar Add host preserves its caller and stays closed after theme changes", async ({
page,
}) => {
await gotoAppShell(page);
await expect(page).toHaveURL(/\/open-project$/);
const originalUrl = page.url();
await openSidebarAddHost(page);
await expect(page).toHaveURL(originalUrl);
await closeAddHostAndChangeTheme(page);
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden();
await expect(page).toHaveURL(originalUrl);
});
async function openSidebarAddHost(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Hosts", exact: true }).click();
await page.getByRole("button", { name: "Add host", exact: true }).click();
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeVisible();
}
async function closeAddHostAndChangeTheme(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Close", exact: true }).click();
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden();
await page.keyboard.press("Control+Alt+t");
}
test("sidebar Add host completes without navigating", async ({ page }) => {
await gotoAppShell(page);
await expect(page).toHaveURL(/\/open-project$/);
const originalUrl = page.url();
await openSidebarAddHost(page);
await connectToTestHost(page);
await expect(page).toHaveURL(originalUrl);
await expect(page.getByRole("button", { name: "Hosts", exact: true })).toBeVisible();
});
test("Settings Add host opens the saved host Overview", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await addDirectHostFromSettings(page, { host: "127.0.0.1", port: Number(getE2EDaemonPort()) });
await expectSettingsHeader(page, "Overview");
await expect(page).toHaveURL(new RegExp(`/settings/hosts/${getServerId()}/host$`));
});
test("New workspace Add host preserves the prompt after connecting", async ({ page }) => {
await gotoAppShell(page);
await openGlobalNewWorkspaceComposer(page);
await fillNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host.");
const originalUrl = page.url();
await openNewWorkspaceAddHost(page);
await expect(page).toHaveURL(originalUrl);
await connectToTestHost(page);
await expect(page).toHaveURL(originalUrl);
await expectNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host.");
});
async function openNewWorkspaceAddHost(page: import("@playwright/test").Page) {
await page.getByTestId("host-picker-trigger").click();
await page.getByRole("button", { name: "Add host", exact: true }).click();
}
async function connectToTestHost(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Direct connection", exact: true }).click();
await page.getByRole("textbox", { name: "Host", exact: true }).fill("127.0.0.1");
await page.getByRole("textbox", { name: "Port", exact: true }).fill(String(getE2EDaemonPort()));
await page.getByRole("button", { name: "Connect", exact: true }).click();
await expect(page.getByTestId("add-host-modal")).toBeHidden({ timeout: 30_000 });
}
@@ -113,3 +113,7 @@ export async function expectAddProjectResultVisible(page: Page, text: string): P
})
.toBe(true);
}
export async function chooseAddHostFromProjectFlow(page: Page): Promise<void> {
await page.getByRole("button", { name: /Add host.*No connected hosts/ }).click();
}
@@ -84,6 +84,9 @@ export async function typeIntoFocusedComposer(page: Page, text: string): Promise
}
export async function sendDraftToQueue(page: Page): Promise<void> {
await expect(
page.getByRole("button", { name: /^(Send and steer|Send and interrupt)$/ }),
).toBeEnabled({ timeout: 30_000 });
await composerInput(page).press("Control+Enter");
}
@@ -94,6 +94,9 @@ if (origin === fixtureRemote) {
labels: [],
baseRefName: "main",
headRefName: isFork ? "pr-branch-2" : "pr-branch-1",
headRefOid: spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).stdout?.trim() || "",
headRepositoryOwner: { login: isFork ? "fork-owner" : "paseo-e2e" },
headRepository: { name: "local-fixture" },
updatedAt: "2026-01-01T00:00:00Z"
};
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
+22
View File
@@ -273,3 +273,25 @@ export async function reloadPreservingHostRegistry(page: Page): Promise<void> {
);
await page.reload();
}
export async function disconnectAllHostsAndReload(page: Page): Promise<void> {
await page.evaluate(
({ registryKey, nonceKey, disableSeedKey }) => {
const nonce = localStorage.getItem(nonceKey);
if (!nonce) throw new Error("Expected the e2e seed nonce");
const hosts = JSON.parse(localStorage.getItem(registryKey) ?? "[]");
for (const host of hosts) {
host.connections = [{ id: "offline", type: "directTcp", endpoint: "127.0.0.1:59999" }];
host.preferredConnectionId = "offline";
}
localStorage.setItem(registryKey, JSON.stringify(hosts));
localStorage.setItem(disableSeedKey, nonce);
},
{
registryKey: REGISTRY_KEY,
nonceKey: SEED_NONCE_KEY,
disableSeedKey: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
);
await page.reload();
}
+9 -6
View File
@@ -1,3 +1,4 @@
import { AddHostProvider, AddHostFlowHost } from "@/hosts/add-host";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
@@ -465,6 +466,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const surface = (
<View style={layoutStyles.surfaceFill}>
{workspaceChrome}
<AddHostFlowHost />
<AppearanceStyleBoundary>
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
<WindowChromeRegion corners="top-left">
@@ -805,7 +807,6 @@ function RootStack() {
<Stack.Screen name="open-project" />
<Stack.Screen name="sessions" />
<Stack.Screen name="schedules" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="h/[serverId]" />
<Stack.Screen name="settings/hosts/[serverId]/index" />
@@ -890,11 +891,13 @@ function RootAppTree() {
<GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}>
<LucideProvider strokeWidth={ICON_STROKE_WIDTH}>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
<AddHostProvider>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</AddHostProvider>
</LucideProvider>
</View>
</GestureHandlerRootView>
+2 -3
View File
@@ -35,10 +35,9 @@ function SettingsDaemonRedirect() {
}
export default function SettingsSectionRoute() {
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
const params = useLocalSearchParams<{ section?: string }>();
const rawSection = typeof params.section === "string" ? params.section : "";
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general";
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
@@ -50,5 +49,5 @@ export default function SettingsSectionRoute() {
);
}
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
return <SettingsScreen view={view} />;
}
@@ -1,3 +1,4 @@
import { useAddHost } from "@/hosts/add-host";
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
@@ -92,7 +93,7 @@ import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import type { AddProjectFlowRequest } from "@/stores/add-project-flow-store";
import type { Theme } from "@/styles/theme";
import { shortenPath } from "@/utils/shorten-path";
import { buildNewWorkspaceRoute, buildSettingsAddHostRoute } from "@/utils/host-routes";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
interface AddProjectFlowProps {
request: AddProjectFlowRequest;
@@ -325,6 +326,7 @@ function setPageStatus(
// The product flow is intentionally one cohesive page-stack state machine.
// eslint-disable-next-line complexity
export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const addHost = useAddHost();
const hosts = useHosts();
const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds);
@@ -616,9 +618,9 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
icon: Plus,
testID: "add-project-flow-add-host",
select: () => {
// The native project Modal must release its window before the root sheet opens.
onClose();
usePanelStore.getState().showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
addHost();
},
});
}
@@ -717,6 +719,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
directoryPaths,
githubQuery.data,
host,
addHost,
onClose,
openAddedProject,
page,
+4 -9
View File
@@ -1,3 +1,4 @@
import { useAddHost } from "@/hosts/add-host";
import { router } from "expo-router";
import {
CircleGauge,
@@ -62,7 +63,7 @@ import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
@@ -177,14 +178,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
router.push(buildSettingsRoute());
}, []);
const handleAddHostMobile = useCallback(() => {
showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
}, [showMobileAgent]);
const handleAddHostDesktop = useCallback(() => {
router.push(buildSettingsAddHostRoute(Date.now()));
}, []);
const handleAddHostMobile = useAddHost();
const handleAddHostDesktop = handleAddHostMobile;
const handleOpenHostSettingsMobile = useCallback(
(serverId: string) => {
@@ -130,6 +130,186 @@ it("preserves empty table cells while escaping spoilers in cell text", () => {
);
});
it("renders a table directly after a paragraph through the library parser", async () => {
const source = "**Notes**\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |";
const separated = "**Notes**\n\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |";
const flags = { hardSoftBreaks: true, latexMath: false };
const expected = await parseMarkdown(separated, flags);
expect(expected.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]);
expect(await parseMarkdown(prepareMarkdownSource(source).render(), flags)).toEqual(expected);
});
it.each([
[
"a quote",
"> Notes\n> | Item |\n> | --- |\n> | A |",
"> Notes\n>\n> | Item |\n> | --- |\n> | A |",
["Document", ["Blockquote", "Paragraph", "Table"]],
],
[
"a list item",
"- Notes\n | Item |\n | --- |\n | A |\n- Next",
"- Notes\n\n | Item |\n | --- |\n | A |\n- Next",
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
],
[
"a quoted list item",
"> - Notes\n> | Item |\n> | --- |\n> | A |",
"> - Notes\n>\n> | Item |\n> | --- |\n> | A |",
["Document", ["Blockquote", ["UnorderedList", ["ListItem", "Paragraph", "Table"]]]],
],
[
"a quote inside a list item",
"- > Notes\n > | Item |\n > | --- |\n > | A |",
"- > Notes\n >\n > | Item |\n > | --- |\n > | A |",
["Document", ["UnorderedList", ["ListItem", ["Blockquote", "Paragraph", "Table"]]]],
],
[
"aligned columns without outer pipes",
"Notes\nItem | Detail\n:--- | ---:\nA | one",
"Notes\n\nItem | Detail\n:--- | ---:\nA | one",
["Document", "Paragraph", "Table"],
],
[
"an entity at the start of a header without outer pipes",
"Notes\nAT&amp;T | Detail\n--- | ---\nA | one",
"Notes\n\nAT\\&T | Detail\n--- | ---\nA | one",
["Document", "Paragraph", "Table"],
],
[
"a local image at the start of a header without outer pipes",
"Notes\n![Local](assets/a.png) | Detail\n--- | ---\nA | one",
"Notes\n\nLocal | Detail\n--- | ---\nA | one",
["Document", "Paragraph", "Table"],
],
[
"CRLF lines",
"Notes\r\n| Item |\r\n| --- |\r\n| A |",
"Notes\r\n\r\n| Item |\r\n| --- |\r\n| A |",
["Document", "Paragraph", "Table"],
],
[
"CR lines",
"Notes\r| Item |\r| --- |\r| A |",
"Notes\r\r| Item |\r| --- |\r| A |",
["Document", "Paragraph", "Table"],
],
[
"a quote with CRLF lines",
"> Notes\r\n> | Item |\r\n> | --- |\r\n> | A |",
"> Notes\r\n>\r\n> | Item |\r\n> | --- |\r\n> | A |",
["Document", ["Blockquote", "Paragraph", "Table"]],
],
[
"a list item with CRLF lines",
"- Notes\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next",
"- Notes\r\n\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next",
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
],
[
"a quote with CR lines",
"> Notes\r> | Item |\r> | --- |\r> | A |",
"> Notes\r>\r> | Item |\r> | --- |\r> | A |",
["Document", ["Blockquote", "Paragraph", "Table"]],
],
[
"a list item with CR lines",
"- Notes\r | Item |\r | --- |\r | A |\r- Next",
"- Notes\r\r | Item |\r | --- |\r | A |\r- Next",
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
],
[
"a non-interrupting ordered marker with tildes",
"Intro\n2. ~~~\n Notes\n | A |\n | --- |\n | x |",
"Intro\n2. ~~~\n Notes\n\n | A |\n | --- |\n | x |",
["Document", "Paragraph", "Table"],
],
[
"a non-interrupting ordered marker with backticks",
"Notes\n2. ```\n | Item |\n | --- |\n | A |",
"Notes\n2. ```\n\n | Item |\n | --- |\n | A |",
["Document", "Paragraph", "Table"],
],
])("preserves tables after paragraphs in %s", async (_name, source, separated, expected) => {
const rendered = prepareMarkdownSource(source).render();
expect(rendered).toEqual(separated);
const flags = { hardSoftBreaks: true, latexMath: false };
function blocks(node: ASTNode): unknown {
if (node.type === "Paragraph" || node.type === "Table") return node.type;
return [node.type, ...(node.children ?? []).map(blocks)];
}
expect(blocks(await parseMarkdown(rendered, flags))).toEqual(expected);
});
it.each([
[
"a literal backslash",
"Notes\\\n| Item |\n| --- |\n| A |",
"Notes\\\n\n| Item |\n| --- |\n| A |",
"Notes\\",
],
[
"an entity and literal backslash with LF lines",
"AT&amp;T\\\n| Item |\n| --- |\n| A |",
"AT\\&T\\\n\n| Item |\n| --- |\n| A |",
"AT&T\\",
],
[
"an entity and literal backslash with CRLF lines",
"AT&amp;T\\\r\n| Item |\r\n| --- |\r\n| A |",
"AT\\&T\\\r\n\r\n| Item |\r\n| --- |\r\n| A |",
"AT&T\\",
],
[
"an entity and literal backslash with CR lines",
"AT&amp;T\\\r| Item |\r| --- |\r| A |",
"AT\\&T\\\r\r| Item |\r| --- |\r| A |",
"AT&T\\",
],
])(
"preserves paragraph text before an interrupting table in %s",
async (_name, source, separated, text) => {
const rendered = prepareMarkdownSource(source).render();
expect(rendered).toEqual(separated);
const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false });
expect(tree.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]);
expect(tree.children?.[0]?.children?.map((node) => node.content).join("")).toEqual(text);
},
);
it.each([
"```yaml\n---\nkey: value\n---\n```\n",
"~~~md\n| Item |\n| --- |\n| A |\n~~~\n",
"````md\n```\n| Item |\n| --- |\n```\n`````\n",
"> ```md\n> | Item |\n> | --- |\n> ```\n",
"> ```md\n> Code\n",
"- ```md\n Code\n",
" ```\n\n",
"```not`a-fence\n",
])("renders a table after a fence without changing its code: %s", async (prefix) => {
const table = "| Item |\n| --- |\n| A |";
const rendered = prepareMarkdownSource(`${prefix}Notes\n${table}`).render();
expect(rendered).toEqual(`${prefix}Notes\n\n${table}`);
const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false });
expect(tree.children?.slice(-2).map((node) => node.type)).toEqual(["Paragraph", "Table"]);
});
it.each([
"| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100),
"Notes\n\n| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100),
"Notes\n\n| Item |\n| --- |\n| A |",
"| Item |\n| --- |\n| A |",
"```md\nNotes\n| Item |\n| --- |\n| A |\n```",
"```yaml\n---\nkey: value\n---\n```",
"```yaml\n---\nkey: value",
"~~~md\n| Item |\n| --- |\n| A |\n~~~",
"````md\n```\n| Item |\n| --- |\n```\n````",
" Notes\n | Item |\n | --- |\n | A |",
"Notes\n| Item | Detail |\n| --- |\n| A |",
])("leaves separated tables and table-like text unchanged: %s", (source) => {
expect(prepareMarkdownSource(source).render()).toEqual(source);
});
it.each([
"src/components/message.tsx",
"C:\\repo\\file.ts",
+24 -5
View File
@@ -138,6 +138,10 @@ export function prepareMarkdownSource(
!source.includes("![") &&
!source.includes("||") &&
!source.includes("&") &&
// Match content, header, and delimiter lines; quote-only lines count as blank.
!/^[\t >]*[^\t >\r\n][^\r\n]*(?:\r\n|\r|\n)[^\r\n]+(?:\r\n|\r|\n)(?=[\t >:|-]*\|)[\t >|]*:?-[-\t :|]*$/m.test(
source,
) &&
!(workspaceRoot && source.includes("]("))
) {
return { images, ...metadata, render: () => source };
@@ -212,11 +216,25 @@ export function prepareMarkdownSource(
if (lineEnd < end) split(lineEnd);
}
}
function visit(node: Nodes) {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start !== undefined && end !== undefined) {
function visit(node: Nodes, previous?: Nodes) {
const position = node.position;
if (position?.start.offset !== undefined && position.end.offset !== undefined) {
const start = position.start.offset;
const end = position.end.offset;
if (node.type === "paragraph") splitStandaloneImages(node, start, end);
if (
node.type === "table" &&
previous?.type === "paragraph" &&
previous.position?.end.line === position.start.line - 1
) {
// MD4C only recognizes a table header on the first line of a paragraph.
const lineStart =
Math.max(source.lastIndexOf("\n", start - 1), source.lastIndexOf("\r", start - 1)) + 1;
const newline = source.slice(lineStart - 2, lineStart).replace(/[^\r\n]/g, "");
const blank = source.slice(lineStart, start).replace(/[^>]/g, " ").trimEnd();
const boundary = lineStart - newline.length;
edits.push({ start: boundary, end: lineStart, text: `${newline}${blank}${newline}` });
}
if (node.type === "link") {
const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot);
if (title !== null)
@@ -244,7 +262,8 @@ export function prepareMarkdownSource(
if (text !== source.slice(start, end)) edits.push({ start, end, text });
}
}
if ("children" in node) node.children.forEach(visit);
if ("children" in node)
node.children.forEach((child, index) => visit(child, node.children[index - 1]));
}
visit(getTree());
return {
+7 -48
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useAddHost } from "@/hosts/add-host";
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router";
@@ -12,11 +13,7 @@ import {
Terminal,
} from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { AddHostModal } from "./add-host-modal";
import { AddRemoteSshHostModal } from "./add-remote-ssh-host-modal";
import { PairLinkModal } from "./pair-link-modal";
import { Button } from "@/components/ui/button";
import { resolveAppVersion } from "@/utils/app-version";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -162,20 +159,13 @@ function useAnyHostOnline(serverIds: string[]): string | null {
);
}
export interface WelcomeScreenProps {
onHostAdded?: (profile: HostProfile) => void;
}
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
export function WelcomeScreen() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
const [isDirectOpen, setIsDirectOpen] = useState(false);
const [isRemoteSshOpen, setIsRemoteSshOpen] = useState(false);
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
const hosts = useHosts();
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
@@ -196,23 +186,10 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
router.push("/settings");
}, [router]);
const handleOpenDirect = useCallback(() => setIsDirectOpen(true), []);
const handleCloseDirect = useCallback(() => setIsDirectOpen(false), []);
const handleOpenRemoteSsh = useCallback(() => setIsRemoteSshOpen(true), []);
const handleCloseRemoteSsh = useCallback(() => setIsRemoteSshOpen(false), []);
const handleOpenPasteLink = useCallback(() => setIsPasteLinkOpen(true), []);
const handleClosePasteLink = useCallback(() => setIsPasteLinkOpen(false), []);
const handleScanQr = useCallback(() => {
router.push("/pair-scan?source=onboarding");
}, [router]);
const handleHostSaved = useCallback(
({ profile }: { profile: HostProfile; serverId: string }) => {
onHostAdded?.(profile);
finishOnboarding();
},
[onHostAdded, finishOnboarding],
);
const handleOpenDirect = useAddHost({ method: "direct", onAdded: finishOnboarding });
const handleOpenRemoteSsh = useAddHost({ method: "ssh", onAdded: finishOnboarding });
const handleOpenPasteLink = useAddHost({ method: "link", onAdded: finishOnboarding });
const handleScanQr = useAddHost({ method: "scan", onAdded: finishOnboarding });
const actions: WelcomeAction[] =
isWeb || isFdroidBuild
@@ -316,24 +293,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
</Button>
</View>
<Text style={styles.versionLabel}>{appVersionText}</Text>
<AddHostModal
visible={isDirectOpen}
onClose={handleCloseDirect}
onSaved={handleHostSaved}
/>
<AddRemoteSshHostModal
visible={isRemoteSshOpen}
onClose={handleCloseRemoteSsh}
onSaved={handleHostSaved}
/>
<PairLinkModal
visible={isPasteLinkOpen}
onClose={handleClosePasteLink}
onSaved={handleHostSaved}
/>
</ScrollView>
</View>
);
+89
View File
@@ -0,0 +1,89 @@
import { createContext, useCallback, useContext, useState, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useStore } from "zustand";
import { AddHostMethodModal } from "./internal/add-host-method-modal";
import { AddHostModal } from "./internal/add-host-modal";
import { AddRemoteSshHostModal } from "./internal/add-remote-ssh-host-modal";
import { PairLinkModal } from "./internal/pair-link-modal";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { PairScanContent } from "./internal/scan";
import { createAddHostFlow, type AddHostOptions, type AddHostSession } from "./internal/model";
const Context = createContext<ReturnType<typeof createAddHostFlow> | null>(null);
/** Owns the flow outside screen and appearance lifetimes. Callers own success navigation. */
export function AddHostProvider({ children }: { children: ReactNode }) {
const [flow] = useState(createAddHostFlow);
return <Context.Provider value={flow}>{children}</Context.Provider>;
}
export function AddHostFlowHost() {
const flow = useContext(Context);
if (!flow) throw new Error("AddHostFlowHost requires AddHostProvider");
const session = useStore(flow.store, (state) => state.session);
return session ? <AddHostSessionView key={session.id} session={session} /> : null;
}
export function useAddHost({ method, onAdded }: AddHostOptions = {}) {
const flow = useContext(Context);
if (!flow) throw new Error("useAddHost requires AddHostProvider");
return useCallback(() => flow.open({ method, onAdded }), [flow, method, onAdded]);
}
function AddHostSessionView({ session }: { session: AddHostSession }) {
const { t } = useTranslation();
const method = useStore(session.step, (state) => state.method);
const back = useCallback(() => session.choose(null), [session]);
const direct = useCallback(() => session.choose("direct"), [session]);
const ssh = useCallback(() => session.choose("ssh"), [session]);
const link = useCallback(() => session.choose("link"), [session]);
const scan = useCallback(() => session.choose("scan"), [session]);
const scanned = useCallback((serverId: string) => session.complete({ serverId }), [session]);
const header = useMemo(
() => ({ title: t("pairing.scan.title"), back: { onPress: back } }),
[t, back],
);
return (
<>
<AddHostMethodModal
visible={method === null}
onClose={session.close}
onDirectConnection={direct}
onRemoteSsh={ssh}
onPasteLink={link}
onScanQr={scan}
/>
<AddHostModal
visible={method === "direct"}
onClose={session.close}
onCancel={back}
onSaved={session.complete}
/>
<AddRemoteSshHostModal
visible={method === "ssh"}
onClose={session.close}
onCancel={back}
onSaved={session.complete}
/>
<PairLinkModal
visible={method === "link"}
onClose={session.close}
onCancel={back}
onSaved={session.complete}
/>
{method === "scan" ? (
<AdaptiveModalSheet
header={header}
visible
onClose={session.close}
scrollable={false}
snapPoints={SCAN_SNAP_POINTS}
desktopHeight="85%"
>
<PairScanContent onConnected={scanned} onClose={back} />
</AdaptiveModalSheet>
) : null}
</>
);
}
const SCAN_SNAP_POINTS = ["90%"];
@@ -1,9 +1,10 @@
import { PressHighlight } from "@/components/ui/press-highlight";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste, Terminal } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { isFdroidBuild } from "@/constants/build-profile";
import { isNative } from "@/constants/platform";
import { isElectronRuntime } from "@/desktop/host";
@@ -84,7 +85,7 @@ export function AddHostMethodModal({
onClose={onClose}
testID="add-host-method-modal"
>
<Pressable
<PressHighlight
style={styles.option}
onPress={handleDirect}
accessibilityRole="button"
@@ -98,10 +99,10 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.direct.description")}
</Text>
</View>
</Pressable>
</PressHighlight>
{isElectronRuntime() ? (
<Pressable
<PressHighlight
style={styles.option}
onPress={handleRemoteSsh}
accessibilityRole="button"
@@ -115,11 +116,11 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.remoteSsh.description")}
</Text>
</View>
</Pressable>
</PressHighlight>
) : null}
{isNative && !isFdroidBuild ? (
<Pressable
<PressHighlight
style={styles.option}
onPress={handleScan}
accessibilityRole="button"
@@ -132,10 +133,10 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.scanQr.description")}
</Text>
</View>
</Pressable>
</PressHighlight>
) : null}
<Pressable
<PressHighlight
style={styles.option}
onPress={handlePaste}
accessibilityRole="button"
@@ -149,7 +150,7 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.pasteLink.description")}
</Text>
</View>
</Pressable>
</PressHighlight>
</AdaptiveModalSheet>
);
}
@@ -1,17 +1,22 @@
import { PressHighlight } from "@/components/ui/press-highlight";
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Alert, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints";
import { buildConnectionFailureCopy } from "./connection-failure-copy";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { buildConnectionFailureCopy } from "@/components/connection-failure-copy";
import {
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { PairingTargetTracker } from "./pair-link-credentials";
import { buildDirectConnectionUri } from "./direct-connection-host";
import { PairingTargetTracker } from "@/components/pair-link-credentials";
import { buildDirectConnectionUri } from "@/components/direct-connection-host";
const FLEX_ONE_STYLE = { flex: 1 } as const;
@@ -188,7 +193,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
}
function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { probeAndUpsertDirectConnection, beginLinkPairing } = useHostMutations();
@@ -207,10 +211,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const advancedTarget = useRef(new PairingTargetTracker("", true));
const connectIcon = useMemo(
() => <Link2 size={16} color={theme.colors.accentForeground} />,
[theme.colors.accentForeground],
);
const connectIcon = useMemo(() => <ThemedLink2 size={16} />, []);
const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);
const checkboxStyle = useMemo(
@@ -415,8 +416,8 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsAdvancedOpen(false);
}, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]);
const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight;
const PasswordIcon = isPasswordVisible ? EyeOff : Eye;
const AdvancedIcon = isAdvancedOpen ? ThemedChevronDown : ThemedChevronRight;
const PasswordIcon = isPasswordVisible ? ThemedEyeOff : ThemedEye;
return (
<AdaptiveModalSheet
@@ -430,7 +431,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
<View style={styles.portRow}>
<View style={hostFieldStyle}>
<Text style={styles.label}>{t("pairing.direct.fields.host")}</Text>
<AdaptiveTextInput
<ThemedAdaptiveTextInput
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel={t("pairing.direct.fields.host")}
@@ -438,7 +439,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-host-${inputResetKey}`}
onChangeText={setHost}
placeholder="localhost"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
@@ -449,7 +449,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
</View>
<View style={portFieldStyle}>
<Text style={styles.label}>{t("pairing.direct.fields.port")}</Text>
<AdaptiveTextInput
<ThemedAdaptiveTextInput
testID="direct-port-input"
nativeID="direct-port-input"
accessibilityLabel={t("pairing.direct.fields.port")}
@@ -457,7 +457,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-port-${inputResetKey}`}
onChangeText={setPort}
placeholder="6767"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
@@ -469,7 +468,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
</View>
</View>
<Pressable
<PressHighlight
style={styles.checkboxRow}
onPress={handleToggleUseTls}
disabled={isSaving}
@@ -482,17 +481,17 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
<View style={checkboxStyle}>
{useTls ? (
<View testID="direct-ssl-toggle-checked">
<Check size={14} color={theme.colors.accentForeground} />
<ThemedCheck size={14} />
</View>
) : null}
</View>
<Text style={styles.label}>{t("pairing.direct.fields.useSsl")}</Text>
</Pressable>
</PressHighlight>
<View style={styles.field}>
<Text style={styles.label}>{t("pairing.direct.fields.password")}</Text>
<View style={styles.passwordRow}>
<AdaptiveTextInput
<ThemedAdaptiveTextInput
testID="direct-password-input"
nativeID="direct-password-input"
accessibilityLabel={t("pairing.direct.fields.password")}
@@ -500,7 +499,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-password-${inputResetKey}`}
onChangeText={setPassword}
placeholder={t("pairing.direct.fields.optional")}
placeholderTextColor={theme.colors.foregroundMuted}
style={passwordInputStyle}
autoCapitalize="none"
autoCorrect={false}
@@ -509,7 +507,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
returnKeyType="done"
onSubmitEditing={handleSubmitEditing}
/>
<Pressable
<PressHighlight
style={styles.iconButton}
onPress={handleTogglePasswordVisibility}
disabled={isSaving}
@@ -521,13 +519,13 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
}
testID="direct-password-visibility-toggle"
>
<PasswordIcon size={18} color={theme.colors.foregroundMuted} />
</Pressable>
<PasswordIcon size={18} />
</PressHighlight>
</View>
</View>
<View style={styles.field}>
<Pressable
<PressHighlight
style={styles.advancedToggle}
onPress={handleToggleAdvanced}
disabled={isSaving}
@@ -537,11 +535,11 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
}
testID="direct-host-advanced-toggle"
>
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} />
<AdvancedIcon size={16} />
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
</Pressable>
</PressHighlight>
{isAdvancedOpen ? (
<AdaptiveTextInput
<ThemedAdaptiveTextInput
testID="direct-host-uri-input"
nativeID="direct-host-uri-input"
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
@@ -549,7 +547,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-host-uri-${inputResetKey}`}
onChangeText={handleChangeAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
@@ -585,3 +582,23 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
</AdaptiveModalSheet>
);
}
const ThemedLink2 = withUnistyles(Link2, (theme) => ({ color: theme.colors.accentForeground }));
const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.accentForeground }));
const ThemedChevronDown = withUnistyles(ChevronDown, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedEye = withUnistyles(Eye, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedEyeOff = withUnistyles(EyeOff, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
@@ -1,7 +1,7 @@
import { PressHighlight } from "@/components/ui/press-highlight";
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Pressable,
Text,
View,
type PressableStateCallbackType,
@@ -23,7 +23,7 @@ import {
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { useIsCompactFormFactor } from "@/constants/layout";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const ThemedTerminal = withUnistyles(Terminal);
@@ -174,11 +174,11 @@ export function AddRemoteSshHostModal({
}
clear();
onClose();
onSaved?.({
...result,
isNewHost: !hosts.some((profile) => profile.serverId === result.serverId),
});
onClose();
}, [clear, hosts, isSaving, onClose, onSaved, probeAndUpsertRemoteSshConnection, t]);
const handleTargetChange = useCallback((value: string) => {
targetRef.current = value;
@@ -260,7 +260,7 @@ export function AddRemoteSshHostModal({
onSubmitEditing={handleSubmit}
/>
</View>
<Pressable
<PressHighlight
style={iconButtonStyle}
onPress={handleTogglePasswordVisibility}
disabled={isSaving}
@@ -273,7 +273,7 @@ export function AddRemoteSshHostModal({
testID="remote-ssh-password-visibility-toggle"
>
{isPasswordVisible ? <ThemedEyeOff size={18} /> : <ThemedEye size={18} />}
</Pressable>
</PressHighlight>
</View>
</Field>
<View style={styles.actions}>
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { createAddHostFlow } from "./model";
describe("add host flow", () => {
it("closes before notifying the consumer and ignores duplicate completion", () => {
const flow = createAddHostFlow();
const added: string[] = [];
flow.open({
onAdded: (serverId) => {
expect(flow.store.getState().session).toBeNull();
added.push(serverId);
},
});
const session = flow.store.getState().session!;
session.choose("direct");
session.complete({ serverId: "new-host" });
session.complete({ serverId: "new-host" });
expect(added).toEqual(["new-host"]);
});
it("cancels without a success action and accepts a fresh request", () => {
const flow = createAddHostFlow();
const added: string[] = [];
flow.open({ onAdded: (id) => added.push(id) });
const cancelled = flow.store.getState().session!;
cancelled.choose("scan");
cancelled.close();
expect(flow.store.getState().session).toBeNull();
flow.open({ method: "link" });
const current = flow.store.getState().session!;
cancelled.complete({ serverId: "late-result" });
cancelled.close();
expect(flow.store.getState().session).toBe(current);
expect(current.step.getState()).toEqual({ method: "link" });
expect(added).toEqual([]);
current.complete({ serverId: "new-host" });
expect(flow.store.getState().session).toBeNull();
});
it("returns to method selection within the same request", () => {
const flow = createAddHostFlow();
flow.open();
const session = flow.store.getState().session!;
session.choose("ssh");
session.choose(null);
expect(flow.store.getState().session).toBe(session);
expect(session.step.getState()).toEqual({ method: null });
});
});
@@ -0,0 +1,47 @@
import { createStore, type StoreApi } from "zustand/vanilla";
export type AddHostMethod = "direct" | "ssh" | "link" | "scan";
export interface AddHostOptions {
method?: AddHostMethod;
onAdded?: (serverId: string) => void;
}
export interface AddHostSession {
id: number;
step: StoreApi<{ method: AddHostMethod | null }>;
choose(method: AddHostMethod | null): void;
close(): void;
complete(result: { serverId: string }): void;
}
export function createAddHostFlow() {
const store = createStore<{ session: AddHostSession | null }>(() => ({ session: null }));
let nextId = 0;
function createSession(options: AddHostOptions) {
const step = createStore<{ method: AddHostMethod | null }>(() => ({
method: options.method ?? null,
}));
const session = {
id: ++nextId,
step,
choose(method: AddHostMethod | null) {
step.setState({ method });
},
close() {
if (store.getState().session === session) store.setState({ session: null });
},
complete({ serverId }: { serverId: string }) {
if (store.getState().session !== session) return;
session.close();
options.onAdded?.(serverId);
},
};
return session;
}
return {
store,
open(options: AddHostOptions = {}) {
store.setState({ session: createSession(options) });
},
};
}
@@ -1,16 +1,20 @@
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Link } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime";
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import {
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
import { PairingTargetTracker } from "./pair-link-credentials";
import { PairingTargetTracker } from "@/components/pair-link-credentials";
import { Button } from "@/components/ui/button";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
@@ -100,7 +104,6 @@ function PairLinkModalContent({
onCancel,
onSaved,
}: PairLinkModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { beginLinkPairing } = useHostMutations();
@@ -126,10 +129,7 @@ function PairLinkModalContent({
resetPasswordInput();
}, []);
const pairIcon = useMemo(
() => <Link size={16} color={theme.colors.accentForeground} />,
[theme.colors.accentForeground],
);
const pairIcon = useMemo(() => <ThemedLink size={16} />, []);
const handleClose = useCallback(() => {
if (isSaving) return;
@@ -212,7 +212,7 @@ function PairLinkModalContent({
<View style={styles.field}>
<Text style={styles.label}>{t("pairing.link.label")}</Text>
<AdaptiveTextInput
<ThemedAdaptiveTextInput
ref={inputRef}
initialValue={initialUrl}
testID="pair-link-input"
@@ -220,7 +220,6 @@ function PairLinkModalContent({
accessibilityLabel={t("pairing.link.label")}
onChangeText={handleChangeOfferUrl}
placeholder="https://app.paseo.sh/#offer=..."
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoFocus
autoCapitalize="none"
@@ -235,7 +234,7 @@ function PairLinkModalContent({
<Text style={styles.label}>
{t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })}
</Text>
<AdaptiveTextInput
<ThemedAdaptiveTextInput
testID="pair-link-password-input"
resetKey={`pair-link-password-${passwordResetKey}`}
accessibilityLabel={t("pairing.hostPassword.label")}
@@ -274,3 +273,9 @@ function PairLinkModalContent({
</AdaptiveModalSheet>
);
}
const ThemedLink = withUnistyles(Link, (theme) => ({ color: theme.colors.accentForeground }));
const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
@@ -1,17 +1,14 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { openPairScan } from "@/hosts/pair-scan-model";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { openPairScan } from "./pair-scan-model";
import { isWeb } from "@/constants/platform";
import { BackHeader } from "@/components/headers/back-header";
import { PairLinkModal } from "@/components/pair-link-modal";
import { PairLinkModal } from "./pair-link-modal";
import { Button } from "@/components/ui/button";
const styles = StyleSheet.create((theme) => ({
@@ -113,50 +110,26 @@ const styles = StyleSheet.create((theme) => ({
},
}));
export default function PairScanScreen() {
const { theme } = useUnistyles();
export function PairScanContent({
onConnected,
onClose,
}: {
onConnected: (serverId: string) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
const params = useLocalSearchParams<{
source?: string;
}>();
const source = typeof params.source === "string" ? params.source : "settings";
const [permission, requestPermission] = useCameraPermissions();
const navigateToPairedHost = useCallback(
(serverId: string) => {
if (source === "onboarding") {
router.replace(buildHostRootRoute(serverId));
return;
}
router.replace(buildSettingsHostRoute(serverId));
},
[router, source],
);
const [scan] = useState(() =>
openPairScan({
importConnectionLink: (link) =>
getHostRuntimeStore().importConnectionLink(
link,
source === "onboarding" ? "hostRoot" : "hostSettings",
),
onConnected: navigateToPairedHost,
importConnectionLink: (link) => getHostRuntimeStore().importConnectionLink(link, "hostRoot"),
onConnected,
}),
);
const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState);
useEffect(() => () => scan.close(), [scan]);
const closeToSource = useCallback(() => {
try {
router.back();
} catch {
router.replace("/" as Href);
}
}, [router]);
useEffect(() => {
if (isWeb) return;
if (permission && permission.granted) return;
@@ -169,35 +142,29 @@ export default function PairScanScreen() {
[scan],
);
const handleRouterBack = useCallback(() => router.back(), [router]);
const savePasswordPairing = useCallback(
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
[navigateToPairedHost],
({ serverId }: { serverId: string }) => onConnected(serverId),
[onConnected],
);
const handleRequestPermission = useCallback(() => {
void requestPermission();
}, [requestPermission]);
const bodyStyle = useMemo(
() => [styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }],
[insets.bottom, theme.spacing],
);
const helperTextStyle = useMemo(
() => [styles.helperText, { color: theme.colors.foreground }],
[theme.colors.foreground],
() => [styles.body, { paddingBottom: insets.bottom + 24 }],
[insets.bottom],
);
if (isWeb) {
return (
<View style={styles.container}>
<BackHeader title={t("pairing.scan.title")} onBack={handleRouterBack} />
<View style={bodyStyle}>
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>{t("pairing.scan.webUnavailableTitle")}</Text>
<Text style={styles.permissionBody}>{t("pairing.scan.webUnavailableBody")}</Text>
<Pressable style={styles.permissionButton} onPress={closeToSource}>
<Text style={styles.permissionButtonText}>{t("pairing.scan.backToSettings")}</Text>
</Pressable>
<Button style={styles.permissionButton} onPress={onClose}>
<Text style={styles.permissionButtonText}>{t("common.actions.back")}</Text>
</Button>
</View>
</View>
</View>
@@ -208,16 +175,14 @@ export default function PairScanScreen() {
return (
<View style={styles.container}>
<BackHeader title={t("pairing.scan.title")} onBack={closeToSource} />
<View style={bodyStyle}>
{!granted ? (
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>{t("pairing.scan.cameraPermissionTitle")}</Text>
<Text style={styles.permissionBody}>{t("pairing.scan.cameraPermissionBody")}</Text>
<Pressable style={styles.permissionButton} onPress={handleRequestPermission}>
<Button style={styles.permissionButton} onPress={handleRequestPermission}>
<Text style={styles.permissionButtonText}>{t("pairing.scan.grantPermission")}</Text>
</Pressable>
</Button>
</View>
) : (
<View style={styles.cameraWrap}>
@@ -235,10 +200,10 @@ export default function PairScanScreen() {
<View style={[styles.corner, styles.cornerBR]} />
</View>
{scanState.status === "pairing" ? (
<Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text>
<Text style={styles.helperText}>{t("pairing.scan.pairing")}</Text>
) : null}
{scanState.status === "stopped" && scanState.error ? (
<Text style={helperTextStyle}>{scanState.error}</Text>
<Text style={styles.helperText}>{scanState.error}</Text>
) : null}
{scanState.status === "stopped" ? (
<Button
+4 -11
View File
@@ -1,12 +1,11 @@
import { useAddHost } from "@/hosts/add-host";
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import { router } from "expo-router";
import { Server } from "lucide-react-native";
import { create } from "zustand";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -20,7 +19,6 @@ import {
} from "@/lib/overlay-root";
import { useHosts } from "@/runtime/host-runtime";
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
type HostFilter = (host: HostProfile) => boolean;
type HostChoiceHandler = (serverId: string) => void | Promise<void>;
@@ -65,6 +63,7 @@ function matchesHostQuery(host: HostProfile, query: string): boolean {
}
export function useHostChooser() {
const addHost = useAddHost();
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const open = useHostChooserStore((state) => state.open);
@@ -76,13 +75,7 @@ export function useHostChooser() {
);
if (availableHosts.length === 0) {
(
input.onNoHosts ??
(() => {
usePanelStore.getState().showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
})
)();
(input.onNoHosts ?? addHost)();
return false;
}
@@ -98,7 +91,7 @@ export function useHostChooser() {
});
return true;
},
[hosts, localServerId, open],
[hosts, localServerId, open, addHost],
);
}
@@ -14,6 +14,45 @@ function thought(text: string, status: "loading" | "ready" = "loading"): StreamI
}
describe("plugin timeline projection", () => {
it.each(["message", "finished", "errored", "permission-required", "closed"] as const)(
"preserves immutable agent-message provenance for %s cards",
(event) => {
const agentMessage = {
event,
sender: { id: "remote::reviewer", title: "QA question-timing #7014/#7000/#6809" },
text: "The original message, including **Markdown**.",
};
const source: StreamItem = {
kind: "tool_call",
id: "message-1",
timestamp: new Date("2026-01-01T00:00:00.000Z"),
payload: {
source: "agent",
data: {
provider: "codex",
callId: "delivery-1",
name: "agent_message",
status: "completed",
error: null,
detail: { type: "plain_text", text: agentMessage.text, icon: "bot" },
agentMessage,
},
},
};
const inspect: TimelineItemTransform = ({ item }) => {
expect(item.type).toBe("tool_call");
if (item.type !== "tool_call") throw new Error("Expected a tool call");
expect(item.agentMessage).toEqual(agentMessage);
expect(item.agentMessage).not.toBe(agentMessage);
expect(Object.isFrozen(item.agentMessage)).toBe(true);
expect(Object.isFrozen(item.agentMessage?.sender)).toBe(true);
return undefined;
};
expect(projectPluginTimelineItems([source], inspect)).toEqual([source]);
expect(Object.isFrozen(agentMessage.sender)).toBe(false);
},
);
it("does not expose mutable tool detail from stream state", () => {
const detail = { type: "read" as const, filePath: "/repo/original.ts" };
const source: StreamItem = {
@@ -22,13 +22,14 @@ function cloneAndFreeze<T>(value: T): T {
}
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
const { callId, name, status, error, detail, metadata } = data;
const { callId, name, status, error, detail, metadata, agentMessage } = data;
const base = {
type: "tool_call" as const,
callId,
name,
detail,
...(metadata ? { metadata } : {}),
...(agentMessage ? { agentMessage } : {}),
};
switch (status) {
case "running":
@@ -1,3 +1,4 @@
import { useAddHost } from "@/hosts/add-host";
import type {
CreateAgentRequestOptions,
CreateWorkspaceRequestOptions,
@@ -8,7 +9,6 @@ import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { router } from "expo-router";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { createNameId } from "mnemonic-id";
@@ -62,7 +62,6 @@ import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { toErrorMessage } from "@/utils/error-messages";
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
import {
getHostProjectId,
getWorktreeSupportForHostProject,
@@ -1166,9 +1165,7 @@ export function NewWorkspaceScreen({
openAddProjectPicker(selectedServerId);
}, [openAddProjectPicker, selectedServerId]);
const handleAddHost = useCallback(() => {
router.push(buildSettingsAddHostRoute(Date.now()));
}, []);
const handleAddHost = useAddHost();
const openPicker = useCallback(() => {
setPickerOpen(true);
+8 -97
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAddHost } from "@/hosts/add-host";
import { useCallback, useMemo, useRef, useState } from "react";
import type { ComponentType, ReactNode } from "react";
import {
Alert,
@@ -74,10 +75,6 @@ import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window
import { confirmDialog } from "@/utils/confirm-dialog";
import { BackHeader } from "@/components/headers/back-header";
import { PageLayout } from "@/components/page-layout";
import { AddHostMethodModal } from "@/components/add-host-method-modal";
import { AddHostModal } from "@/components/add-host-modal";
import { AddRemoteSshHostModal } from "@/components/add-remote-ssh-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal";
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
import { EditorSection } from "@/screens/settings/editor-section";
import { FilesSection } from "@/screens/settings/files-section";
@@ -1089,21 +1086,15 @@ function SettingsSidebar({
export interface SettingsScreenProps {
view: SettingsView;
openAddHostIntent?: string | null;
}
export default function SettingsScreen({ view, openAddHostIntent = null }: SettingsScreenProps) {
export default function SettingsScreen({ view }: SettingsScreenProps) {
const router = useRouter();
const { t } = useTranslation();
const voiceAudioEngine = useVoiceAudioEngineOptional();
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isRemoteSshVisible, setIsRemoteSshVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false);
const [playbackTestResult, setPlaybackTestResult] = useState<string | null>(null);
const lastOpenedAddHostIntentRef = useRef<string | null>(null);
const isDesktopApp = isElectronRuntime();
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
@@ -1164,50 +1155,10 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
}
}, [isPlaybackTestRunning, t, voiceAudioEngine]);
const closeAddConnectionFlow = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(false);
setIsRemoteSshVisible(false);
setIsPasteLinkVisible(false);
}, []);
const goBackToAddConnectionMethods = useCallback(() => {
setIsDirectHostVisible(false);
setIsRemoteSshVisible(false);
setIsPasteLinkVisible(false);
setIsAddHostMethodVisible(true);
}, []);
const handleAddHost = useCallback(() => {
setIsAddHostMethodVisible(true);
}, []);
useEffect(() => {
if (!openAddHostIntent || lastOpenedAddHostIntentRef.current === openAddHostIntent) {
return;
}
lastOpenedAddHostIntentRef.current = openAddHostIntent;
handleAddHost();
}, [handleAddHost, openAddHostIntent]);
const handleSelectDirectConnection = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(true);
}, []);
const handleSelectRemoteSsh = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsRemoteSshVisible(true);
}, []);
const handleSelectPasteLink = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsPasteLinkVisible(true);
}, []);
const handleHostAdded = useCallback(
({ serverId }: { serverId: string }) => {
const target = buildSettingsHostSectionRoute(serverId, "connections");
(serverId: string) => {
selectSettingsHost(serverId);
const target = buildSettingsHostSectionRoute(serverId, "host");
if (isCompactLayout) {
router.push(target);
} else {
@@ -1217,6 +1168,8 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
[isCompactLayout, router],
);
const handleAddHost = useAddHost({ onAdded: handleHostAdded });
const handleSelectSection = useCallback(
(section: SettingsSectionSlug) => {
const target = buildSettingsSectionRoute(section);
@@ -1272,14 +1225,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
[activeHostServerId, handleAddHost, isCompactLayout, router],
);
const handleScanQr = useCallback(() => {
closeAddConnectionFlow();
router.push({
pathname: "/pair-scan",
params: { source: "settings" },
});
}, [closeAddConnectionFlow, router]);
const handleHostRemoved = useCallback(() => {
const fallback = buildSettingsSectionRoute("general");
if (isCompactLayout) {
@@ -1388,37 +1333,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
);
}
const addHostModals = (
<>
<AddHostMethodModal
visible={isAddHostMethodVisible}
onClose={closeAddConnectionFlow}
onDirectConnection={handleSelectDirectConnection}
onRemoteSsh={handleSelectRemoteSsh}
onPasteLink={handleSelectPasteLink}
onScanQr={handleScanQr}
/>
<AddHostModal
visible={isDirectHostVisible}
onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
<AddRemoteSshHostModal
visible={isRemoteSshVisible}
onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
<PairLinkModal
visible={isPasteLinkVisible}
onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
</>
);
// Mobile root: full-screen sidebar-as-list.
if (isCompactLayout && view.kind === "root") {
return (
@@ -1436,7 +1350,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
layout="mobile"
/>
</ScrollView>
{addHostModals}
</View>
);
}
@@ -1447,7 +1360,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
<PageLayout title={detailTitle} onBack={handleBackFromDetail}>
{content}
</PageLayout>
{addHostModals}
</View>
);
}
@@ -1482,7 +1394,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
</View>
</WindowChromeRegion>
</View>
{addHostModals}
</View>
);
}
@@ -8,7 +8,6 @@ import {
buildOpenProjectRoute,
resolveKnownHostRoute,
buildSessionsRoute,
buildSettingsAddHostRoute,
buildProjectSettingsRoute,
buildProjectsSettingsRoute,
decodeFilePathFromPathSegment,
@@ -185,14 +184,6 @@ describe("workspace route parsing", () => {
});
describe("projects settings routes", () => {
it("buildSettingsAddHostRoute opens settings with the add-host flag", () => {
expect(buildSettingsAddHostRoute()).toBe("/settings/general?addHost=1");
});
it("buildSettingsAddHostRoute accepts a repeatable intent id", () => {
expect(buildSettingsAddHostRoute("retry 1")).toBe("/settings/general?addHost=retry%201");
});
it("buildProjectsSettingsRoute scopes the list to a host", () => {
expect(buildProjectsSettingsRoute("host a")).toBe("/settings/hosts/host%20a/projects");
});
-4
View File
@@ -553,10 +553,6 @@ export function buildSettingsSectionRoute(section: SettingsSectionSlug) {
return `/settings/${section}` as const;
}
export function buildSettingsAddHostRoute(intentId: string | number = "1") {
return `/settings/general?addHost=${encodeURIComponent(String(intentId))}` as const;
}
export function buildSettingsHostRoute(serverId: string) {
const normalized = trimNonEmpty(serverId);
if (!normalized) {
+16
View File
@@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url";
import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { chromium } from "playwright";
import { expect } from "@playwright/test";
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs";
@@ -198,6 +199,20 @@ async function waitForDesktopStatus(page) {
);
}
async function settleDesktopStartup(page) {
await expect(
page.getByRole("button", { name: "Desktop browser workspace 1", exact: true }),
).toBeVisible({ timeout: timeoutMs });
// The dev launcher supplies its package directory as an open-project request.
// Before the local host connects, that request opens the connection prompt.
// Finish startup before exercising the unrelated Settings/browser workflows.
const connectionPrompt = page.getByTestId("add-host-method-modal");
if (await connectionPrompt.isVisible()) {
await connectionPrompt.getByRole("button", { name: "Close", exact: true }).click();
await expect(connectionPrompt).toBeHidden();
}
}
async function startTargetPage() {
const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
@@ -1208,6 +1223,7 @@ async function main() {
const page = await waitForAppPage(browser, expoPort);
await hideDeveloperToasts(page);
const status = await waitForDesktopStatus(page);
await settleDesktopStartup(page);
const checkPluginLinks = () =>
runPluginLinksRegression({
@@ -14,6 +14,8 @@ 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 { createGitLabService } from "../../services/gitlab-service.js";
import { createGiteaService } from "../../services/gitea-service.js";
import type { ForgeService } from "../../../services/forge-service.js";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
@@ -30,6 +32,7 @@ function createPullRequest(
state: "open",
baseRefName: "main",
headRefName: "feature",
headSha: "same-head",
isMerged: true,
...overrides,
};
@@ -46,6 +49,8 @@ function createSnapshot(overrides?: {
repoRoot: "/tmp/repo",
mainRepoRoot: "/tmp/repo",
currentBranch: "feature",
upstreamKnown: true,
headSha: "same-head",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: true,
isDirty: false,
@@ -346,6 +351,89 @@ afterEach(() => {
});
describe("archiveIfSafe", () => {
test("archives an exact-head GitLab merge using the adapter status", async () => {
const mr = {
iid: 123,
title: "Merge me",
web_url: "https://gitlab.example.com/acme/repo/-/merge_requests/123",
state: "merged",
source_branch: "feature",
target_branch: "main",
source_project_id: 1,
target_project_id: 1,
sha: "same-head",
references: { full: "acme/repo!123" },
};
const forge = createGitLabService({
resolveGlabPath: async () => "/test/glab",
resolveRemoteUrl: async () => "https://gitlab.example.com/acme/repo.git",
runner: async (args) => ({
stdout: JSON.stringify(args[1] === "list" ? [mr] : mr),
stderr: "",
}),
});
const status = await forge.getCurrentPullRequestStatus({ cwd: CWD, headRef: "feature" });
expect(status).toMatchObject({ isMerged: true, headSha: "same-head" });
const harness = createHarness({ snapshot: createSnapshot({ pullRequest: status }) });
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).toHaveBeenCalledTimes(1);
});
test("archives a Gitea merge when optional checks fail but selected head is known", async () => {
const pr = {
number: 123,
html_url: "https://gitea.com/acme/repo/pulls/123",
title: "Merge me",
state: "closed",
merged: true,
head: {
ref: "feature",
sha: "same-head",
repo: { id: 1, full_name: "acme/repo", owner: { login: "acme" } },
},
base: { ref: "main", repo: { id: 1, owner: { login: "acme" } } },
};
const forge = createGiteaService({
resolveTeaPath: async () => "/test/tea",
resolveRemoteUrl: async () => "https://gitea.com/acme/repo.git",
runner: async (args) => {
if (args[0] === "pr" && args[1] === "list") return { stdout: "[]", stderr: "" };
if (args[0] === "api" && args[1].includes("/pulls?"))
return { stdout: JSON.stringify([pr]), stderr: "" };
throw new Error("checks unavailable");
},
});
const status = await forge.getCurrentPullRequestStatus({ cwd: CWD, headRef: "feature" });
expect(status).toMatchObject({ isMerged: true, headSha: "same-head" });
const harness = createHarness({ snapshot: createSnapshot({ pullRequest: status }) });
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).toHaveBeenCalledTimes(1);
});
test("does not archive when the checkout and merged PR heads differ", async () => {
const harness = createHarness({
snapshot: createSnapshot({
git: { headSha: "checkout-head" } as Partial<WorkspaceGitRuntimeSnapshot["git"]>,
pullRequest: createPullRequest({ headSha: "remote-head" } as Partial<
NonNullable<WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"]>
>),
}),
});
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
test("does not archive when either head or the worktree state is unavailable", async () => {
for (const snapshot of [
createSnapshot({ git: { headSha: undefined } }),
createSnapshot({ pullRequest: createPullRequest({ headSha: undefined }) }),
createSnapshot({ git: { isDirty: null } }),
]) {
const harness = createHarness({ snapshot });
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
}
});
beforeEach(() => {
vi.clearAllMocks();
});
@@ -381,6 +469,26 @@ describe("archiveIfSafe", () => {
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
test("does not archive when the upstream lookup failed", async () => {
const harness = createHarness({
snapshot: createSnapshot({
git: { aheadOfOrigin: null, upstreamKnown: false } as Partial<
WorkspaceGitRuntimeSnapshot["git"]
>,
}),
});
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
test("does not archive when upstream safety provenance is missing", async () => {
const harness = createHarness({
snapshot: createSnapshot({ git: { aheadOfOrigin: 0, upstreamKnown: undefined } }),
});
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
test("archives when the PR is merged and the upstream branch was deleted", async () => {
const harness = createHarness({
snapshot: createSnapshot({ git: { aheadOfOrigin: null, behindOfOrigin: null } }),
@@ -47,6 +47,19 @@ const defaultDependencies: ArchiveIfSafeDependencies = {
killTerminalsForWorkspace,
};
function isEligibleForMergeArchive(snapshot: WorkspaceGitRuntimeSnapshot): boolean {
const pullRequest = snapshot.forge.pullRequest;
return Boolean(
pullRequest?.isMerged &&
!snapshot.forge.error &&
snapshot.git.headSha &&
snapshot.git.headSha === pullRequest.headSha &&
snapshot.git.isDirty === false &&
snapshot.git.upstreamKnown === true &&
(snapshot.git.aheadOfOrigin === 0 || snapshot.git.aheadOfOrigin === null),
);
}
export async function archiveIfSafe(input: {
workspaceId: string;
snapshot: WorkspaceGitRuntimeSnapshot;
@@ -59,13 +72,7 @@ export async function archiveIfSafe(input: {
const cwd = snapshot.cwd;
const pullRequest = snapshot.forge.pullRequest;
if (!pullRequest?.isMerged) {
return;
}
if (snapshot.git.isDirty === true) {
return;
}
if (typeof snapshot.git.aheadOfOrigin === "number" && snapshot.git.aheadOfOrigin > 0) {
if (!pullRequest || !isEligibleForMergeArchive(snapshot)) {
return;
}
@@ -30,8 +30,8 @@ interface PullRequestObservation {
const cleanupPaths: string[] = [];
const cleanupServices: WorkspaceGitServiceImpl[] = [];
function run(cwd: string, args: string[]): void {
execFileSync("git", args, { cwd, stdio: "pipe" });
function run(cwd: string, args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" });
}
async function settleObservation(): Promise<void> {
@@ -63,6 +63,7 @@ async function createWorkspaceFiles() {
async function createWorkspaceJourney() {
const { repoDir, worktree } = await createWorkspaceFiles();
const remoteHeadSha = run(worktree.worktreePath, ["rev-parse", "HEAD"]).trim();
const workspaceId = "workspace-under-test";
let active = true;
let currentPullRequest: PullRequestObservation | null = null;
@@ -81,6 +82,7 @@ async function createWorkspaceJourney() {
repoRoot: worktree.worktreePath,
mainRepoRoot: repoDir,
currentBranch: branch,
headSha: run(worktree.worktreePath, ["rev-parse", "HEAD"]).trim(),
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: true,
isDirty: false,
@@ -102,6 +104,7 @@ async function createWorkspaceJourney() {
state: currentPullRequest.state,
baseRefName: "main",
headRefName: currentPullRequest.headRefName,
headSha: remoteHeadSha,
isMerged: currentPullRequest.state === "merged",
}
: null,
@@ -161,9 +164,13 @@ async function createWorkspaceJourney() {
};
}
async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
async function createUnobservedWorkspace(
archiveBarrier?: Promise<void>,
changeBeforeArchiveRead = false,
) {
const { repoDir, paseoHome, worktree } = await createWorkspaceFiles();
const cwd = worktree.worktreePath;
let remoteHeadSha = run(cwd, ["rev-parse", "HEAD"]).trim();
run(repoDir, ["remote", "add", "origin", "https://github.com/acme/repo.git"]);
run(repoDir, ["update-ref", "refs/remotes/origin/workspace", "HEAD"]);
run(cwd, ["branch", "--set-upstream-to=origin/workspace"]);
@@ -176,38 +183,58 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
const reads: Array<string | undefined> = [];
const archives: string[] = [];
let active = true;
const service = new WorkspaceGitServiceImpl({
logger,
paseoHome,
deps: {
forgeOverrides: { github },
getPullRequestStatus: async (_cwd, _forge, options) => {
reads.push(options?.reason);
return {
status: observation && {
...observation,
title: observation.headRefName,
baseRefName: "main",
isMerged: observation.state === "merged",
},
authState: "authenticated",
featuresEnabled: true,
githubFeaturesEnabled: true,
};
function createService() {
const service = new WorkspaceGitServiceImpl({
logger,
paseoHome,
deps: {
forgeOverrides: { github },
getPullRequestStatus: async (_cwd, _forge, options) => {
reads.push(options?.reason);
return {
status: observation && {
...observation,
headSha: remoteHeadSha,
title: observation.headRefName,
baseRefName: "main",
isMerged: observation.state === "merged",
},
authState: "authenticated",
featuresEnabled: true,
githubFeaturesEnabled: true,
};
},
},
},
});
cleanupServices.push(service);
});
cleanupServices.push(service);
return service;
}
let service = createService();
const options = {
logger,
paseoHome,
daemonConfigStore: { get: () => ({ autoArchiveAfterMerge: true }) },
workspaceGitService: service,
workspaceGitService: {
onSnapshotUpdated: (listener: (snapshot: WorkspaceGitRuntimeSnapshot) => void) =>
service.onSnapshotUpdated(listener),
getSnapshot: async (
snapshotCwd: string,
readOptions?: { reason?: string; force?: boolean },
) => {
if (changeBeforeArchiveRead && readOptions?.reason === "auto-archive-on-merge") {
changeBeforeArchiveRead = false;
writeFileSync(path.join(cwd, "README.md"), "changed after notification\n");
run(cwd, ["add", "README.md"]);
run(cwd, ["-c", "commit.gpgsign=false", "commit", "-m", "after notification"]);
}
return service.getSnapshot(snapshotCwd, readOptions);
},
},
getAutoArchivedChangeRequestUrl: async () => null,
listActiveWorkspaces: async () =>
active ? [{ workspaceId: "workspace-under-test", cwd, kind: "worktree" }] : [],
} as unknown as AutoArchiveOnMergeOptions;
setupAutoArchiveOnMerge(options, {
const archiveDependencies: AutoArchiveOnMergeDependencies = {
resolvePath: path.resolve,
archiveIfSafe: async (input) =>
archiveIfSafe({
@@ -228,12 +255,22 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
},
},
}),
});
};
let subscription = setupAutoArchiveOnMerge(options, archiveDependencies);
return {
archives,
reads,
metrics: () => service.getMetrics(),
isActive: () => active,
restartProducer: async () => {
subscription.unsubscribe();
await service.dispose();
service = createService();
subscription = setupAutoArchiveOnMerge(options, archiveDependencies);
},
advanceRemoteHead: () => {
remoteHeadSha = "a".repeat(40);
},
makeDirty: () => writeFileSync(path.join(cwd, "README.md"), "uncommitted work\n"),
commitUnpushed: () => {
writeFileSync(path.join(cwd, "README.md"), "unpushed work\n");
@@ -270,6 +307,24 @@ afterEach(async () => {
});
describe("workspace auto-archive behavior", () => {
test("rechecks a checkout changed after the merged notification", async () => {
const workspace = await createUnobservedWorkspace(undefined, true);
await workspace.observe("open");
await workspace.observe("merged");
expect(workspace.reads).toContain("auto-archive-on-merge");
expect(workspace.archives).toEqual([]);
expect(workspace.isActive()).toBe(true);
});
test("keeps a merged badge and an active workspace when the remote head advanced", async () => {
const workspace = await createUnobservedWorkspace();
await workspace.observe("open");
workspace.advanceRemoteHead();
const merged = await workspace.observe("merged");
expect(merged.forge.pullRequest).toMatchObject({ state: "merged", headSha: "a".repeat(40) });
expect(merged.git.headSha).not.toBe(merged.forge.pullRequest?.headSha);
expect(workspace.isActive()).toBe(true);
expect(workspace.archives).toEqual([]);
});
test("archives a clean owned workspace on an unobserved open-to-merged transition only once", async () => {
let releaseArchive!: () => void;
const archiveBarrier = new Promise<void>((resolve) => {
@@ -292,10 +347,11 @@ describe("workspace auto-archive behavior", () => {
expect(workspace.metrics().workspaceListenerCount).toBe(0);
expect(workspace.archives).toEqual(["workspace-under-test"]);
// Confirmation reads the completed snapshot cache; it does not shell out again.
// The archive decision rereads the checkout and forge after the transition.
expect(workspace.reads).toEqual([
"workspace-journey",
"workspace-journey",
"auto-archive-on-merge",
"workspace-journey",
]);
} finally {
@@ -306,11 +362,14 @@ describe("workspace auto-archive behavior", () => {
test("keeps an unobserved workspace active when its first snapshot is already merged", async () => {
const workspace = await createUnobservedWorkspace();
await workspace.observe("merged");
workspace.advanceRemoteHead();
expect((await workspace.observe("merged")).forge.pullRequest?.state).toBe("merged");
await workspace.restartProducer();
expect((await workspace.observe("merged")).forge.pullRequest?.state).toBe("merged");
expect(workspace.metrics().workspaceListenerCount).toBe(0);
expect(workspace.archives).toEqual([]);
expect(workspace.reads).toEqual(["workspace-journey"]);
expect(workspace.reads).toEqual(["workspace-journey", "workspace-journey"]);
});
test("keeps dirty work when an unobserved workspace PR merges", async () => {
@@ -90,6 +90,11 @@ test("fans one fresh observation out to every workspace attached to its exact cw
await finished;
expect(getSnapshot).toHaveBeenCalledTimes(1);
expect(getSnapshot).toHaveBeenCalledWith("/repo/worktree/.", {
force: true,
afterInFlight: true,
reason: "auto-archive-on-merge",
});
expect(calls).toEqual([
{ workspaceId: "workspace-a", pullRequest: freshSnapshot.forge.pullRequest },
{ workspaceId: "workspace-b", pullRequest: freshSnapshot.forge.pullRequest },
@@ -63,6 +63,8 @@ export function setupAutoArchiveOnMerge(
let freshSnapshot: WorkspaceGitRuntimeSnapshot | null;
try {
freshSnapshot = await options.workspaceGitService.getSnapshot(snapshot.cwd, {
force: true,
afterInFlight: true,
reason: "auto-archive-on-merge",
});
} catch (error) {
@@ -150,6 +150,8 @@ function currentPullRequestJson(overrides: Record<string, unknown> = {}): string
isDraft: false,
baseRefName: "main",
headRefName: "feature",
headRepositoryOwner: { login: "acme" },
headRepository: { name: "repo" },
mergedAt: null,
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
@@ -802,6 +804,28 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
service.dispose();
});
test("an after-in-flight read refreshes again after an earlier forced read", async () => {
const forcedRefresh = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi
.fn<() => Promise<CheckoutStatusGit>>()
.mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD))
.mockImplementationOnce(async () => forcedRefresh.promise)
.mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD));
const service = createService({ getCheckoutStatus });
await service.getSnapshot(REPO_CWD);
const first = service.getSnapshot(REPO_CWD, { force: true, reason: "earlier" });
await flushPromises();
const second = service.getSnapshot(REPO_CWD, {
force: true,
afterInFlight: true,
reason: "archive",
});
forcedRefresh.resolve(createCheckoutStatus(REPO_CWD));
await Promise.all([first, second]);
expect(getCheckoutStatus).toHaveBeenCalledTimes(3);
service.dispose();
});
test("a forced GitHub-inclusive call during an in-flight forced git refresh queues a GitHub refresh", async () => {
const forcedGitRefresh = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi
@@ -852,7 +876,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
expect(getPullRequestStatus).toHaveBeenCalledWith(
REPO_CWD,
expect.anything(),
{ force: true, reason: "merge-pr-validation" },
{ force: true, reason: "merge-pr-validation", previousPullRequest: null },
expect.anything(),
);
@@ -942,7 +966,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
expect(getPullRequestStatus).toHaveBeenCalledWith(
REPO_CWD,
expect.anything(),
{ force: false, reason: "initial" },
{ force: false, reason: "initial", previousPullRequest: null },
expect.anything(),
);
@@ -1274,6 +1298,14 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
const service = createService({
getCheckoutSnapshotFacts,
getCheckoutStatus,
getPullRequestStatus: vi.fn(async () => ({
...createPullRequestStatusResult(),
status: {
...createPullRequestStatusResult().status!,
number: 2,
url: "https://forge-self-heal.test/acme/repo/pulls/2",
},
})),
});
const listener = vi.fn();
@@ -1288,6 +1320,11 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
cwd: REPO_CWD,
headRef: "feature",
headSha: "1111111111111111111111111111111111111111",
previousPullRequest: {
number: 2,
url: "https://forge-self-heal.test/acme/repo/pulls/2",
state: "open",
},
reason: "self-heal-forge-pr-status",
});
expect(listener).toHaveBeenCalledWith(
@@ -1308,6 +1345,176 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
}
});
test.each(["poll then refresh", "refresh then poll"] as const)(
"uses the current workspace association across %s",
async (direction) => {
const merged = createCurrentPullRequestStatus({
number: 1,
url: "https://forge-owner.test/acme/repo/pulls/1",
state: "merged",
isMerged: true,
});
const open = createCurrentPullRequestStatus({
number: 2,
url: "https://forge-owner.test/acme/repo/pulls/2",
state: "open",
});
let refreshFindsOpen = false;
const forge = {
...createGitHubServiceStub(),
retainCurrentPullRequestStatusPoll: undefined,
getCurrentPullRequestStatus: vi.fn(async (input) =>
direction === "poll then refresh" || input.previousPullRequest?.number === 2
? open
: merged,
),
};
const unregister = defaultForgeRegistry.register("forge-owner-test", {
createService: () => forge,
matchesHost: (host) => host === "forge-owner.test",
});
const getPullRequestStatus = vi.fn(async (_cwd, _forge, options) => ({
...createPullRequestStatusResult(),
status: refreshFindsOpen || options.previousPullRequest?.number === 2 ? open : merged,
}));
const service = createService({
getCheckoutSnapshotFacts: vi.fn(async (cwd) =>
createCheckoutFacts(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-owner.test/acme/repo.git",
pullRequestLookupTarget: { headRef: "feature" },
}),
),
getCheckoutStatus: vi.fn(async (cwd) =>
createCheckoutStatus(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-owner.test/acme/repo.git",
}),
),
getPullRequestStatus,
});
try {
expect((await service.getSnapshot(REPO_CWD)).forge.pullRequest?.number).toBe(1);
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises();
if (direction === "poll then refresh") {
await vi.advanceTimersByTimeAsync(120_000);
await flushPromises();
expect((await service.getSnapshot(REPO_CWD)).forge.pullRequest?.number).toBe(2);
expect(
(await service.getSnapshot(REPO_CWD, { force: true, reason: "test" })).forge.pullRequest
?.number,
).toBe(2);
} else {
refreshFindsOpen = true;
expect(
(await service.getSnapshot(REPO_CWD, { force: true, reason: "test" })).forge.pullRequest
?.number,
).toBe(2);
refreshFindsOpen = false;
await vi.advanceTimersByTimeAsync(120_000);
await flushPromises();
expect((await service.getSnapshot(REPO_CWD)).forge.pullRequest?.number).toBe(2);
}
subscription.unsubscribe();
} finally {
service.dispose();
unregister();
}
},
);
test.each(["older poll", "older refresh"] as const)(
"does not publish an %s after a newer observation",
async (older) => {
const merged = createCurrentPullRequestStatus({
number: 1,
url: "https://forge-race.test/acme/repo/pulls/1",
state: "merged",
isMerged: true,
});
const open = createCurrentPullRequestStatus({
number: 2,
url: "https://forge-race.test/acme/repo/pulls/2",
state: "open",
});
const delayedPoll = createDeferred<CurrentPullRequestStatus | null>();
const delayedRefresh = createDeferred<PullRequestStatusResult>();
let delayRefresh = false;
const forge = {
...createGitHubServiceStub(),
retainCurrentPullRequestStatusPoll: undefined,
getCurrentPullRequestStatus: vi.fn(async () =>
older === "older poll" ? delayedPoll.promise : open,
),
};
const unregister = defaultForgeRegistry.register("forge-race-test", {
createService: () => forge,
matchesHost: (host) => host === "forge-race.test",
});
let first = true;
const getPullRequestStatus = vi.fn(async (_cwd, _forge, options) => {
if (first) {
first = false;
return { ...createPullRequestStatusResult(), status: merged };
}
if (delayRefresh) {
delayRefresh = false;
return delayedRefresh.promise;
}
return {
...createPullRequestStatusResult(),
status:
older === "older poll" || options.previousPullRequest?.number === 2 ? open : merged,
};
});
const service = createService({
getCheckoutSnapshotFacts: vi.fn(async (cwd) =>
createCheckoutFacts(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-race.test/acme/repo.git",
pullRequestLookupTarget: { headRef: "feature" },
}),
),
getCheckoutStatus: vi.fn(async (cwd) =>
createCheckoutStatus(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-race.test/acme/repo.git",
}),
),
getPullRequestStatus,
});
try {
await service.getSnapshot(REPO_CWD);
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises();
if (older === "older poll") {
await vi.advanceTimersByTimeAsync(120_000);
await flushPromises();
expect(
(await service.getSnapshot(REPO_CWD, { force: true, reason: "test" })).forge.pullRequest
?.number,
).toBe(2);
delayedPoll.resolve(merged);
await flushPromises();
} else {
delayRefresh = true;
const refresh = service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
await flushPromises();
await vi.advanceTimersByTimeAsync(120_000);
await flushPromises();
delayedRefresh.resolve({ ...createPullRequestStatusResult(), status: merged });
expect((await refresh).forge.pullRequest?.number).toBe(2);
}
expect((await service.getSnapshot(REPO_CWD)).forge.pullRequest?.number).toBe(2);
subscription.unsubscribe();
} finally {
service.dispose();
unregister();
}
},
);
test("generic forge self-heal uses the fast poll window while checks are pending", async () => {
const forge = {
...createGitHubServiceStub(),
@@ -1397,7 +1604,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
nowMs = 3_000;
await service.refresh(REPO_CWD);
expect(workspaceRuntimeStore.get(REPO_CWD)?.forge.pullRequest).toBeNull();
expect(workspaceRuntimeStore.get(REPO_CWD)?.forge.pullRequest?.title).toBe("Visible PR");
expect(forge.getCurrentPullRequestStatus).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(0);
@@ -1412,6 +1619,57 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
}
});
test("keeps the known open association when a checkout commit changes before forge refresh", async () => {
let headSha = "1111111111111111111111111111111111111111";
const open = createCurrentPullRequestStatus({
number: 2,
url: "https://forge-commit.test/acme/repo/pulls/2",
state: "open",
});
const merged = createCurrentPullRequestStatus({
number: 1,
url: "https://forge-commit.test/acme/repo/pulls/1",
state: "merged",
isMerged: true,
});
const forge = { ...createGitHubServiceStub(), retainCurrentPullRequestStatusPoll: undefined };
const unregister = defaultForgeRegistry.register("forge-commit-test", {
createService: () => forge,
matchesHost: (host) => host === "forge-commit.test",
});
let first = true;
const service = createService({
getCheckoutSnapshotFacts: vi.fn(async (cwd) =>
createCheckoutFacts(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-commit.test/acme/repo.git",
pullRequestLookupTarget: { headRef: "feature", headSha },
}),
),
getCheckoutStatus: vi.fn(async (cwd) =>
createCheckoutStatus(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-commit.test/acme/repo.git",
}),
),
getPullRequestStatus: vi.fn(async (_cwd, _forge, options) => {
const result = first || options.previousPullRequest?.number === 2 ? open : merged;
first = false;
return { ...createPullRequestStatusResult(), status: result };
}),
});
try {
expect((await service.getSnapshot(REPO_CWD)).forge.pullRequest?.number).toBe(2);
headSha = "2222222222222222222222222222222222222222";
const refreshed = await service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
expect(refreshed.git.headSha).toBe(headSha);
expect(refreshed.forge.pullRequest).toMatchObject({ number: 2, state: "open" });
} finally {
service.dispose();
unregister();
}
});
test("subscription cancels generic forge PR status self-heal polling after unsubscribe", async () => {
const forge = {
...createGitHubServiceStub(),
@@ -23,6 +23,7 @@ import {
getPullRequestStatus,
forgeAuthStateFromError,
hasOriginRemote,
isPullRequestTargetOnForge,
listBranchSuggestions,
resolveRepositoryDefaultBranch,
resolveBranchCheckout,
@@ -151,6 +152,8 @@ export const getWorkspaceGitSelfHealPhaseMs = getWorkspaceGitObservationReensure
export interface WorkspaceGitRuntimeSnapshot {
cwd: string;
git: {
headSha?: string;
upstreamKnown?: boolean;
isGit: boolean;
repoRoot: string | null;
mainRepoRoot: string | null;
@@ -176,6 +179,7 @@ export interface WorkspaceGitRuntimeSnapshot {
*/
forge?: string;
pullRequest: {
headSha?: string;
number?: number;
repoOwner?: string;
repoName?: string;
@@ -321,6 +325,7 @@ export type WorkspaceGitSnapshotOptions =
force: true;
includeForge?: boolean;
reason: string;
afterInFlight?: boolean;
};
interface WorkspaceGitRefreshRequest {
@@ -550,6 +555,10 @@ interface WorkspaceForgePrStatusPollTarget {
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
headRepositoryHost?: string;
headRepositoryEndpoint?: import("../services/forge-service.js").HeadRepositoryEndpoint;
changeRequestNumber?: number;
}
function buildDefaultWorkspaceGitServiceDeps(
@@ -2970,6 +2979,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
...(pollTarget.headRepositoryOwner
? { headRepositoryOwner: pollTarget.headRepositoryOwner }
: {}),
...(pollTarget.headRepositoryPath
? { headRepositoryPath: pollTarget.headRepositoryPath }
: {}),
...(pollTarget.headRepositoryEndpoint
? { headRepositoryEndpoint: pollTarget.headRepositoryEndpoint }
: {}),
...(pollTarget.changeRequestNumber
? { changeRequestNumber: pollTarget.changeRequestNumber }
: {}),
onStatus: (status) => {
if (!this.isActiveObservedWorkspaceTarget(target)) {
return;
@@ -3020,8 +3038,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}): { unsubscribe: () => void } {
let closed = false;
let timer: NodeJS.Timeout | null = null;
let latestStatus: WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"] =
target.latestForge?.pullRequest ?? null;
let consecutiveErrors = 0;
const schedule = (delayMs: number) => {
@@ -3038,6 +3054,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (closed || !this.isActiveObservedWorkspaceTarget(target)) {
return;
}
const previousForge = target.latestForge;
const previousStatus = previousForge?.pullRequest;
try {
const status = await service.getCurrentPullRequestStatus({
cwd: target.cwd,
@@ -3046,10 +3064,31 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
...(pollTarget.headRepositoryOwner
? { headRepositoryOwner: pollTarget.headRepositoryOwner }
: {}),
...(pollTarget.headRepositoryPath
? { headRepositoryPath: pollTarget.headRepositoryPath }
: {}),
...(pollTarget.headRepositoryEndpoint
? { headRepositoryEndpoint: pollTarget.headRepositoryEndpoint }
: {}),
...(pollTarget.changeRequestNumber
? { changeRequestNumber: pollTarget.changeRequestNumber }
: {}),
...(previousStatus?.state === "open"
? {
previousPullRequest: {
number: previousStatus.number,
url: previousStatus.url,
state: previousStatus.state,
},
}
: {}),
reason: "self-heal-forge-pr-status",
});
if (!closed && this.isActiveObservedWorkspaceTarget(target)) {
latestStatus = status;
if (
!closed &&
this.isActiveObservedWorkspaceTarget(target) &&
target.latestForge === previousForge
) {
consecutiveErrors = 0;
this.rememberForgePrStatusSnapshot(target, buildForgeSnapshotFromStatus(status, forge));
}
@@ -3067,7 +3106,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
"Failed to run forge PR status self-heal refresh",
);
} finally {
schedule(computeGenericForgeNextInterval(latestStatus, consecutiveErrors));
schedule(
computeGenericForgeNextInterval(
target.latestForge?.pullRequest ?? null,
consecutiveErrors,
),
);
}
};
@@ -3075,7 +3119,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
// changes. Revalidate that new identity immediately instead of leaving the
// PR panel empty for the full stable polling interval.
schedule(
pollImmediately ? 0 : computeGenericForgeNextInterval(latestStatus, consecutiveErrors),
pollImmediately
? 0
: computeGenericForgeNextInterval(
target.latestForge?.pullRequest ?? null,
consecutiveErrors,
),
);
return {
unsubscribe: () => {
@@ -3101,10 +3150,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
? target.latestFacts.pullRequestLookupTarget
: null;
if (target.latestFacts?.isGit && target.latestFacts.paseoWorktree.isPaseoOwnedWorktree) {
return lookupTarget;
return lookupTarget && isPullRequestTargetOnForge(git.remoteUrl, lookupTarget)
? lookupTarget
: null;
}
if (lookupTarget) {
return lookupTarget;
return isPullRequestTargetOnForge(git.remoteUrl, lookupTarget) ? lookupTarget : null;
}
return { headRef: git.currentBranch };
@@ -3242,7 +3293,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
refreshWorktree: true,
includeForge: options?.includeForge ?? true,
reason: options?.reason ?? defaultReason,
queueIfBusy: false,
queueIfBusy: options?.force === true && options.afterInFlight === true,
movedRemoteRefs: new Set(),
};
}
@@ -3459,12 +3510,17 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
runGitCommand: runRefreshGitCommand,
},
);
target.latestFacts = { ...facts, upstreamStatus: derived.upstreamStatus };
target.latestFacts = {
...facts,
upstreamStatus: derived.upstreamStatus,
upstreamKnown: derived.upstreamStatus !== null,
};
target.latestGit = {
...latestGit,
aheadBehind: derived.aheadBehind,
diffStat: derived.diffStat,
upstreamRef: derived.upstreamStatus?.ref ?? null,
upstreamKnown: derived.upstreamStatus !== null,
aheadOfOrigin: derived.upstreamStatus?.aheadBehind.ahead ?? null,
behindOfOrigin: derived.upstreamStatus?.aheadBehind.behind ?? null,
};
@@ -3509,7 +3565,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target.lastShellOutAtMs = now.getTime();
const cwd = target.cwd;
const previousForgePrStatusPollKey = this.getForgePrStatusPollKey(target);
const previousForgePrAssociationKey = this.getForgePrAssociationKey(target);
const baseContext: CheckoutContext = {
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
@@ -3539,6 +3595,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target.latestGit = {
isGit: true,
headSha: facts.isGit ? facts.pullRequestLookupTarget?.headSha : undefined,
upstreamKnown: facts.isGit ? facts.upstreamKnown : false,
repoRoot: checkoutStatus.repoRoot,
mainRepoRoot: checkoutStatus.mainRepoRoot,
currentBranch: checkoutStatus.currentBranch,
@@ -3558,7 +3616,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const loadedAtMs = this.deps.now().getTime();
target.latestGitLoadedAtMs = loadedAtMs;
if (previousForgePrStatusPollKey !== this.getForgePrStatusPollKey(target)) {
if (previousForgePrAssociationKey !== this.getForgePrAssociationKey(target)) {
target.latestForge = buildForgeUnavailableSnapshot();
target.latestForgeLoadedAtMs = target.latestGitLoadedAtMs;
}
@@ -3588,20 +3646,28 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
forgeService.invalidate({ cwd: target.cwd });
}
const forgeSnapshot = await loadForgeSnapshot({
cwd: target.cwd,
forgeService,
now: this.deps.now(),
deps: this.deps,
force: forceForge,
reason: request.reason,
facts,
runGitCommand: runRefreshGitCommand,
});
// 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();
for (let attempt = 0; attempt < 2; attempt += 1) {
const previousForge = target.latestForge;
const forgeSnapshot = await loadForgeSnapshot({
cwd: target.cwd,
forgeService,
now: this.deps.now(),
deps: this.deps,
force: forceForge,
reason: request.reason,
facts,
runGitCommand: runRefreshGitCommand,
previousForge,
});
if (target.latestForge !== previousForge) {
if (forceForge) continue;
return;
}
target.latestForge = { ...forgeSnapshot, forge: resolution.forge };
target.latestForgeLoadedAtMs = this.deps.now().getTime();
return;
}
throw new Error("Forge status changed during the forced workspace refresh");
}
private combineSnapshot(target: WorkspaceGitTarget): WorkspaceGitRuntimeSnapshot {
@@ -3616,7 +3682,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
};
}
private getForgePrStatusPollKey(target: WorkspaceGitTarget): string | null {
private getForgePrAssociationKey(target: WorkspaceGitTarget): string | null {
const git = target.latestGit;
if (!git?.currentBranch || !git.remoteUrl) {
return null;
@@ -3632,7 +3698,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return null;
}
return buildWorkspaceForgePrStatusPollKey({
return buildWorkspaceForgePrAssociationKey({
forge: resolution.forge,
remoteUrl: git.remoteUrl,
target: pollTarget,
@@ -3971,6 +4037,7 @@ async function loadForgeSnapshot(options: {
reason?: string;
facts?: CheckoutSnapshotFacts;
runGitCommand: RunGitCommand;
previousForge: WorkspaceGitRuntimeSnapshot["forge"] | null;
}): Promise<WorkspaceGitRuntimeSnapshot["forge"]> {
const forgeService = options.forgeService;
if (!forgeService) {
@@ -3996,6 +4063,7 @@ async function loadForgeSnapshot(options: {
{
force: options.force,
reason: options.reason,
previousPullRequest: options.previousForge?.pullRequest ?? null,
},
{ facts: options.facts, runGitCommand: options.runGitCommand },
);
@@ -4003,7 +4071,7 @@ async function loadForgeSnapshot(options: {
} catch (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, {
return buildForgeSnapshot("authenticated", options.previousForge?.pullRequest ?? null, {
message: error instanceof Error ? error.message : String(error),
});
}
@@ -4120,13 +4188,31 @@ function buildWorkspaceForgePrStatusPollKey({
forge: string;
remoteUrl: string;
target: WorkspaceForgePrStatusPollTarget;
}): string {
return JSON.stringify([
buildWorkspaceForgePrAssociationKey({ forge, remoteUrl, target }),
target.headSha ?? null,
]);
}
function buildWorkspaceForgePrAssociationKey({
forge,
remoteUrl,
target,
}: {
forge: string;
remoteUrl: string;
target: WorkspaceForgePrStatusPollTarget;
}): string {
return JSON.stringify([
forge,
remoteUrl,
target.headRef,
target.headSha ?? null,
target.headRepositoryOwner ?? null,
target.headRepositoryPath ?? null,
target.headRepositoryHost ?? null,
target.headRepositoryEndpoint ?? null,
target.changeRequestNumber ?? null,
]);
}
@@ -1,4 +1,29 @@
import type { ForgeSearchKind } from "@getpaseo/protocol/messages";
import { parseGitRemoteLocation, type GitRemoteLocation } from "@getpaseo/protocol/git-remote";
/** A checkout endpoint that must be proven against the candidate's native clone URL. */
export type HeadRepositoryEndpoint = GitRemoteLocation;
/** The bounded discovery window could not establish a result. */
export class ForgeDiscoveryIncompleteError extends Error {}
export function matchesHeadRepositoryEndpoint(
endpoint: HeadRepositoryEndpoint | undefined,
urls: (string | null | undefined)[],
): boolean {
if (!endpoint) return true;
return urls.some((url) => {
const candidate = url ? parseGitRemoteLocation(url) : null;
if (!candidate) return false;
const transport = (value: GitRemoteLocation["transport"]) => (value === "scp" ? "ssh" : value);
return (
transport(candidate.transport) === transport(endpoint.transport) &&
candidate.host === endpoint.host &&
candidate.port === endpoint.port &&
candidate.path.toLowerCase() === endpoint.path.toLowerCase()
);
});
}
export type ForgeSearchRequestKind = ForgeSearchKind | "github-issue" | "github-pr" | "pr";
@@ -114,6 +139,8 @@ export type ForgeAuthState =
export type ForgeSpecificStatusFacts = { forge: string } & Record<string, unknown>;
export interface CurrentPullRequestStatus {
/** Commit recorded as the change request's head by the forge. */
headSha?: string;
number?: number;
repoOwner?: string;
repoName?: string;
@@ -472,6 +499,11 @@ export interface ForgeService {
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
headRepositoryHost?: string;
headRepositoryEndpoint?: HeadRepositoryEndpoint;
previousPullRequest?: { number?: number; url: string; state: string } | null;
changeRequestNumber?: number;
} & ForgeReadOptions,
): Promise<CurrentPullRequestStatus | null>;
getPullRequestTimeline(options: GetPullRequestTimelineOptions): Promise<PullRequestTimeline>;
@@ -513,6 +545,9 @@ export interface ForgeService {
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
headRepositoryEndpoint?: HeadRepositoryEndpoint;
changeRequestNumber?: number;
onStatus?: (status: CurrentPullRequestStatus | null) => void;
onError?: (error: unknown) => void;
}): { unsubscribe: () => void };
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { PullRequestCommandStatus, PullRequestTimelineItem } from "./forge-service.js";
import { ForgeDiscoveryIncompleteError } from "./forge-service.js";
import {
type CreateGiteaServiceOptions,
createGiteaService,
@@ -19,9 +20,40 @@ function ok(stdout: string): TeaCommandResult {
function makeService(responder: Responder, overrides: Partial<CreateGiteaServiceOptions> = {}) {
const calls: string[][] = [];
const listed = new Map<string, typeof OPEN_PR>();
const listedDetail = (args: string[]): TeaCommandResult | null => {
const match = args[0] === "api" ? args[1]?.match(/\/pulls\/(\d+)$/) : null;
const item = match ? listed.get(match[1]) : undefined;
if (!item) return null;
const detail = currentPullRequestApi({
number: Number(item.index),
state: "open",
headRef: item.head,
headSha: STATUS_PR_VIEW.headSha,
});
return ok(JSON.stringify({ ...detail, html_url: item.url, title: item.title }));
};
const runner: TeaCommandRunner = async (args) => {
calls.push(args);
return responder(args);
try {
const result = await responder(args);
if (args[0] === "pr" && args[1] === "list") {
const items = JSON.parse(result.stdout);
if (Array.isArray(items)) for (const item of items) listed.set(item.index, item);
}
if (args[0] === "api" && /\/pulls\/\d+$/.test(args[1] ?? "")) {
const data = JSON.parse(result.stdout);
if (!data?.number) return listedDetail(args) ?? result;
}
return result;
} catch (error) {
if (error instanceof Error && error.message.startsWith("unexpected call:")) {
if (args[0] === "api" && args[1] === "repos/example-user/sample-repo")
return ok(JSON.stringify({ full_name: "example-user/sample-repo" }));
return listedDetail(args) ?? Promise.reject(error);
}
throw error;
}
};
const service = createGiteaService({
runner,
@@ -94,7 +126,7 @@ function currentPullRequestApi(input: {
head: {
ref: input.headRef,
sha: input.headSha,
repo: { id: 1, owner: { login: "example-user" } },
repo: { id: 1, full_name: "example-user/sample-repo", owner: { login: "example-user" } },
},
base: {
ref: "main",
@@ -485,8 +517,9 @@ describe("createGiteaService", () => {
expect(calls[0]).toContain("--fields");
expect(calls[0]).toContain("-o");
expect(calls[0]).toContain("json");
expect(calls[1]).toEqual(["pr", "5", "-o", "json"]);
expect(calls[2]).toEqual([
expect(calls[1]).toEqual(["api", "repos/example-user/sample-repo/pulls/5"]);
expect(calls[2]).toEqual(["pr", "5", "-o", "json"]);
expect(calls[3]).toEqual([
"api",
"repos/example-user/sample-repo/commits/3333333333333333333333333333333333333333/status",
]);
@@ -1627,6 +1660,7 @@ describe("createGiteaService", () => {
"--page",
"1",
],
["api", "repos/example-user/sample-repo/pulls/5"],
["pr", "5", "-o", "json"],
["api", `repos/example-user/sample-repo/commits/${STATUS_PR_VIEW.headSha}/status`],
]);
@@ -2055,6 +2089,7 @@ describe("createGiteaService", () => {
cwd: "/repo",
headRef: "feat/recently-closed",
headSha,
headRepositoryPath: "example-user/sample-repo",
});
expect(status).toMatchObject({ number: 8, headRefName: "feat/recently-closed" });
@@ -2064,7 +2099,31 @@ describe("createGiteaService", () => {
]);
});
it("does not attach a stale Gitea-family PR after a same-name branch advances", async () => {
it("discovers the repository from tea when origin and a head path are unavailable", async () => {
const pr = currentPullRequestApi({
number: 8,
state: "closed",
headRef: "topic",
headSha: "1".repeat(40),
merged: true,
});
const { service } = makeService(
(args) => {
if (args[0] === "api" && args[1] === "repos/{owner}/{repo}")
return ok(JSON.stringify({ full_name: "example-user/sample-repo" }));
if (args[0] === "pr" && args[1] === "list") return ok("[]");
if (args[0] === "api" && args[1].includes("pulls?state=all"))
return ok(JSON.stringify([pr]));
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{ resolveRemoteUrl: async () => null },
);
expect(
(await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic" }))?.number,
).toBe(8);
});
it("shows a merged PR when the checkout head differs", async () => {
const stale = currentPullRequestApi({
number: 8,
state: "closed",
@@ -2086,9 +2145,548 @@ describe("createGiteaService", () => {
headRef: "dev",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toMatchObject({ number: 8, state: "merged", headSha: stale.head.sha });
});
it("does not scan beyond the bounded terminal window", async () => {
const unrelated = Array.from({ length: 50 }, (_, i) =>
currentPullRequestApi({
number: i + 100,
state: "closed",
headRef: `other-${i}`,
headSha: "1".repeat(40),
}),
);
const wanted = currentPullRequestApi({
number: 8,
state: "closed",
headRef: "topic",
headSha: "2".repeat(40),
merged: true,
});
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok("[]");
if (args[0] === "api" && args[1].includes("/pulls?state=all"))
return ok(JSON.stringify(args[1].includes("page=2") ? [wanted] : unrelated));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
await expect(
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic" }),
).rejects.toBeInstanceOf(ForgeDiscoveryIncompleteError);
expect(calls.filter((args) => args[1]?.includes("/pulls?state=all"))).toHaveLength(1);
});
it("does not associate a different repository owned by the same account", async () => {
const foreign = currentPullRequestApi({
number: 8,
state: "closed",
headRef: "dev",
headSha: "1".repeat(40),
merged: true,
});
foreign.head.repo.id = 2;
foreign.head.repo.full_name = "example-user/other";
const { service } = makeService((args) => {
if (args[0] === "api" && args[1] === "repos/example-user/sample-repo")
return ok(JSON.stringify({ full_name: "example-user/sample-repo" }));
if (args[0] === "pr" && args[1] === "list") return ok("[]");
if (args[0] === "api" && args[1].includes("/pulls?state=all"))
return ok(JSON.stringify([foreign]));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
await expect(
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "dev" }),
).resolves.toBeNull();
});
it.each(["open", "merged"] as const)(
"retains a %s PR when its repository was renamed",
async (state) => {
const original = currentPullRequestApi({
number: 8,
state: state === "open" ? "open" : "closed",
headRef: "topic",
headSha: "1".repeat(40),
merged: state === "merged",
});
const pr = {
...original,
html_url: "https://gitea.example/acme/renamed/pulls/8",
head: {
...original.head,
repo: { ...original.head.repo, full_name: "acme/renamed", owner: { login: "acme" } },
},
base: {
...original.base,
repo: { ...original.base.repo, full_name: "acme/renamed", owner: { login: "acme" } },
},
};
const { service, calls } = makeService(
(args) => {
if (args[0] === "pr" && args[1] === "list")
return ok(
JSON.stringify(
state === "open"
? [{ ...OPEN_PR, index: "8", head: "topic", url: pr.html_url }]
: [],
),
);
if (args[0] === "api" && args[1].endsWith("/pulls/8")) return ok(JSON.stringify(pr));
if (args[0] === "api" && args[1] === "repos/acme/original")
return ok(JSON.stringify({ full_name: "acme/renamed" }));
if (args[0] === "api" && args[1].includes("pulls?state=all"))
return ok(JSON.stringify([pr]));
if (args[0] === "pr" && args[1] === "8") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/"))
return ok(JSON.stringify({ state: "", statuses: [] }));
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{ resolveRemoteUrl: async () => "https://gitea.example/acme/original.git" },
);
const input = { cwd: "/repo", headRef: "topic", headRepositoryPath: "acme/original" };
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(8);
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(8);
await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" });
expect(calls.filter((args) => args[1] === "repos/acme/original")).toHaveLength(2);
},
);
it("bounds canonical repository reads when many branch candidates differ", async () => {
const other = currentPullRequestApi({
number: 1,
state: "closed",
headRef: "topic",
headSha: "1".repeat(40),
});
other.head.repo.full_name = "example-user/other";
const candidates = Array.from({ length: 50 }, (_, i) => ({ ...other, number: i + 1 }));
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok("[]");
if (args[0] === "api" && args[1] === "repos/example-user/sample-repo")
return ok(JSON.stringify({ full_name: "example-user/sample-repo" }));
if (args[0] === "api" && args[1].includes("pulls?state=all"))
return ok(JSON.stringify(candidates));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
await expect(
service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "topic",
force: true,
reason: "archive",
}),
).rejects.toBeInstanceOf(ForgeDiscoveryIncompleteError);
expect(calls.filter((args) => args[1] === "repos/example-user/sample-repo")).toHaveLength(1);
});
it("isolates renamed repository identities when the forge remote changes", async () => {
let host = "first.example";
const original = currentPullRequestApi({
number: 8,
state: "closed",
headRef: "topic",
headSha: "1".repeat(40),
merged: true,
});
const { service, calls } = makeService(
(args) => {
if (args[0] === "pr" && args[1] === "list") return ok("[]");
if (args[0] === "api" && args[1] === "repos/example-user/sample-repo")
return ok(JSON.stringify({ full_name: `example-user/${host}` }));
if (args[0] === "api" && args[1].includes("pulls?state=all"))
return ok(
JSON.stringify([
{
...original,
head: {
...original.head,
repo: { ...original.head.repo, full_name: `example-user/${host}` },
},
},
]),
);
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{ resolveRemoteUrl: async () => `https://${host}/example-user/sample-repo.git` },
);
const input = { cwd: "/repo", headRef: "topic" };
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(8);
host = "second.example";
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(8);
expect(calls.filter((args) => args[1] === "repos/example-user/sample-repo")).toHaveLength(2);
});
it("revalidates the source repository when the forge remote changes", async () => {
let host = "first.example";
const { service, calls } = makeService(
(args) => {
if (args[0] === "pr" && args[1] === "list")
return ok(JSON.stringify([{ ...OPEN_PR, index: "8", head: "topic" }]));
if (args[0] === "api" && args[1].endsWith("/pulls/8")) {
const pr = currentPullRequestApi({
number: 8,
state: "open",
headRef: "topic",
headSha: "1".repeat(40),
});
if (host === "second.example") pr.head.repo.full_name = "example-user/foreign";
return ok(JSON.stringify(pr));
}
if (args[0] === "api" && args[1] === "repos/example-user/sample-repo")
return ok(JSON.stringify({ full_name: "example-user/sample-repo" }));
if (args[0] === "api" && args[1].includes("pulls?state=all")) return ok("[]");
if (args[0] === "pr" && args[1] === "8") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/"))
return ok(JSON.stringify({ state: "", statuses: [] }));
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{ resolveRemoteUrl: async () => `https://${host}/example-user/sample-repo.git` },
);
const input = { cwd: "/repo", headRef: "topic" };
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(8);
host = "second.example";
expect(await service.getCurrentPullRequestStatus(input)).toBeNull();
expect(calls.filter((args) => args[1]?.endsWith("/pulls/8"))).toHaveLength(2);
});
it.each(["open", "merged"] as const)("matches a transferred fork for an %s PR", async (state) => {
const original = currentPullRequestApi({
number: 8,
state: state === "open" ? "open" : "closed",
headRef: "topic",
headSha: "1".repeat(40),
merged: state === "merged",
});
const pr = {
...original,
html_url: "https://gitea.example/base/repo/pulls/8",
head: {
...original.head,
repo: {
...original.head.repo,
id: 2,
full_name: "new-owner/repo",
owner: { login: "new-owner" },
},
},
base: { ...original.base, repo: { id: 1, owner: { login: "base" } } },
};
const { service } = makeService(
(args) => {
if (args[0] === "pr" && args[1] === "list")
return ok(
JSON.stringify(
state === "open"
? [{ ...OPEN_PR, index: "8", head: "new-owner:topic", url: pr.html_url }]
: [],
),
);
if (args[0] === "api" && args[1].endsWith("/pulls/8")) return ok(JSON.stringify(pr));
if (args[0] === "api" && args[1] === "repos/old-owner/repo")
return ok(JSON.stringify({ full_name: "new-owner/repo" }));
if (args[0] === "api" && args[1].includes("pulls?state=all"))
return ok(JSON.stringify([pr]));
if (args[0] === "pr" && args[1] === "8") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/"))
return ok(JSON.stringify({ state: "", statuses: [] }));
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{ resolveRemoteUrl: async () => "https://gitea.example/base/repo.git" },
);
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "topic",
headRepositoryOwner: "old-owner",
headRepositoryPath: "old-owner/repo",
}),
).toMatchObject({ number: 8, state });
});
it.each(["merged", "open"] as const)(
"finds a recent %s candidate after 200 unrelated open requests",
async (state) => {
const unrelated = Array.from({ length: 50 }, (_, i) => ({
...OPEN_PR,
index: String(i + 100),
head: `other-${i}`,
}));
const wanted = currentPullRequestApi({
number: 9,
state: state === "merged" ? "closed" : "open",
headRef: "topic",
headSha: "1".repeat(40),
merged: state === "merged",
});
const newerMerged = currentPullRequestApi({
number: 10,
state: "closed",
headRef: "topic",
headSha: "2".repeat(40),
merged: true,
});
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify(unrelated));
if (args[0] === "api" && args[1].includes("pulls?state=all"))
return ok(
JSON.stringify(
state === "open"
? [
{ ...newerMerged, updated_at: "2026-10-01T00:00:00Z" },
{ ...wanted, updated_at: "2026-01-01T00:00:00Z" },
]
: [wanted],
),
);
if (args[0] === "pr" && args[1] === "9") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/"))
return ok(JSON.stringify({ state: "", statuses: [] }));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
expect(
(await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic" }))?.number,
).toBe(9);
expect(calls.filter((args) => args[0] === "pr" && args[1] === "list")).toHaveLength(4);
expect(calls.filter((args) => args[1]?.includes("pulls?state=all"))).toHaveLength(1);
},
);
it("revalidates a known open PR before replacing it with a crowded-window terminal candidate", async () => {
const open = currentPullRequestApi({
number: 2,
state: "open",
headRef: "topic",
headSha: "1".repeat(40),
});
const oldMerged = currentPullRequestApi({
number: 1,
state: "closed",
headRef: "topic",
headSha: "2".repeat(40),
merged: true,
});
const unrelated = Array.from({ length: 50 }, (_, i) => ({
...OPEN_PR,
index: String(i + 100),
head: `other-${i}`,
}));
const fullRecentItems = Array.from({ length: 50 }, (_, i) => ({
...oldMerged,
number: i + 100,
head: { ...oldMerged.head, ref: `other-${i}` },
}));
let crowded = false;
let fullRecent = false;
let prior = open;
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list")
return ok(
JSON.stringify(
crowded ? unrelated : [{ ...OPEN_PR, index: "2", url: open.html_url, head: "topic" }],
),
);
if (args[0] === "api" && args[1].endsWith("/pulls/2")) return ok(JSON.stringify(prior));
if (args[0] === "api" && args[1].includes("pulls?state=all"))
return ok(JSON.stringify(fullRecent ? fullRecentItems : [oldMerged]));
if (args[0] === "pr" && args[1] === "2") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/"))
return ok(JSON.stringify({ state: "", statuses: [] }));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const input = { cwd: "/repo", headRef: "topic" };
const observed = await service.getCurrentPullRequestStatus(input);
expect(observed).toMatchObject({ number: 2, state: "open" });
crowded = true;
expect(
await service.getCurrentPullRequestStatus({ ...input, previousPullRequest: observed! }),
).toMatchObject({ number: 2, state: "open" });
prior = { ...open, state: "closed", merged: true };
expect(
await service.getCurrentPullRequestStatus({ ...input, previousPullRequest: observed! }),
).toMatchObject({ number: 2, state: "merged" });
prior = { ...open, state: "closed", merged: false };
expect(
await service.getCurrentPullRequestStatus({ ...input, previousPullRequest: observed! }),
).toMatchObject({ number: 2, state: "closed" });
fullRecent = true;
expect(
await service.getCurrentPullRequestStatus({ ...input, previousPullRequest: observed! }),
).toMatchObject({ number: 2, state: "closed" });
expect(calls.filter((args) => args[1]?.endsWith("/pulls/2"))).toHaveLength(5);
});
it("requires candidate clone URL proof for mixed ports and isolates cached identity", async () => {
const pr = currentPullRequestApi({
number: 5,
state: "open",
headRef: "topic",
headSha: "1".repeat(40),
});
let cloneUrl: string | undefined = "ssh://git@forge.example:2222/example-user/sample-repo.git";
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list")
return ok(JSON.stringify([{ ...OPEN_PR, head: "topic" }]));
if (args[0] === "api" && args[1].endsWith("/pulls/5"))
return ok(
JSON.stringify({
...pr,
head: { ...pr.head, repo: { ...pr.head.repo, ssh_url: cloneUrl } },
}),
);
if (args[0] === "api" && args[1].includes("pulls?state=all")) return ok("[]");
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/"))
return ok(JSON.stringify({ state: "", statuses: [] }));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const endpoint = {
transport: "ssh" as const,
host: "forge.example",
port: "2222",
path: "example-user/sample-repo",
};
const input = { cwd: "/repo", headRef: "topic", headRepositoryEndpoint: endpoint };
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(5);
expect(
await service.getCurrentPullRequestStatus({
...input,
headRepositoryEndpoint: { ...endpoint, port: "2223" },
}),
).toBeNull();
expect(
(await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" }))
?.number,
).toBe(5);
cloneUrl = undefined;
expect(
await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" }),
).toBeNull();
expect(calls.filter((args) => args[1]?.endsWith("/pulls/5"))).toHaveLength(4);
});
it("ignores an open PR from another repository of the same owner", async () => {
const foreign = currentPullRequestApi({
number: 5,
state: "open",
headRef: "dev",
headSha: "1".repeat(40),
});
foreign.head.repo.id = 2;
foreign.head.repo.full_name = "example-user/other";
const { service } = makeService((args) => {
if (args[0] === "api" && args[1] === "repos/example-user/sample-repo")
return ok(JSON.stringify({ full_name: "example-user/sample-repo" }));
if (args[0] === "pr" && args[1] === "list")
return ok(JSON.stringify([{ ...OPEN_PR, head: "dev" }]));
if (args[0] === "api" && args[1].endsWith("/pulls/5")) return ok(JSON.stringify(foreign));
if (args[0] === "api" && args[1].includes("/pulls?state=all")) return ok("[]");
throw new Error(`unexpected call: ${args.join(" ")}`);
});
await expect(
service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "dev",
headRepositoryPath: "example-user/sample-repo",
}),
).resolves.toBeNull();
});
it("uses a checkout hint to discover a renamed fork before finding the current PR", async () => {
const forkPr = currentPullRequestApi({
number: 42,
state: "open",
headRef: "topic",
headSha: "1".repeat(40),
});
forkPr.head.repo = { id: 2, full_name: "contributor/renamed", owner: { login: "contributor" } };
const { service } = makeService((args) => {
if (args[0] === "api" && args[1].endsWith("/pulls/41"))
return ok(JSON.stringify({ ...forkPr, number: 41 }));
if (args[0] === "pr" && args[1] === "list") return ok("[]");
if (args[0] === "api" && args[1].includes("/pulls?state=all"))
return ok(JSON.stringify([forkPr]));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "topic",
headRepositoryOwner: "contributor",
changeRequestNumber: 41,
});
expect(status?.number).toBe(42);
});
it("prefers an open PR before looking for terminal requests", async () => {
const open = { ...OPEN_PR, head: "topic" };
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([open]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/"))
return ok(JSON.stringify({ state: "", statuses: [] }));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
expect(
await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic" }),
).toMatchObject({ number: 5, state: "open" });
expect(calls.some((args) => args[1]?.includes("pulls?state=all"))).toBe(false);
});
it("bounds repository identity reads when a branch is crowded", async () => {
const candidates = Array.from({ length: 4 }, (_, i) => ({
...OPEN_PR,
index: String(i + 20),
head: "topic",
}));
const { service, calls } = makeService((args) => {
if (args[0] === "api" && args[1] === "repos/example-user/sample-repo")
return ok(JSON.stringify({ full_name: "example-user/sample-repo" }));
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify(candidates));
if (args[0] === "api" && args[1].includes("pulls?state=all")) return ok("[]");
if (args[0] === "api" && /\/pulls\/\d+$/.test(args[1])) {
const pr = currentPullRequestApi({
number: Number(args[1].split("/").at(-1)),
state: "open",
headRef: "topic",
headSha: "1".repeat(40),
});
pr.head.repo.full_name = "example-user/other";
return ok(JSON.stringify(pr));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
await expect(
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic" }),
).rejects.toBeInstanceOf(ForgeDiscoveryIncompleteError);
expect(calls.filter((args) => args[0] === "api" && /\/pulls\/\d+$/.test(args[1]))).toHaveLength(
3,
);
});
it("reuses the head repository identity on subsequent open PR polls", async () => {
const open = { ...OPEN_PR, head: "topic" };
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([open]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/"))
return ok(JSON.stringify({ state: "", statuses: [] }));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const input = { cwd: "/repo", headRef: "topic" };
expect((await service.getCurrentPullRequestStatus(input))?.headSha).toBe(
STATUS_PR_VIEW.headSha,
);
expect((await service.getCurrentPullRequestStatus(input))?.headSha).toBe(
STATUS_PR_VIEW.headSha,
);
expect(
calls.filter((args) => args[1] === "repos/example-user/sample-repo/pulls/5"),
).toHaveLength(1);
await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" });
expect(
calls.filter((args) => args[1] === "repos/example-user/sample-repo/pulls/5"),
).toHaveLength(2);
});
it("returns null when no PR matches the current branch", async () => {
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([]));
@@ -2110,11 +2708,7 @@ describe("createGiteaService", () => {
]);
});
it("resolves an open cross-fork PR by head sha despite the owner gate", async () => {
// The PR lives in the base repo but its head is on a fork
// (contributor:feat/fork-change), so the checkout-owner gate excludes it.
// The head sha uniquely identifies it, so resolution must accept it — this
// mirrors GitLab, which resolves fork MRs from the base checkout.
it("resolves an open cross-fork PR for the specified head owner", async () => {
const forkHeadSha = "c4ce035f493f2c50f656e3e5bdba29b321a74042";
const forkPr = {
number: 6,
@@ -2129,7 +2723,7 @@ describe("createGiteaService", () => {
head: {
ref: "feat/fork-change",
sha: forkHeadSha,
repo: { id: 2, owner: { login: "contributor" } },
repo: { id: 2, full_name: "contributor/renamed", owner: { login: "contributor" } },
},
base: {
ref: "main",
@@ -2157,6 +2751,8 @@ describe("createGiteaService", () => {
cwd: "/repo",
headRef: "feat/fork-change",
headSha: forkHeadSha,
headRepositoryOwner: "contributor",
headRepositoryPath: "contributor/renamed",
});
expect(status?.number).toBe(6);
+281 -45
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { LRUCache } from "lru-cache";
import { CHECK_TRAIT_ACTION_REQUIRED, CHECK_TRAIT_WARNING } from "@getpaseo/protocol/check-traits";
import { mapGiteaCommitState } from "@getpaseo/protocol/gitea-status";
import pLimit from "p-limit";
@@ -27,6 +28,8 @@ import {
createUnavailableSearchResult,
normalizeForgeSearchKinds,
parseOptionalTime,
matchesHeadRepositoryEndpoint,
ForgeDiscoveryIncompleteError,
} from "./forge-service.js";
import type {
CheckDetails,
@@ -95,6 +98,7 @@ const ISSUE_LIST_FIELDS = "index,state,author,url,title,body,labels,comments,cre
const CURRENT_PR_LOOKUP_PAGE_SIZE = 50;
const CURRENT_PR_OPEN_LOOKUP_MAX_PAGES = 4;
const CURRENT_PR_IDENTITY_READ_LIMIT = 3;
const TIMELINE_PAGE_SIZE = 50;
const TIMELINE_MAX_PAGES_PER_BUCKET = 4;
@@ -160,6 +164,7 @@ const GiteaPrListItemSchema = z
head: z.string().optional(),
created: z.string().optional(),
updated: z.string().optional(),
headSha: z.string().optional(),
labels: z.string().optional(),
ci: z.string().optional(),
})
@@ -223,7 +228,9 @@ const GiteaPullRequestViewSchema = z
const GiteaPullRequestRepoSchema = z
.object({
id: z.number().optional(),
full_name: z.string().optional(),
ssh_url: z.string().nullable().optional(),
clone_url: z.string().nullable().optional(),
html_url: z.string().nullable().optional(),
owner: z.object({ login: z.string().optional() }).passthrough().nullable().optional(),
})
@@ -1010,6 +1017,7 @@ function currentPullRequestApiToListItem(item: GiteaCurrentPullRequestApi): Gite
...(item.mergeable != null ? { mergeable: String(item.mergeable) } : {}),
base: item.base.ref,
head,
headSha: item.head.sha,
updated: item.updated_at,
labels: item.labels.map((label) => label.name).join(","),
};
@@ -1082,6 +1090,7 @@ function toCurrentPullRequestStatus(item: GiteaPrListItem): CurrentPullRequestSt
state,
baseRefName: item.base ?? "",
headRefName: stripHeadOwner(item.head),
...(item.headSha ? { headSha: item.headSha } : {}),
isMerged: state === "merged",
mergeable: mapGiteaMergeable(item),
checks: [],
@@ -1394,6 +1403,11 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
const resolveCurrentBranch = options.resolveCurrentBranch ?? defaultResolveCurrentBranch;
const now = options.now ?? Date.now;
const currentPrHeadShaCache = new Map<string, { sha: string; expiresAt: number }>();
const pullRequestIdentityCache = new LRUCache<string, boolean>({ max: 512, ttl: 120_000 });
const canonicalRepositoryPaths = new LRUCache<string, { path: string | null }>({
max: 512,
ttl: 120_000,
});
async function run(args: string[], runOptions: TeaCommandRunnerOptions): Promise<string> {
const teaPath = await resolveTea();
@@ -1578,6 +1592,12 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
cwd: string;
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
headRepositoryEndpoint?: import("./forge-service.js").HeadRepositoryEndpoint;
changeRequestNumber?: number;
force?: boolean;
previousPullRequest?: { number?: number; url: string; state: string } | null;
}): Promise<CurrentPullRequestStatus | null> {
const match = await findCurrentPullRequestForHeadRef(input);
if (!match) {
@@ -1606,21 +1626,199 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
cwd: string;
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
headRepositoryEndpoint?: import("./forge-service.js").HeadRepositoryEndpoint;
changeRequestNumber?: number;
force?: boolean;
previousPullRequest?: { number?: number; url: string; state: string } | null;
}): Promise<GiteaPrListItem | null> {
const repoIdentity = await resolveCurrentRepoIdentity(input.cwd);
const expectedHeadOwner = repoIdentity?.owner ?? null;
const openMatch = await findOpenPullRequestForHeadRef(input, expectedHeadOwner);
if (openMatch) {
return openMatch;
}
// tea discovers repository context from any configured git remote, while
// the default remote resolver reads origin only. Preserve that broader
// discovery for repositories whose primary remote has another name.
const repoPath = repoIdentity
? `repos/${encodeURIComponent(repoIdentity.owner)}/${encodeURIComponent(repoIdentity.name)}`
: "repos/{owner}/{repo}";
const recentItems = await runJsonArray(
let expectedPath =
input.headRepositoryPath ??
(input.headRepositoryOwner
? null
: repoIdentity && `${repoIdentity.owner}/${repoIdentity.name}`);
if (!expectedPath && !input.headRepositoryOwner && !repoIdentity) {
const repository = await runJson(
["api", "repos/{owner}/{repo}"],
{ cwd: input.cwd },
GiteaPullRequestRepoSchema,
);
expectedPath = repository.full_name ?? null;
}
if (!expectedPath && input.headRepositoryOwner && input.changeRequestNumber) {
const hint = await runJson(
["api", `${repoPath}/pulls/${input.changeRequestNumber}`],
{ cwd: input.cwd },
GiteaCurrentPullRequestApiSchema,
);
const path = hint.head.repo?.full_name;
if (path?.split("/")[0]?.toLowerCase() === input.headRepositoryOwner.toLowerCase())
expectedPath = path;
}
if (!expectedPath) return null;
const expectedHeadOwner = expectedPath.split("/")[0] ?? null;
let canonicalLookup: Promise<string | null> | undefined;
const loadCanonicalPath = async (): Promise<string | null> => {
const key = JSON.stringify([
input.cwd,
await resolveRemoteUrl(input.cwd),
repoPath,
expectedPath.toLowerCase(),
]);
const cached = input.force ? undefined : canonicalRepositoryPaths.get(key);
if (cached !== undefined) return cached.path;
const path = expectedPath.split("/").map(encodeURIComponent).join("/");
const repository = await runJson(
["api", `repos/${path}`],
{ cwd: input.cwd },
GiteaPullRequestRepoSchema,
);
const result = repository.full_name ?? null;
canonicalRepositoryPaths.set(key, { path: result });
return result;
};
const matchesHead = async (item: GiteaPrListItem): Promise<boolean> => {
if (matchesCurrentHeadRef(item, input.headRef, expectedHeadOwner)) return true;
if (stripHeadOwner(item.head) !== input.headRef) return false;
canonicalLookup ??= loadCanonicalPath();
const canonicalPath = await canonicalLookup;
return canonicalPath
? matchesCurrentHeadRef(item, input.headRef, canonicalPath.split("/")[0] ?? null)
: false;
};
const matchesRepository = async (item: GiteaCurrentPullRequestApi): Promise<boolean> => {
const headPath =
item.head.repo?.full_name ??
(item.head.repo?.id !== undefined &&
item.head.repo.id === item.base.repo?.id &&
repoIdentity
? `${repoIdentity.owner}/${repoIdentity.name}`
: null);
if (!headPath) return false;
let canonicalPath = expectedPath;
if (headPath.toLowerCase() !== expectedPath.toLowerCase()) {
canonicalLookup ??= loadCanonicalPath();
const resolved = await canonicalLookup;
if (!resolved) return false;
canonicalPath = resolved;
}
return (
headPath.toLowerCase() === canonicalPath.toLowerCase() &&
matchesHeadRepositoryEndpoint(input.headRepositoryEndpoint, [
item.head.repo?.ssh_url,
item.head.repo?.clone_url,
item.head.repo?.html_url,
])
);
};
const open = await findOpenPullRequestForHeadRef(
input,
matchesHead,
repoPath,
matchesRepository,
expectedPath,
input.headRepositoryEndpoint,
);
if (open.match) return open.match;
// A full open window does not make the recent all-state window useless:
// Gitea does not expose a source-branch filter for this endpoint.
return resolveRecentPullRequest({
...input,
matchesHead,
repoPath,
matchesRepository,
exhausted: open.exhausted,
});
}
async function resolveRecentPullRequest(input: {
cwd: string;
headRef: string;
previousPullRequest?: { number?: number; url: string; state: string } | null;
matchesHead: (item: GiteaPrListItem) => Promise<boolean>;
repoPath: string;
matchesRepository: (item: GiteaCurrentPullRequestApi) => Promise<boolean>;
exhausted: boolean;
}): Promise<GiteaPrListItem | null> {
let recent: GiteaPrListItem | null;
try {
recent = await findRecentPullRequestForHeadRef(
input,
input.matchesHead,
input.repoPath,
input.matchesRepository,
);
} catch (error) {
if (!(error instanceof ForgeDiscoveryIncompleteError) || !input.exhausted) throw error;
const prior = await revalidatePreviousPullRequest({
...input,
recent: null,
});
if (prior) return prior;
throw error;
}
if (input.exhausted) {
const prior = await revalidatePreviousPullRequest({
...input,
recent,
});
if (prior) return prior;
}
if (!recent && input.exhausted)
throw new ForgeDiscoveryIncompleteError(
"Gitea pull request discovery exceeded its open candidate window",
);
return recent;
}
async function revalidatePreviousPullRequest(input: {
cwd: string;
headRef: string;
repoPath: string;
matchesHead: (item: GiteaPrListItem) => Promise<boolean>;
matchesRepository: (item: GiteaCurrentPullRequestApi) => Promise<boolean>;
previousPullRequest?: { number?: number; url: string; state: string } | null;
recent: GiteaPrListItem | null;
}): Promise<GiteaPrListItem | null> {
const previous = input.previousPullRequest;
if (previous?.state !== "open") return null;
if (
input.recent &&
(mapGiteaState(input.recent.state) === "open" || input.recent.url === previous.url)
)
return null;
if (!previous.number)
throw new ForgeDiscoveryIncompleteError(
"Cannot revalidate the previously observed open pull request",
);
const current = await runJson(
["api", `${input.repoPath}/pulls/${previous.number}`],
{ cwd: input.cwd },
GiteaCurrentPullRequestApiSchema,
);
const candidate = currentPullRequestApiToListItem(current);
return candidate.url === previous.url &&
(await input.matchesHead(candidate)) &&
(await input.matchesRepository(current))
? candidate
: null;
}
async function findRecentPullRequestForHeadRef(
input: { cwd: string; headRef: string },
matchesHead: (item: GiteaPrListItem) => Promise<boolean>,
repoPath: string,
matchesRepository: (item: GiteaCurrentPullRequestApi) => Promise<boolean>,
): Promise<GiteaPrListItem | null> {
// tea discovers repository context from any configured git remote, while
// the default remote resolver reads origin only. Preserve that broader
// discovery for repositories whose primary remote has another name.
const items = await runJsonArray(
[
"api",
`${repoPath}/pulls?state=all&sort=recentupdate&page=1&limit=${CURRENT_PR_LOOKUP_PAGE_SIZE}`,
@@ -1628,32 +1826,31 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
{ cwd: input.cwd },
GiteaCurrentPullRequestApiSchema,
);
const candidates = recentItems.filter((item) => {
const candidates: GiteaCurrentPullRequestApi[] = [];
for (const item of items) {
if (
matchesCurrentHeadRef(
currentPullRequestApiToListItem(item),
input.headRef,
expectedHeadOwner,
)
) {
return true;
}
// A cross-fork PR carries the fork owner in its head, so the checkout-owner
// gate excludes it even though the PR lives in this base repo. The head sha
// is an unambiguous identity, so accept a match on it regardless of owner —
// this mirrors the GitLab adapter, which resolves fork MRs without an
// owner gate.
return input.headSha !== undefined && item.head.sha === input.headSha;
});
const match =
candidates.find((item) => mapGiteaState(item.state) === "open") ??
candidates.find(
(item) =>
mapGiteaState(item.state) !== "open" &&
input.headSha !== undefined &&
item.head.sha === input.headSha,
) ??
null;
(await matchesHead(currentPullRequestApiToListItem(item))) &&
(await matchesRepository(item))
)
candidates.push(item);
}
candidates.sort(
(a, b) =>
Number(b.state === "open") - Number(a.state === "open") ||
parseOptionalTime(b.updated_at) - parseOptionalTime(a.updated_at) ||
b.number - a.number,
);
const match = candidates[0] ?? null;
const last = items.at(-1);
if (
items.length >= CURRENT_PR_LOOKUP_PAGE_SIZE &&
(!match?.updated_at ||
!last?.updated_at ||
parseOptionalTime(last.updated_at) >= parseOptionalTime(match.updated_at))
)
throw new ForgeDiscoveryIncompleteError(
"Gitea pull request discovery exceeded its recent candidate window",
);
return match ? currentPullRequestApiToListItem(match) : null;
}
@@ -1662,9 +1859,16 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
cwd: string;
headRef: string;
headSha?: string;
force?: boolean;
},
expectedHeadOwner: string | null,
): Promise<GiteaPrListItem | null> {
matchesHead: (item: GiteaPrListItem) => Promise<boolean>,
repoPath: string,
matchesRepository: (item: GiteaCurrentPullRequestApi) => Promise<boolean>,
expectedPath: string,
endpoint?: import("./forge-service.js").HeadRepositoryEndpoint,
): Promise<{ match: GiteaPrListItem | null; exhausted: boolean }> {
let identityReads = 0;
const remoteUrl = await resolveRemoteUrl(input.cwd);
for (let page = 1; page <= CURRENT_PR_OPEN_LOOKUP_MAX_PAGES; page += 1) {
const items = await listPullRequestItems({
cwd: input.cwd,
@@ -1672,17 +1876,36 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
limit: CURRENT_PR_LOOKUP_PAGE_SIZE,
page,
});
const match = items.find((item) =>
matchesCurrentHeadRef(item, input.headRef, expectedHeadOwner),
);
if (match) {
return match;
for (const item of items) {
if (!(await matchesHead(item))) continue;
const identityKey = JSON.stringify([
input.cwd,
remoteUrl,
repoPath,
item.index,
expectedPath.toLowerCase(),
endpoint ?? null,
]);
const cached = input.force ? undefined : pullRequestIdentityCache.get(identityKey);
if (cached === true) return { match: item, exhausted: false };
if (cached === false) continue;
if (identityReads >= CURRENT_PR_IDENTITY_READ_LIMIT)
return { match: null, exhausted: true };
identityReads += 1;
const detail = await runJson(
["api", `${repoPath}/pulls/${item.index}`],
{ cwd: input.cwd },
GiteaCurrentPullRequestApiSchema,
);
const matches = await matchesRepository(detail);
pullRequestIdentityCache.set(identityKey, matches);
if (matches) return { match: { ...item, headSha: detail.head.sha }, exhausted: false };
}
if (items.length < CURRENT_PR_LOOKUP_PAGE_SIZE) {
return null;
return { match: null, exhausted: false };
}
}
return null;
return { match: null, exhausted: true };
}
async function loadCurrentPullRequestChecks(
@@ -1718,7 +1941,12 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
combined,
actionTasks,
);
return applyGiteaChecks(status, combined, actionTasks, actionRequiredRunIds);
return applyGiteaChecks(
status.headSha ? status : { ...status, headSha: pr.headSha },
combined,
actionTasks,
actionRequiredRunIds,
);
} catch {
return status;
}
@@ -2003,6 +2231,12 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
cwd: input.cwd,
headRef: input.headRef,
headSha: input.headSha,
headRepositoryOwner: input.headRepositoryOwner,
headRepositoryPath: input.headRepositoryPath,
headRepositoryEndpoint: input.headRepositoryEndpoint,
previousPullRequest: input.previousPullRequest,
changeRequestNumber: input.changeRequestNumber,
force: input.force,
});
},
@@ -2325,6 +2559,8 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
},
invalidate(input: { cwd: string }): void {
pullRequestIdentityCache.clear();
canonicalRepositoryPaths.clear();
const prefix = `${input.cwd}:`;
for (const key of currentPrHeadShaCache.keys()) {
if (key.startsWith(prefix)) {
@@ -18,14 +18,16 @@ import {
} from "./github-service.js";
import { isPlatform } from "../test-utils/platform.js";
import { CheckoutPrStatusResponseSchema } from "@getpaseo/protocol/messages";
import { ForgeDiscoveryIncompleteError } from "./forge-service.js";
const EXPECTED_GITHUB_FAST_POLL_MS = 20_000;
const EXPECTED_GITHUB_SLOW_POLL_MS = 120_000;
const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000;
const CURRENT_PR_STATUS_BASE_FIELDS =
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,updatedAt,mergedAt,reviewDecision,mergeable,headRepositoryOwner,headRepository";
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
const FORK_PR_QUERY = "state=all&per_page=100&head=forkOwner%3Afeature%2Ffork";
const FORK_PR_QUERY =
"state=all&per_page=100&head=forkOwner%3Afeature%2Ffork&sort=updated&direction=desc";
interface RunnerCall {
args: string[];
@@ -202,6 +204,8 @@ function readFakeGitHubCliLog(logPath: string): string[] {
}
function currentPullRequestJson(overrides: Record<string, unknown> = {}): string {
const repoUrl = String(overrides.url ?? "https://github.com/parentOwner/parentRepo/pull/42");
const [, owner, name] = new URL(repoUrl).pathname.split("/");
return JSON.stringify({
number: 42,
url: "https://github.com/parentOwner/parentRepo/pull/42",
@@ -211,6 +215,8 @@ function currentPullRequestJson(overrides: Record<string, unknown> = {}): string
baseRefName: "main",
headRefName: "feature/fork",
headRefOid: "1111111111111111111111111111111111111111",
headRepositoryOwner: { login: owner },
headRepository: { name },
mergedAt: null,
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
@@ -244,6 +250,8 @@ function currentPullRequestGithubFactsJson(overrides: Record<string, unknown> =
}
function batchPollPrNodeJson(overrides: Record<string, unknown> = {}): Record<string, unknown> {
const repoUrl = String(overrides.url ?? "https://github.com/acme/widgets/pull/42");
const [, owner, name] = new URL(repoUrl).pathname.split("/");
return {
number: 42,
url: "https://github.com/acme/widgets/pull/42",
@@ -256,7 +264,8 @@ function batchPollPrNodeJson(overrides: Record<string, unknown> = {}): Record<st
mergedAt: "2026-08-01T00:00:00Z",
mergeable: "UNKNOWN",
reviewDecision: null,
headRepositoryOwner: { login: "acme" },
headRepositoryOwner: { login: owner },
headRepository: { name },
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
@@ -888,6 +897,7 @@ describe("ForgeService", () => {
cwd: "/repo",
headRef: "open-button-targets-active-file",
headRepositoryOwner: "fork-owner",
headRepositoryPath: "fork-owner/parentRepo",
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
@@ -907,6 +917,50 @@ describe("ForgeService", () => {
service.dispose?.();
});
it("keeps mixed-port endpoint proof in retained polls", async () => {
const endpoint = {
transport: "ssh" as const,
host: "forge.example",
port: "2222",
path: "parentOwner/parentRepo",
};
const calls: string[][] = [];
const service = createGitHubService({
ttlMs: 0,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => null,
runner: async (args) => {
calls.push(args);
if (args[0] === "pr") return { stdout: currentPullRequestJson(), stderr: "" };
if (args[1] === "repos/parentOwner/parentRepo")
return {
stdout: JSON.stringify({
ssh_url: "ssh://git@forge.example:2222/parentOwner/parentRepo.git",
}),
stderr: "",
};
return { stdout: currentPullRequestGithubFactsJson(), stderr: "" };
},
});
const reads = recordCurrentPullRequestStatusReads(service);
const statuses: Array<CurrentPullRequestStatus | null> = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryPath: endpoint.path,
headRepositoryEndpoint: endpoint,
onStatus: (status) => statuses.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(reads).toEqual([expect.objectContaining({ headRepositoryEndpoint: endpoint })]);
expect(statuses).toEqual([expect.objectContaining({ number: 42 })]);
expect(calls.filter((args) => args[1] === "repos/parentOwner/parentRepo")).toHaveLength(1);
subscription?.unsubscribe();
service.dispose?.();
});
it("polls PR status at slow cadence after stable checks", async () => {
let now = 0;
const runner = createRunner([
@@ -1711,10 +1765,10 @@ describe("ForgeService", () => {
batchPollPrNodeJson({
number: 100 + index,
url: `https://github.com/acme/widgets/pull/${100 + index}`,
state: "OPEN",
mergedAt: null,
state: index === 0 ? "MERGED" : "OPEN",
mergedAt: index === 0 ? "2026-08-01T00:00:00Z" : null,
headRefName: "feat-a",
headRepositoryOwner: { login: "stranger" },
headRepositoryOwner: { login: index === 0 ? "acme" : "stranger" },
}),
);
const runner = createScriptedRunner([
@@ -1740,8 +1794,7 @@ describe("ForgeService", () => {
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
// Ten same-named fork PRs filled the page, so absence of a self-owned PR
// is inconclusive and the target resolves through the legacy path.
// A full page can contain a terminal match while an older open PR is omitted.
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
expect(statuses).toEqual([expect.objectContaining({ number: 42, state: "open" })]);
@@ -1790,6 +1843,114 @@ describe("ForgeService", () => {
service.dispose?.();
});
it("uses a full fork path in batched polls without a separate owner hint", async () => {
const node = batchPollPrNodeJson({
state: "OPEN",
mergedAt: null,
headRepositoryOwner: { login: "fork" },
headRepository: { name: "renamed" },
});
const runner = createScriptedRunner([
batchPollStatusJson({ t0: batchPollRepositoryJson([node]) }),
batchPollStatusJson({ t0: batchPollChecksAliasJson([]) }),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => "acme/widgets",
});
const statuses: Array<CurrentPullRequestStatus | null> = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
headRepositoryPath: "fork/renamed",
onStatus: (status) => statuses.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(statuses).toEqual([expect.objectContaining({ number: 42, state: "open" })]);
subscription?.unsubscribe();
service.dispose?.();
});
it("uses canonical repository identity for batched polls after a transfer", async () => {
const node = batchPollPrNodeJson({
state: "OPEN",
mergedAt: null,
headRepositoryOwner: { login: "new-owner" },
headRepository: { name: "repo" },
});
const runner = createScriptedRunner([
batchPollStatusJson({
t0: batchPollRepositoryJson([node], { owner: { login: "new-owner" }, name: "repo" }),
}),
batchPollStatusJson({ t0: batchPollChecksAliasJson([]) }),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => "old-owner/repo",
});
const statuses: Array<CurrentPullRequestStatus | null> = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
headRepositoryPath: "old-owner/repo",
onStatus: (status) => statuses.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(statuses).toEqual([expect.objectContaining({ number: 42, state: "open" })]);
subscription?.unsubscribe();
service.dispose?.();
});
it("resolves a transferred fork during batched polling", async () => {
const node = batchPollPrNodeJson({
state: "OPEN",
mergedAt: null,
headRepositoryOwner: { login: "new-fork" },
headRepository: { name: "repo" },
});
const pr = currentPullRequestJson({
url: "https://github.com/base/repo/pull/42",
headRefName: "feat-a",
headRepositoryOwner: { login: "new-fork" },
headRepository: { name: "repo" },
});
const runner = createScriptedRunner([
batchPollStatusJson({ t0: batchPollRepositoryJson([node]) }),
pr,
JSON.stringify([JSON.parse(pr)]),
JSON.stringify({ full_name: "new-fork/repo" }),
currentPullRequestGithubFactsJson(),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => "base/repo",
});
const statuses: Array<CurrentPullRequestStatus | null> = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
headRepositoryOwner: "old-fork",
headRepositoryPath: "old-fork/repo",
onStatus: (status) => statuses.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(statuses).toEqual([expect.objectContaining({ number: 42, state: "open" })]);
subscription?.unsubscribe();
service.dispose?.();
});
it("recovers healthy aliases from a partially failed batch response", async () => {
let now = 0;
const partialBody = batchPollStatusJson({
@@ -2718,6 +2879,8 @@ describe("ForgeService", () => {
isDraft: true,
baseRefName: "main",
headRefName: "feature/pr-pane",
headRepositoryOwner: { login: "acme" },
headRepository: { name: "repo" },
mergedAt: null,
reviewDecision: "REVIEW_REQUIRED",
statusCheckRollup: [
@@ -3219,14 +3382,18 @@ describe("ForgeService", () => {
});
});
it("keeps a merged PR only when headRefOid matches the checkout HEAD", async () => {
it("keeps a merged PR when headRefOid matches the checkout HEAD", async () => {
const headSha = "2222222222222222222222222222222222222222";
const runner = createRunner([
currentPullRequestJson({
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
headRefOid: headSha,
}),
const merged = currentPullRequestJson({
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
headRefOid: headSha,
headRepositoryOwner: { login: "parentOwner" },
});
const runner = createScriptedRunner([
merged,
JSON.stringify({ owner: { login: "parentOwner" }, name: "parentRepo", parent: null }),
JSON.stringify([JSON.parse(merged)]),
]);
const service = createGitHubService({
runner: runner.runner,
@@ -3243,11 +3410,12 @@ describe("ForgeService", () => {
expect(status?.state).toBe("merged");
});
it("does not attach a stale merged PR after a same-name branch advances", async () => {
it("shows a merged PR after a same-name branch advances", async () => {
const stale = currentPullRequestJson({
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
headRefOid: "1111111111111111111111111111111111111111",
headRepositoryOwner: { login: "parentOwner" },
});
const runner = createScriptedRunner([
stale,
@@ -3265,12 +3433,183 @@ describe("ForgeService", () => {
headRef: "feature/fork",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toBeNull();
).resolves.toMatchObject({
number: 42,
state: "merged",
headSha: "1111111111111111111111111111111111111111",
});
expect(runner.calls.some((call) => call.args[0] === "pr" && call.args[1] === "list")).toBe(
true,
);
});
it.each(["OPEN", "MERGED"])(
"ignores a %s PR from another repository of the same owner",
async (state) => {
const foreign = JSON.parse(
currentPullRequestJson({
state,
headRepositoryOwner: { login: "same" },
headRepository: { name: "other" },
}),
);
const wanted = JSON.parse(
currentPullRequestJson({
number: 43,
state,
headRepositoryOwner: { login: "same" },
headRepository: { name: "wanted" },
}),
);
const runner = createScriptedRunner([
JSON.stringify(foreign),
JSON.stringify([foreign, wanted]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "same",
headRepositoryPath: "same/wanted",
});
expect(status?.number).toBe(43);
},
);
it("uses the checkout hint only to discover its fork repository", async () => {
const hint = currentPullRequestJson({
number: 9,
state: "MERGED",
headRepositoryOwner: { login: "fork" },
headRepository: { name: "renamed" },
});
const open = currentPullRequestJson({
number: 10,
headRepositoryOwner: { login: "fork" },
headRepository: { name: "renamed" },
});
const runner = createScriptedRunner([hint, open]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "fork",
changeRequestNumber: 9,
});
expect(status).toMatchObject({ number: 10, state: "open" });
});
it("uses the full head path when no separate owner hint is present", async () => {
const other = currentPullRequestJson({
url: "https://github.com/base/repo/pull/42",
state: "CLOSED",
headRepositoryOwner: { login: "base" },
headRepository: { name: "repo" },
});
const wanted = currentPullRequestJson({
number: 43,
url: "https://github.com/base/repo/pull/43",
headRepositoryOwner: { login: "fork" },
headRepository: { name: "renamed" },
});
const runner = createScriptedRunner([
other,
JSON.stringify({ owner: { login: "base" }, name: "repo", parent: null }),
JSON.stringify([JSON.parse(wanted)]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryPath: "fork/renamed",
}),
).toMatchObject({ number: 43, state: "open" });
});
it("finds the correct repository when the branch search is crowded", async () => {
const other = JSON.parse(
currentPullRequestJson({
headRepositoryOwner: { login: "same" },
headRepository: { name: "other" },
}),
);
const crowd = Array.from({ length: 10 }, (_, i) => ({ ...other, number: i + 100 }));
const wanted = currentPullRequestJson({
number: 42,
url: "https://github.com/base/project/pull/42",
headRepositoryOwner: { login: "same" },
headRepository: { name: "wanted" },
});
const runner = createScriptedRunner([
JSON.stringify(other),
JSON.stringify(crowd),
JSON.stringify({ full_name: "same/wanted" }),
JSON.stringify({ owner: { login: "base" }, name: "project", parent: null }),
JSON.stringify([{ number: 42, state: "open", head: { repo: { full_name: "same/wanted" } } }]),
wanted,
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "same",
headRepositoryPath: "same/wanted",
}),
).toMatchObject({ number: 42, state: "open" });
});
it("selects the most recently updated terminal PR instead of the checkout SHA", async () => {
const older = JSON.parse(
currentPullRequestJson({
number: 41,
state: "MERGED",
mergedAt: "2026-07-01T00:00:00Z",
updatedAt: "2026-08-01T01:00:00+02:00",
headRefOid: "checkout",
}),
);
const newer = JSON.parse(
currentPullRequestJson({
number: 42,
state: "CLOSED",
mergedAt: null,
updatedAt: "2026-08-01T00:00:00Z",
headRefOid: "remote",
}),
);
const runner = createScriptedRunner([
JSON.stringify(older),
JSON.stringify({ owner: { login: "parentOwner" }, name: "parentRepo", parent: null }),
JSON.stringify([older, newer]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha: "checkout",
headRepositoryPath: "parentOwner/parentRepo",
}),
).toMatchObject({ number: 42, state: "closed" });
});
it("prefers an open PR over an exact-SHA merged PR for the same head", async () => {
const checkoutSha = "2222222222222222222222222222222222222222";
const owner = { login: "forkOwner" };
@@ -3308,6 +3647,7 @@ describe("ForgeService", () => {
headRef: "feature/fork",
headSha: checkoutSha,
headRepositoryOwner: "forkOwner",
headRepositoryPath: "forkOwner/parentRepo",
});
expect(status).toMatchObject({ number: 43, state: "open" });
@@ -3326,7 +3666,18 @@ describe("ForgeService", () => {
name: "parentRepo",
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
}),
JSON.stringify([{ number: 41 }, { number: 42 }]),
JSON.stringify([
{
number: 41,
updated_at: "2026-08-02",
head: { repo: { full_name: "forkOwner/parentRepo" } },
},
{
number: 42,
updated_at: "2026-08-01",
head: { repo: { full_name: "forkOwner/parentRepo" } },
},
]),
JSON.stringify({
number: 41,
url: "https://github.com/parentOwner/parentRepo/pull/41",
@@ -3339,6 +3690,7 @@ describe("ForgeService", () => {
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: "otherFork" },
headRepository: { name: "parentRepo" },
}),
JSON.stringify({
number: 42,
@@ -3352,6 +3704,7 @@ describe("ForgeService", () => {
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: "forkOwner" },
headRepository: { name: "parentRepo" },
}),
]);
const service = createGitHubService({
@@ -3381,7 +3734,7 @@ describe("ForgeService", () => {
]);
});
it("reads the fork PR carrying the checked-out commit past newer attempts on the branch", async () => {
it("selects the latest fork PR when a branch was reused", async () => {
const checkoutSha = "3333333333333333333333333333333333333333";
const viewed: number[] = [];
const runner: GitHubCommandRunner = async (args) => {
@@ -3399,10 +3752,19 @@ describe("ForgeService", () => {
// Newest first, and the checkout is sitting on the oldest attempt.
return {
stdout: JSON.stringify([
{ number: 50, head: { sha: "5".repeat(40) } },
{ number: 49, head: { sha: "4".repeat(40) } },
{ number: 48, head: { sha: "8".repeat(40) } },
{ number: 47, head: { sha: checkoutSha } },
{
number: 50,
head: { sha: "5".repeat(40), repo: { full_name: "forkOwner/parentRepo" } },
},
{
number: 49,
head: { sha: "4".repeat(40), repo: { full_name: "forkOwner/parentRepo" } },
},
{
number: 48,
head: { sha: "8".repeat(40), repo: { full_name: "forkOwner/parentRepo" } },
},
{ number: 47, head: { sha: checkoutSha, repo: { full_name: "forkOwner/parentRepo" } } },
]),
stderr: "",
};
@@ -3436,14 +3798,163 @@ describe("ForgeService", () => {
});
expect(status).toMatchObject({
number: 47,
number: 50,
repoOwner: "parentOwner",
repoName: "parentRepo",
});
expect(viewed).toContain(47);
expect(viewed).not.toContain(47);
expect(viewed).toHaveLength(3);
});
it("does not scan beyond the fork request budget when open status is unknown", async () => {
const refs = Array.from({ length: 100 }, (_, i) => ({
number: i + 1,
state: "closed",
head: { repo: { full_name: "forkOwner/parentRepo" } },
}));
const runner = createScriptedRunner([
{ error: noPullRequestError() },
JSON.stringify({
owner: { login: "forkOwner" },
name: "parentRepo",
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
}),
JSON.stringify(refs),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
await expect(
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }),
).rejects.toBeInstanceOf(ForgeDiscoveryIncompleteError);
expect(runner.calls.filter((call) => call.args[0] === "api")).toHaveLength(1);
});
it("matches a repository transferred while the checkout retains its old origin URL", async () => {
const pr = currentPullRequestJson({
url: "https://github.com/new-owner/repo/pull/42",
headRepositoryOwner: { login: "new-owner" },
headRepository: { name: "repo" },
});
const runner = createScriptedRunner([
pr,
JSON.stringify({ owner: { login: "new-owner" }, name: "repo", parent: null }),
JSON.stringify([JSON.parse(pr)]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoSlug: async () => "old-owner/repo",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryPath: "old-owner/repo",
}),
).toMatchObject({ number: 42 });
});
it("matches a transferred fork using its canonical repository identity", async () => {
const pr = currentPullRequestJson({
url: "https://github.com/base/repo/pull/42",
headRepositoryOwner: { login: "new-fork" },
headRepository: { name: "repo" },
});
const runner = createScriptedRunner([
pr,
JSON.stringify([JSON.parse(pr)]),
JSON.stringify({ full_name: "new-fork/repo" }),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "old-fork",
headRepositoryPath: "old-fork/repo",
}),
).toMatchObject({ number: 42 });
});
it("finds a transferred fork when the branch candidate window is full", async () => {
const pr = JSON.parse(
currentPullRequestJson({
url: "https://github.com/base/repo/pull/42",
headRepositoryOwner: { login: "new-fork" },
headRepository: { name: "repo" },
}),
);
const unrelated = Array.from({ length: 9 }, (_, i) => ({
...pr,
number: i + 50,
headRepositoryOwner: { login: "other" },
}));
const runner = createScriptedRunner([
JSON.stringify(pr),
JSON.stringify([...unrelated, pr]),
JSON.stringify({ full_name: "new-fork/repo" }),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "old-fork",
headRepositoryPath: "old-fork/repo",
}),
).toMatchObject({ number: 42, state: "open" });
});
it("uses the canonical fork in the bounded terminal fallback after a transfer", async () => {
const pr = JSON.parse(
currentPullRequestJson({
url: "https://github.com/base/repo/pull/42",
state: "MERGED",
mergedAt: "2026-01-01T00:00:00Z",
headRepositoryOwner: { login: "new-fork" },
headRepository: { name: "repo" },
}),
);
const unrelated = Array.from({ length: 9 }, (_, i) => ({
...pr,
number: i + 50,
headRepositoryOwner: { login: "other" },
}));
const runner = createScriptedRunner([
JSON.stringify(pr),
JSON.stringify([...unrelated, pr]),
JSON.stringify({ full_name: "new-fork/repo" }),
JSON.stringify({ owner: { login: "base" }, name: "repo", parent: null }),
JSON.stringify([
{ number: 42, state: "closed", head: { repo: { full_name: "new-fork/repo" } } },
]),
JSON.stringify(pr),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "old-fork",
headRepositoryPath: "old-fork/repo",
}),
).toMatchObject({ number: 42, state: "merged" });
expect(
runner.calls.some((call) => call.args[1]?.includes("head=new-fork%3Afeature%2Ffork")),
).toBe(true);
});
it("propagates a failed fork PR lookup instead of reporting no pull request", async () => {
const rateLimitError = new GitHubCommandError({
args: ["api", "repos/parentOwner/parentRepo/pulls"],
@@ -3493,6 +4004,7 @@ describe("ForgeService", () => {
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: headRepositoryOwner },
headRepository: { name: "parentRepo" },
};
}
const crowd = Array.from({ length: 10 }, (_, index) =>
@@ -3524,7 +4036,17 @@ describe("ForgeService", () => {
}
if (args[0] === "api" && args[1]?.startsWith("repos/")) {
expect(args[1]).toContain("head=forkOwner%3Amain");
return { stdout: JSON.stringify([{ number: 4984 }]), stderr: "" };
return {
stdout: JSON.stringify([
...[1, 2, 3, 4].map((number) => ({
number,
state: "open",
head: { repo: { full_name: "forkOwner/other" } },
})),
{ number: 4984, state: "open", head: { repo: { full_name: "forkOwner/parentRepo" } } },
]),
stderr: "",
};
}
return { stdout: "{}", stderr: "" };
};
@@ -3560,7 +4082,7 @@ describe("ForgeService", () => {
name: "parentRepo",
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
}),
JSON.stringify([{ number: 42 }]),
JSON.stringify([{ number: 42, head: { repo: { full_name: "forkOwner/parentRepo" } } }]),
{
error: statusCheckRollupPermissionError([
"pr",
@@ -3583,6 +4105,7 @@ describe("ForgeService", () => {
mergedAt: null,
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: "forkOwner" },
headRepository: { name: "parentRepo" },
}),
]);
const service = createGitHubService({
@@ -3679,6 +4202,8 @@ describe("ForgeService", () => {
"all",
"--head",
"main",
"--search",
"sort:updated-desc",
"--limit",
"10",
"--json",
@@ -3716,6 +4241,7 @@ describe("ForgeService", () => {
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: "chethanuk" },
headRepository: { name: "repo" },
},
]),
]);
@@ -3729,6 +4255,7 @@ describe("ForgeService", () => {
cwd: "/repo",
headRef: "main",
headRepositoryOwner: "chethanuk",
headRepositoryPath: "chethanuk/repo",
});
expect(status).toMatchObject({
@@ -3746,6 +4273,8 @@ describe("ForgeService", () => {
"all",
"--head",
"main",
"--search",
"sort:updated-desc",
"--limit",
"10",
"--json",
@@ -3762,7 +4291,7 @@ describe("ForgeService", () => {
name: "repo",
parent: { owner: { login: "parentOwner" }, name: "repo" },
}),
JSON.stringify([{ number: 88 }]),
JSON.stringify([{ number: 88, head: { repo: { full_name: "forkOwner/repo" } } }]),
JSON.stringify({
number: 88,
url: "https://github.com/parentOwner/repo/pull/88",
@@ -3775,6 +4304,7 @@ describe("ForgeService", () => {
statusCheckRollup: [],
reviewDecision: null,
headRepositoryOwner: { login: "forkOwner" },
headRepository: { name: "repo" },
}),
]);
const service = createGitHubService({
@@ -4420,6 +4950,49 @@ describe("ForgeService", () => {
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
});
it("requires forge clone URL proof for mixed ports and separates cache entries", async () => {
const calls: string[][] = [];
let cloneUrl: string | undefined = "ssh://git@forge.example:2222/parentOwner/parentRepo.git";
const service = createGitHubService({
resolveGhPath: async () => "/usr/bin/gh",
runner: async (args) => {
calls.push(args);
if (args[0] === "pr") return { stdout: currentPullRequestJson(), stderr: "" };
if (args[0] === "api" && args[1] === "repos/parentOwner/parentRepo")
return { stdout: JSON.stringify({ ssh_url: cloneUrl }), stderr: "" };
return { stdout: currentPullRequestGithubFactsJson(), stderr: "" };
},
});
const endpoint = {
transport: "ssh" as const,
host: "forge.example",
port: "2222",
path: "parentOwner/parentRepo",
};
const input = {
cwd: "/repo",
headRef: "feature/fork",
headRepositoryPath: endpoint.path,
headRepositoryEndpoint: endpoint,
};
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(42);
expect(
await service.getCurrentPullRequestStatus({
...input,
headRepositoryEndpoint: { ...endpoint, port: "2223" },
}),
).toBeNull();
expect(
(await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" }))
?.number,
).toBe(42);
cloneUrl = undefined;
expect(
await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" }),
).toBeNull();
expect(calls.filter((args) => args[1] === "repos/parentOwner/parentRepo")).toHaveLength(4);
});
it("bypasses the warm PR status cache for forced reads", async () => {
const runner = createRunner([
currentPullRequestJson({ number: 41, title: "First" }),
+326 -89
View File
@@ -28,6 +28,8 @@ import {
createUnavailableSearchResult,
normalizeForgeSearchKinds,
parseOptionalTime,
matchesHeadRepositoryEndpoint,
ForgeDiscoveryIncompleteError,
} from "./forge-service.js";
import type {
CheckAnnotation,
@@ -346,6 +348,8 @@ const CurrentPullRequestStatusSchema = z.object({
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
headRefOid: z.string().optional(),
headRepository: z.object({ name: z.string() }).nullable().optional(),
updatedAt: z.string().optional(),
mergedAt: z.string().nullable().optional(),
statusCheckRollup: z.unknown().optional(),
reviewDecision: z.unknown().optional(),
@@ -469,8 +473,13 @@ const GitHubRepoViewSchema = z.object({
const ForkPullRequestRefsSchema = z.array(
z.object({
number: z.number(),
state: z.string().optional(),
updated_at: z.string().optional(),
head: z
.object({ sha: z.string().catch("") })
.object({
sha: z.string().catch(""),
repo: z.object({ full_name: z.string() }).nullable().optional(),
})
.nullable()
.optional(),
}),
@@ -524,7 +533,7 @@ query PullRequestCheckoutTarget($owner: String!, $name: String!, $number: Int!)
}`;
const CURRENT_PR_STATUS_BASE_FIELDS =
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,updatedAt,mergedAt,reviewDecision,mergeable,headRepositoryOwner,headRepository";
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
const PULL_REQUEST_STATUS_FACTS_QUERY = `
@@ -640,6 +649,8 @@ const BatchPollPrNodeSchema = z.object({
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
headRefOid: z.string().catch(""),
headRepository: z.object({ name: z.string() }).nullable().optional(),
updatedAt: z.string().optional(),
mergedAt: z.string().nullable().optional(),
mergeable: PullRequestMergeableSchema.optional().default("UNKNOWN"),
reviewDecision: z.unknown().optional(),
@@ -656,6 +667,7 @@ const BatchPollPrNodeSchema = z.object({
const BatchPollRepositorySchema = z.object({
owner: z.object({ login: z.string().optional() }).nullable().optional().catch(null),
name: z.string().optional(),
isFork: z.boolean().optional().catch(false),
parent: z
.object({ owner: z.object({ login: z.string() }), name: z.string() })
@@ -744,12 +756,14 @@ fragment PaseoPollPullRequest on PullRequest {
baseRefName
headRefName
headRefOid
updatedAt
mergedAt
mergeable
reviewDecision
headRepositoryOwner {
login
}
headRepository { name }
mergeStateStatus
autoMergeRequest {
enabledAt
@@ -785,6 +799,7 @@ function buildBatchPullRequestStatusQuery(
owner {
login
}
name
isFork
parent {
owner {
@@ -799,7 +814,7 @@ function buildBatchPullRequestStatusQuery(
viewerDefaultMergeMethod
pullRequests(headRefName: ${graphqlString(
entry.headRef,
)}, first: ${BATCH_PR_CANDIDATE_LIMIT}, orderBy: {field: CREATED_AT, direction: DESC}) {
)}, first: ${BATCH_PR_CANDIDATE_LIMIT}, orderBy: {field: UPDATED_AT, direction: DESC}) {
nodes {
...PaseoPollPullRequest
}
@@ -1071,6 +1086,9 @@ interface GitHubPollTarget {
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
headRepositoryEndpoint?: import("./forge-service.js").HeadRepositoryEndpoint;
changeRequestNumber?: number;
retainCount: number;
/** Absolute due time on the shared poll grid; null while a poll is in flight. */
nextDueAt: number | null;
@@ -1093,6 +1111,7 @@ interface GitHubBatchPollEntry {
owner: string;
name: string;
headRepositoryOwner?: string;
canonicalHeadRepositoryPath?: string;
}
interface PendingBatchPullRequestChecks {
@@ -1116,8 +1135,9 @@ interface GitHubBatchDistribution {
interface ResolvedPullRequestCandidate {
status: CurrentPullRequestStatus;
headSha?: string;
updatedAt?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
}
export function createGitHubService(options: CreateGitHubServiceOptions = {}): GitHubService {
@@ -1163,7 +1183,12 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
const frozenPullRequestChecksCache = new Map<string, unknown[]>();
const pollRepositoryRedirects = new Map<
string,
{ owner: string; name: string; headRepositoryOwner: string }
{
owner: string;
name: string;
headRepositoryOwner: string;
canonicalHeadRepositoryPath: string;
}
>();
const pollPausedUntilByHost = new Map<string, number>();
const rateLimitResetLoads = new Map<string, Promise<number | null>>();
@@ -1357,6 +1382,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
headRepositoryEndpoint?: import("./forge-service.js").HeadRepositoryEndpoint;
changeRequestNumber?: number;
}): string {
return buildCacheKey({
cwd: target.cwd,
@@ -1365,6 +1393,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: target.headRef,
headSha: target.headSha,
headRepositoryOwner: target.headRepositoryOwner,
headRepositoryPath: target.headRepositoryPath,
headRepositoryEndpoint: target.headRepositoryEndpoint,
changeRequestNumber: target.changeRequestNumber,
},
});
}
@@ -1374,6 +1405,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
headRepositoryEndpoint?: import("./forge-service.js").HeadRepositoryEndpoint;
changeRequestNumber?: number;
status: CurrentPullRequestStatus | null;
notify: boolean;
}): void {
@@ -1702,6 +1736,13 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
redirected: [],
};
for (const [index, entry] of input.entries.entries()) {
if (
entry.target.headRepositoryEndpoint ||
(entry.target.headRepositoryOwner && !entry.target.headRepositoryPath)
) {
distribution.legacyFallback.push(entry.target);
continue;
}
const aliasValue = input.aliases[batchAlias(index)];
const repository = aliasValue ? BatchPollRepositorySchema.safeParse(aliasValue) : null;
if (!repository?.success) {
@@ -1722,20 +1763,18 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
owner: repository.data.parent.owner.login,
name: repository.data.parent.name,
headRepositoryOwner: forkOwner,
canonicalHeadRepositoryPath: `${forkOwner}/${repository.data.name ?? entry.originName}`,
});
distribution.redirected.push(entry);
continue;
}
if (repository.data.pullRequests.nodes.length >= BATCH_PR_CANDIDATE_LIMIT) {
distribution.legacyFallback.push(entry.target);
continue;
}
const node = selectBatchPollNode(entry, repository.data);
if (!node) {
// Legacy fallback covers the two ways "no match" can be wrong: a fork
// whose PR lives in the parent repository, and a full candidate page —
// ten newer same-named fork PRs can crowd the checkout's own PR out of
// the first:N window, so absence is only conclusive on a partial page.
if (
repository.data.isFork ||
repository.data.pullRequests.nodes.length >= BATCH_PR_CANDIDATE_LIMIT
) {
if (shouldRetryUnmatchedBatch(entry, repository.data)) {
distribution.legacyFallback.push(entry.target);
} else {
finalizeBatchPollTarget({
@@ -1748,10 +1787,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
}
continue;
}
const frozenKey =
node.state !== "OPEN" && node.headRefOid.length > 0
? `${entry.owner}/${entry.name}#${node.number}@${node.headRefOid}`
: null;
const frozenKey = frozenPullRequestKey(entry, node);
const cachedRollup = frozenKey ? frozenPullRequestChecksCache.get(frozenKey) : undefined;
if (frozenKey && cachedRollup) {
rememberFrozenPullRequestChecks(frozenKey, cachedRollup);
@@ -1774,6 +1810,24 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
return distribution;
}
function shouldRetryUnmatchedBatch(
entry: GitHubBatchPollEntry,
repository: BatchPollRepository,
): boolean {
return Boolean(
repository.isFork ||
(entry.target.headRepositoryOwner &&
entry.target.headRepositoryPath &&
repository.pullRequests.nodes.length > 0),
);
}
function frozenPullRequestKey(entry: GitHubBatchPollEntry, node: BatchPollPrNode): string | null {
return node.state !== "OPEN" && node.headRefOid.length > 0
? `${entry.owner}/${entry.name}#${node.number}@${node.headRefOid}`
: null;
}
function addressGitHubBatchEntry(entry: GitHubBatchPollEntry): GitHubBatchPollEntry {
const redirect = pollRepositoryRedirects.get(batchRepositoryRedirectKey(entry));
return redirect
@@ -1782,6 +1836,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
owner: redirect.owner,
name: redirect.name,
headRepositoryOwner: redirect.headRepositoryOwner,
canonicalHeadRepositoryPath: redirect.canonicalHeadRepositoryPath,
}
: entry;
}
@@ -1812,7 +1867,11 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
// name. Without a fork-owner hint the workspace branch lives in this
// repository, so only self-owned heads are real candidates (mirrors
// `gh pr view` resolving the current branch's own PR).
if (!headRepositoryOwner && candidate.headRepositoryOwner?.toLowerCase() !== selfOwner) {
if (
!headRepositoryOwner &&
!entry.target.headRepositoryPath &&
candidate.headRepositoryOwner?.toLowerCase() !== selfOwner
) {
continue;
}
nodeByCandidate.set(candidate, node);
@@ -1821,8 +1880,14 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
const match = pickPullRequestCandidate({
candidates,
headRef: entry.target.headRef,
headSha: entry.target.headSha,
headRepositoryOwner,
headRepositoryPath:
!entry.target.headRepositoryOwner &&
entry.target.headRepositoryPath?.toLowerCase() ===
`${entry.originOwner}/${entry.originName}`.toLowerCase()
? (entry.canonicalHeadRepositoryPath ??
`${repository.owner?.login ?? entry.originOwner}/${repository.name ?? entry.originName}`)
: (entry.target.headRepositoryPath ?? `${entry.originOwner}/${entry.originName}`),
});
return match ? (nodeByCandidate.get(match) ?? null) : null;
}
@@ -1958,6 +2023,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: entry.target.headRef,
headSha: entry.target.headSha,
headRepositoryOwner: entry.target.headRepositoryOwner,
headRepositoryPath: entry.target.headRepositoryPath,
headRepositoryEndpoint: entry.target.headRepositoryEndpoint,
changeRequestNumber: entry.target.changeRequestNumber,
},
});
// A direct or forced read can finish between this batch becoming due and
@@ -1970,6 +2038,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: entry.target.headRef,
headSha: entry.target.headSha,
headRepositoryOwner: entry.target.headRepositoryOwner,
headRepositoryPath: entry.target.headRepositoryPath,
headRepositoryEndpoint: entry.target.headRepositoryEndpoint,
changeRequestNumber: entry.target.changeRequestNumber,
status: existing.value as CurrentPullRequestStatus | null,
notify: true,
});
@@ -1983,6 +2054,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: entry.target.headRef,
headSha: entry.target.headSha,
headRepositoryOwner: entry.target.headRepositoryOwner,
headRepositoryPath: entry.target.headRepositoryPath,
headRepositoryEndpoint: entry.target.headRepositoryEndpoint,
changeRequestNumber: entry.target.changeRequestNumber,
status,
notify: true,
});
@@ -1995,6 +2069,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: target.headRef,
headSha: target.headSha,
headRepositoryOwner: target.headRepositoryOwner,
headRepositoryPath: target.headRepositoryPath,
headRepositoryEndpoint: target.headRepositoryEndpoint,
changeRequestNumber: target.changeRequestNumber,
reason: "self-heal-github",
});
} catch (error) {
@@ -2161,6 +2238,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: input.headRef,
headSha: input.headSha,
headRepositoryOwner: input.headRepositoryOwner,
headRepositoryPath: input.headRepositoryPath,
headRepositoryEndpoint: input.headRepositoryEndpoint,
changeRequestNumber: input.changeRequestNumber,
},
readOptions: input,
load: async () => {
@@ -2169,8 +2249,33 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: input.headRef,
headSha: input.headSha,
headRepositoryOwner: input.headRepositoryOwner,
headRepositoryPath: input.headRepositoryPath,
originRepositoryPath:
input.headRepositoryPath && !input.headRepositoryOwner
? await resolveRepoSlugCached(input.cwd)
: null,
changeRequestNumber: input.changeRequestNumber,
run,
});
if (status && input.headRepositoryEndpoint) {
if (!input.headRepositoryPath) return null;
const repo = await runGhJson(
["api", `repos/${input.headRepositoryPath}`],
{ cwd: input.cwd },
z.object({
ssh_url: z.string().nullable().optional(),
clone_url: z.string().nullable().optional(),
}),
"{}",
);
if (
!matchesHeadRepositoryEndpoint(input.headRepositoryEndpoint, [
repo.ssh_url,
repo.clone_url,
])
)
return null;
}
return addCurrentPullRequestGithubFacts({ cwd: input.cwd, status, run });
},
}).then((status) => {
@@ -2179,6 +2284,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: input.headRef,
headSha: input.headSha,
headRepositoryOwner: input.headRepositoryOwner,
headRepositoryPath: input.headRepositoryPath,
headRepositoryEndpoint: input.headRepositoryEndpoint,
changeRequestNumber: input.changeRequestNumber,
status,
notify: input.reason === "self-heal-github",
});
@@ -2571,6 +2679,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: input.headRef,
headSha: input.headSha,
headRepositoryOwner: input.headRepositoryOwner,
headRepositoryPath: input.headRepositoryPath,
headRepositoryEndpoint: input.headRepositoryEndpoint,
changeRequestNumber: input.changeRequestNumber,
retainCount: 0,
nextDueAt: null,
pollCycleStartedAt: null,
@@ -2952,60 +3063,160 @@ function isGitHubRateLimitError(error: unknown): boolean {
);
}
async function resolveCurrentPullRequestView(options: {
interface CurrentPullRequestLookup {
cwd: string;
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
originRepositoryPath?: string | null;
changeRequestNumber?: number;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<CurrentPullRequestStatus | null> {
const viewCandidate = await tryCurrentPullRequestView(options);
const viewMatch = viewCandidate
? pickPullRequestCandidate({
candidates: [viewCandidate],
headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner: options.headRepositoryOwner,
})
: null;
if (viewMatch) {
}
async function resolveCurrentPullRequestView(
options: CurrentPullRequestLookup,
): Promise<CurrentPullRequestStatus | null> {
const resolved = await resolveHintedHeadRepository(options);
return resolved ? resolveCurrentPullRequestForRepository(resolved) : null;
}
async function resolveHintedHeadRepository(
options: CurrentPullRequestLookup,
): Promise<CurrentPullRequestLookup | null> {
if (options.headRepositoryOwner && !options.headRepositoryPath) {
if (!options.changeRequestNumber) return null;
const hint = await tryCurrentPullRequestView({
...options,
args: ["pr", "view", String(options.changeRequestNumber)],
});
if (
!hint?.headRepositoryPath ||
hint.headRepositoryOwner?.toLowerCase() !== options.headRepositoryOwner.toLowerCase()
)
return null;
return { ...options, headRepositoryPath: hint.headRepositoryPath };
}
return options;
}
async function resolveCurrentPullRequestForRepository(
options: CurrentPullRequestLookup,
): Promise<CurrentPullRequestStatus | null> {
const viewMatch = await findDirectPullRequestMatch(options);
if (viewMatch?.status.state === "open") {
return viewMatch.status;
}
let headRepositoryOwner = options.headRepositoryOwner;
if (!options.headRepositoryOwner) return resolvePullRequestWithoutOwner(options, viewMatch);
return resolveListedPullRequest(options, viewMatch, options.headRepositoryOwner);
}
if (!headRepositoryOwner) {
const repo = await getGitHubRepoView(options);
const forkOwner = repo?.owner?.login;
const parentOwner = repo?.parent?.owner?.login;
const parentName = repo?.parent?.name;
if (!forkOwner) {
return null;
}
if (parentOwner && parentName) {
return resolveForkPullRequestView({
...options,
forkOwner,
repo: `${parentOwner}/${parentName}`,
});
}
headRepositoryOwner = forkOwner;
}
async function resolvePullRequestWithoutOwner(
options: CurrentPullRequestLookup,
viewMatch: ResolvedPullRequestCandidate | null,
): Promise<CurrentPullRequestStatus | null> {
const repo = await getGitHubRepoView(options);
const forkOwner = repo?.owner?.login;
const forkName = repo?.name;
const parentOwner = repo?.parent?.owner?.login;
const parentName = repo?.parent?.name;
if (!forkOwner || !forkName) return null;
const headRepositoryPath =
options.headRepositoryPath &&
options.headRepositoryPath.toLowerCase() !== options.originRepositoryPath?.toLowerCase()
? options.headRepositoryPath
: `${forkOwner}/${forkName}`;
options = { ...options, headRepositoryPath };
const expectedOwner = headRepositoryPath.split("/")[0];
if (!expectedOwner) return null;
if (parentOwner && parentName)
return resolveForkPullRequestView({
...options,
forkOwner: expectedOwner,
repo: `${parentOwner}/${parentName}`,
});
return resolveListedPullRequest(options, viewMatch, expectedOwner);
}
async function resolveListedPullRequest(
options: CurrentPullRequestLookup,
viewMatch: ResolvedPullRequestCandidate | null,
headRepositoryOwner: string,
): Promise<CurrentPullRequestStatus | null> {
const candidates = await listCurrentPullRequestCandidates({
cwd: options.cwd,
headRef: options.headRef,
run: options.run,
});
const match = pickPullRequestCandidate({
candidates,
let match = pickPullRequestCandidate({
candidates: viewMatch ? [viewMatch, ...candidates] : candidates,
headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner,
headRepositoryPath: options.headRepositoryPath,
});
if (
!match &&
options.headRepositoryOwner &&
options.headRepositoryPath &&
candidates.some((candidate) => isCandidateForHeadRef(candidate, options.headRef))
) {
const args = ["api", `repos/${options.headRepositoryPath}`];
const stdout = await options.run(args, { cwd: options.cwd });
const repository = parseGitHubJsonOutput(
stdout,
z.object({ full_name: z.string().optional() }),
{ args, cwd: options.cwd, emptyFallback: "{}" },
);
if (
repository.full_name &&
repository.full_name.toLowerCase() !== options.headRepositoryPath.toLowerCase()
) {
options = {
...options,
headRepositoryPath: repository.full_name,
headRepositoryOwner: repository.full_name.split("/")[0],
};
match = pickPullRequestCandidate({
candidates: viewMatch ? [viewMatch, ...candidates] : candidates,
headRef: options.headRef,
headRepositoryOwner: repository.full_name.split("/")[0],
headRepositoryPath: repository.full_name,
});
}
}
if (candidates.length >= BATCH_PR_CANDIDATE_LIMIT && options.headRepositoryPath) {
if (match?.status.state === "open") return match.status;
return resolveCrowdedPullRequests(options, options.headRepositoryPath);
}
return match?.status ?? null;
}
async function resolveCrowdedPullRequests(
options: CurrentPullRequestLookup,
headRepositoryPath: string,
): Promise<CurrentPullRequestStatus | null> {
const repo = await getGitHubRepoView(options);
const owner = repo?.parent?.owner?.login ?? repo?.owner?.login;
const name = repo?.parent?.name ?? repo?.name;
const forkOwner = headRepositoryPath.split("/")[0];
if (!owner || !name || !forkOwner) return null;
return resolveForkPullRequestView({ ...options, forkOwner, repo: `${owner}/${name}` });
}
async function findDirectPullRequestMatch(
options: CurrentPullRequestLookup,
): Promise<ResolvedPullRequestCandidate | null> {
const candidate = await tryCurrentPullRequestView(options);
if (!candidate) return null;
return pickPullRequestCandidate({
candidates: [candidate],
headRef: options.headRef,
headRepositoryOwner: options.headRepositoryOwner,
headRepositoryPath: options.headRepositoryPath,
});
}
// A fork checkout's pull request lives in the parent repository, where only
// its head repository tells it apart from every other fork's pull request on
// the same branch name. `gh pr list --head` cannot ask that question: it
@@ -3019,6 +3230,7 @@ async function resolveForkPullRequestView(options: {
headRef: string;
headSha?: string;
forkOwner: string;
headRepositoryPath?: string;
repo: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<CurrentPullRequestStatus | null> {
@@ -3038,22 +3250,19 @@ async function resolveForkPullRequestView(options: {
(candidate): candidate is ResolvedPullRequestCandidate => candidate !== null,
),
headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner: options.forkOwner,
headRepositoryPath: options.headRepositoryPath,
});
return match?.status ?? null;
}
// The endpoint answers with the fork branch's pull requests, newest first, so
// a branch reused for several attempts can carry the checkout's head commit on
// an older one. Those are read first, and the rest of the window is filled
// newest-first, so the full read stays bounded without dropping the attempt the
// checkout is actually sitting on.
// The endpoint answers with the fork branch's pull requests, newest first.
async function listForkPullRequestNumbers(options: {
cwd: string;
headRef: string;
headSha?: string;
forkOwner: string;
headRepositoryPath?: string;
repo: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<number[]> {
@@ -3061,6 +3270,8 @@ async function listForkPullRequestNumbers(options: {
state: "all",
per_page: String(FORK_PR_PAGE_SIZE),
head: `${options.forkOwner}:${options.headRef}`,
sort: "updated",
direction: "desc",
});
const args = ["api", `repos/${options.repo}/pulls?${query.toString()}`];
const stdout = await options.run(args, { cwd: options.cwd });
@@ -3069,12 +3280,22 @@ async function listForkPullRequestNumbers(options: {
cwd: options.cwd,
emptyFallback: "[]",
});
function carriesHeadSha(ref: (typeof refs)[number]): boolean {
return options.headSha !== undefined && ref.head?.sha === options.headSha;
}
return [...refs.filter(carriesHeadSha), ...refs.filter((ref) => !carriesHeadSha(ref))]
.slice(0, FORK_PR_VIEW_LIMIT)
.map((ref) => ref.number);
const matching = refs
.filter(
(ref) =>
ref.head?.repo?.full_name?.toLowerCase() === options.headRepositoryPath?.toLowerCase(),
)
.sort(
(a, b) =>
Number(b.state === "open") - Number(a.state === "open") ||
parseOptionalTime(b.updated_at) - parseOptionalTime(a.updated_at) ||
b.number - a.number,
);
if (refs.length >= FORK_PR_PAGE_SIZE && !matching.some((ref) => ref.state === "open"))
throw new ForgeDiscoveryIncompleteError(
"GitHub pull request discovery exceeded its candidate window",
);
return matching.slice(0, FORK_PR_VIEW_LIMIT).map((ref) => ref.number);
}
async function addCurrentPullRequestGithubFacts(options: {
@@ -3163,7 +3384,18 @@ async function listCurrentPullRequestCandidates(options: {
headRef: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<ResolvedPullRequestCandidate[]> {
const args = ["pr", "list", "--state", "all", "--head", options.headRef, "--limit", "10"];
const args = [
"pr",
"list",
"--state",
"all",
"--head",
options.headRef,
"--search",
"sort:updated-desc",
"--limit",
String(BATCH_PR_CANDIDATE_LIMIT),
];
try {
const stdout = await runCurrentPullRequestStatusCommand({
cwd: options.cwd,
@@ -3337,11 +3569,13 @@ function toBatchCurrentPullRequestItem(
baseRefName: node.baseRefName,
headRefName: node.headRefName,
...(node.headRefOid ? { headRefOid: node.headRefOid } : {}),
...(node.updatedAt ? { updatedAt: node.updatedAt } : {}),
mergedAt: node.mergedAt ?? null,
...(statusCheckRollup ? { statusCheckRollup } : {}),
reviewDecision: node.reviewDecision,
mergeable: node.mergeable,
headRepositoryOwner: node.headRepositoryOwner,
headRepository: node.headRepository,
};
}
@@ -3354,11 +3588,15 @@ function toCurrentPullRequestCandidate(
return null;
}
const headRepositoryOwner = item.headRepositoryOwner?.login;
const headSha = item.headRefOid;
const headRepositoryPath =
headRepositoryOwner && item.headRepository?.name
? `${headRepositoryOwner}/${item.headRepository.name}`
: undefined;
return {
status,
...(headSha ? { headSha } : {}),
updatedAt: item.updatedAt,
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
...(headRepositoryPath ? { headRepositoryPath } : {}),
};
}
@@ -3373,55 +3611,53 @@ function hasResolvedRepoIdentity(status: CurrentPullRequestStatus): boolean {
function pickPullRequestCandidate(options: {
candidates: ResolvedPullRequestCandidate[];
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
}): ResolvedPullRequestCandidate | null {
const matching = options.candidates.filter((candidate) => {
if (!isCandidateForHeadRef(candidate, options.headRef)) {
return false;
}
if (
candidate.status.state !== "open" &&
(!options.headSha || candidate.headSha !== options.headSha)
) {
options.headRepositoryPath &&
candidate.headRepositoryPath?.toLowerCase() !== options.headRepositoryPath.toLowerCase()
)
return false;
}
if (!options.headRepositoryOwner) {
return true;
if (options.headRepositoryPath) return true;
return Boolean(
candidate.headRepositoryPath &&
candidate.headRepositoryPath.toLowerCase() ===
`${candidate.status.repoOwner}/${candidate.status.repoName}`.toLowerCase(),
);
}
return candidate.headRepositoryOwner === options.headRepositoryOwner;
return (
candidate.headRepositoryOwner?.toLowerCase() === options.headRepositoryOwner.toLowerCase()
);
});
matching.sort((left, right) =>
comparePullRequestCandidatePreference(left, right, options.headSha),
);
matching.sort(comparePullRequestCandidatePreference);
return matching[0] ?? null;
}
function comparePullRequestCandidatePreference(
left: ResolvedPullRequestCandidate,
right: ResolvedPullRequestCandidate,
headSha?: string,
): number {
const stateRank = getPullRequestStateRank(left.status) - getPullRequestStateRank(right.status);
if (stateRank !== 0) {
return stateRank;
}
const leftExact = headSha !== undefined && left.headSha === headSha;
const rightExact = headSha !== undefined && right.headSha === headSha;
if (leftExact !== rightExact) {
return leftExact ? -1 : 1;
}
return 0;
return (
parseOptionalTime(right.updatedAt) - parseOptionalTime(left.updatedAt) ||
(right.status.number ?? 0) - (left.status.number ?? 0)
);
}
function getPullRequestStateRank(status: CurrentPullRequestStatus): number {
if (status.state === "open" || status.isDraft) {
if (status.state === "open") {
return 0;
}
if (status.state === "merged") {
return 1;
}
return 2;
return 1;
}
type GitHubCloneProtocol = "https" | "ssh";
@@ -3963,6 +4199,7 @@ function toCurrentPullRequestStatus(
const checks = parseStatusCheckRollup(item.statusCheckRollup);
return {
...(typeof item.number === "number" ? { number: item.number } : {}),
...(item.headRefOid ? { headSha: item.headRefOid } : {}),
...(repoIdentity ? { repoOwner: repoIdentity.owner, repoName: repoIdentity.name } : {}),
url: item.url,
title: item.title,
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { PullRequestCommandStatus } from "./forge-service.js";
import { ForgeDiscoveryIncompleteError } from "./forge-service.js";
import { GITLAB_ACTIVE_PIPELINE_STATUS_SET } from "@getpaseo/protocol/gitlab-pipeline";
import type { GitLabStatusFacts } from "./gitlab-facts.js";
import {
@@ -261,6 +262,114 @@ const DISCUSSIONS = [
];
describe("createGitLabService", () => {
it.each([true, false])(
"discovers the project without an origin remote (head path supplied: %s)",
async (hasHeadPath) => {
const mr = { ...OPEN_MR, source_branch: "topic" };
const { service } = makeService(
(args) => {
if (args[1] === "list") return ok(JSON.stringify([mr]));
if (args[1] === "view") return ok(JSON.stringify(mr));
if (
args[1] === "projects/:fullpath" ||
args[1] === "projects/example-group%2Fexample-project"
)
return ok(
JSON.stringify({ id: 101, path_with_namespace: "example-group/example-project" }),
);
return ok("{}");
},
{ resolveRemoteUrl: async () => null },
);
expect(
(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "topic",
...(hasHeadPath ? { headRepositoryPath: "example-group/example-project" } : {}),
})
)?.number,
).toBe(14);
},
);
it("requires native clone URL proof for mixed transport ports and isolates cached endpoints", async () => {
const mr = { ...OPEN_MR, source_branch: "topic" };
let cloneUrl: string | undefined =
"ssh://git@gitlab.example.com:2222/example-group/example-project.git";
const { service, calls } = makeService((args) => {
if (args[1] === "list") return ok(JSON.stringify([mr]));
if (args[1] === "view") return ok(JSON.stringify(mr));
if (args[1] === "projects/example-group%2Fexample-project")
return ok(
JSON.stringify({
id: 101,
path_with_namespace: "example-group/example-project",
ssh_url_to_repo: cloneUrl,
}),
);
return ok("{}");
});
const endpoint = {
transport: "ssh" as const,
host: "gitlab.example.com",
port: "2222",
path: "example-group/example-project",
};
const input = {
cwd: "/repo",
headRef: "topic",
headRepositoryPath: endpoint.path,
headRepositoryEndpoint: endpoint,
};
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(14);
expect(
await service.getCurrentPullRequestStatus({
...input,
headRepositoryEndpoint: { ...endpoint, port: "2223" },
}),
).toBeNull();
expect(
(await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" }))
?.number,
).toBe(14);
cloneUrl = undefined;
expect(
await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" }),
).toBeNull();
expect(
calls.filter((args) => args[1] === "projects/example-group%2Fexample-project"),
).toHaveLength(3);
});
it("finds the target fork without reading unrelated source projects", async () => {
const unrelated = Array.from({ length: 4 }, (_, i) => ({
...OPEN_MR,
iid: i + 30,
source_branch: "topic",
source_project_id: i + 200,
}));
const wanted = { ...OPEN_MR, iid: 44, source_branch: "topic", source_project_id: 300 };
const { service, calls } = makeService((args) => {
if (args[1] === "list") return ok(JSON.stringify([...unrelated, wanted]));
if (args[1] === "view") return ok(JSON.stringify(wanted));
if (args[1] === "projects/team%2Ffork")
return ok(JSON.stringify({ id: 300, path_with_namespace: "team/fork" }));
if (args[0] === "api" && /^projects\/\d+$/.test(args[1]))
return ok(
JSON.stringify({ id: Number(args[1].split("/")[1]), path_with_namespace: "other/repo" }),
);
return ok("{}");
});
expect(
(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "topic",
headRepositoryPath: "team/fork",
})
)?.number,
).toBe(44);
expect(calls.filter((args) => /^projects\/\d+$/.test(args[1] ?? ""))).toHaveLength(0);
});
it("maps a glab merge request view to the neutral current PR status", async () => {
const { service, calls } = makeService((args) =>
ok(JSON.stringify(args[1] === "list" ? [OPEN_MR] : OPEN_MR)),
@@ -321,7 +430,7 @@ describe("createGitLabService", () => {
expect(status).toBeNull();
});
it("selects a terminal merge request only when its head SHA matches the checkout", async () => {
it("selects the latest terminal merge request even when the checkout head differs", async () => {
const branch = "dev";
const checkoutSha = "2222222222222222222222222222222222222222";
const newestStale = {
@@ -342,7 +451,7 @@ describe("createGitLabService", () => {
};
const { service, calls } = makeService((args) => {
if (args[1] === "list") return ok(JSON.stringify([newestStale, exactOlder]));
if (args[1] === "view" && args[2] === "270") return ok(JSON.stringify(exactOlder));
if (args[1] === "view" && args[2] === "271") return ok(JSON.stringify(newestStale));
if (args[0] === "api") return ok("{}");
throw new Error(`unexpected call: ${args.join(" ")}`);
});
@@ -353,18 +462,159 @@ describe("createGitLabService", () => {
headSha: checkoutSha,
});
expect(status?.number).toBe(270);
expect(calls[1]).toEqual(["mr", "view", "270", "-F", "json"]);
expect(status?.number).toBe(271);
expect(status?.headSha).toBe(newestStale.sha);
expect(calls[1]).toEqual(["mr", "view", "271", "-F", "json"]);
});
it("does not attach the latest historical merge request after a reused branch advances", async () => {
it.each(["opened", "merged"])(
"matches a nested, renamed fork for a %s merge request",
async (state) => {
const wrong = { ...OPEN_MR, iid: 41, state, source_branch: "topic", source_project_id: 201 };
const right = { ...wrong, iid: 42, source_project_id: 202 };
const { service } = makeService((args) => {
if (args[1] === "list") return ok(JSON.stringify([wrong, right]));
if (args[1] === "view") return ok(JSON.stringify(right));
if (args[1] === "projects/team%2Fsubgroup%2Frenamed")
return ok(JSON.stringify({ id: 202, path_with_namespace: "team/subgroup/renamed" }));
if (args[1] === "projects/201")
return ok(JSON.stringify({ path_with_namespace: "team/subgroup/other" }));
if (args[1] === "projects/202")
return ok(JSON.stringify({ path_with_namespace: "team/subgroup/renamed" }));
if (args[0] === "api") return ok("{}");
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const result = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "topic",
headRepositoryPath: "team/subgroup/renamed",
});
expect(result?.number).toBe(42);
},
);
it("resolves a checkout hint to the full fork project before selecting the current request", async () => {
const hint = { ...OPEN_MR, iid: 41, source_branch: "topic", source_project_id: 202 };
const current = { ...hint, iid: 42, state: "opened" };
const { service } = makeService((args) => {
if (args[1] === "list") return ok(JSON.stringify([current]));
if (args[1] === "view") return ok(JSON.stringify(args[2] === "41" ? hint : current));
if (args[1] === "projects/202")
return ok(JSON.stringify({ path_with_namespace: "team/subgroup/renamed" }));
if (args[1] === "projects/team%2Fsubgroup%2Frenamed")
return ok(JSON.stringify({ id: 202, path_with_namespace: "team/subgroup/renamed" }));
if (args[0] === "api") return ok("{}");
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const result = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "topic",
headRepositoryOwner: "team",
changeRequestNumber: 41,
});
expect(result?.number).toBe(42);
});
it("prefers an open merge request over a more recently updated terminal request", async () => {
const terminal = {
...OPEN_MR,
iid: 50,
state: "merged",
source_branch: "topic",
updated_at: "2026-08-01T00:00:00Z",
};
const open = {
...OPEN_MR,
iid: 49,
state: "opened",
source_branch: "topic",
updated_at: "2026-07-01T00:00:00Z",
};
const { service } = makeService((args) => {
if (args[1] === "list") return ok(JSON.stringify([terminal, open]));
if (args[1] === "view") return ok(JSON.stringify(open));
if (args[0] === "api") return ok("{}");
throw new Error(`unexpected call: ${args.join(" ")}`);
});
expect(
await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic" }),
).toMatchObject({ number: 49, state: "open" });
});
it("does not select a terminal request when the first page cannot establish whether an open one exists", async () => {
const terminal = Array.from({ length: 100 }, (_, i) => ({
...OPEN_MR,
iid: i + 100,
state: "merged",
source_branch: "topic",
}));
const open = { ...OPEN_MR, iid: 99, state: "opened", source_branch: "topic" };
const { service, calls } = makeService((args) => {
if (args[1] === "list")
return ok(JSON.stringify(args.includes("--page") ? [open] : terminal));
if (args[1] === "view") return ok(JSON.stringify(open));
if (args[0] === "api") return ok("{}");
throw new Error(`unexpected call: ${args.join(" ")}`);
});
await expect(
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic" }),
).rejects.toBeInstanceOf(ForgeDiscoveryIncompleteError);
expect(calls.filter((args) => args[1] === "list")).toHaveLength(1);
});
it("bounds source project identity reads when a branch is crowded", async () => {
const requests = Array.from({ length: 4 }, (_, i) => ({
...OPEN_MR,
iid: i + 20,
state: "merged",
source_branch: "topic",
source_project_id: i + 200,
}));
const { service, calls } = makeService((args) => {
if (args[1] === "list") return ok(JSON.stringify(requests));
if (args[0] === "api" && args[1].startsWith("projects/"))
return ok(JSON.stringify({ path_with_namespace: "elsewhere/repo" }));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
expect(
await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic" }),
).toBeNull();
expect(
calls.filter((args) => args[0] === "api" && /^projects\/\d+$/.test(args[1])),
).toHaveLength(0);
});
it("reuses a resolved source project identity on subsequent polls", async () => {
const mr = { ...OPEN_MR, iid: 44, source_branch: "topic", source_project_id: 202 };
const { service, calls } = makeService((args) => {
if (args[1] === "list") return ok(JSON.stringify([mr]));
if (args[1] === "view") return ok(JSON.stringify(mr));
if (args[1] === "projects/team%2Ffork")
return ok(JSON.stringify({ id: 202, path_with_namespace: "team/fork" }));
if (args[0] === "api") return ok("{}");
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const input = { cwd: "/repo", headRef: "topic", headRepositoryPath: "team/fork" };
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(44);
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(44);
expect(calls.filter((args) => args[1] === "projects/team%2Ffork")).toHaveLength(1);
await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" });
expect(calls.filter((args) => args[1] === "projects/team%2Ffork")).toHaveLength(2);
});
it("shows the latest historical merge request after a reused branch advances", async () => {
const stale = {
...OPEN_MR,
state: "merged",
source_branch: "dev",
sha: "1111111111111111111111111111111111111111",
};
const { service, calls } = makeService(() => ok(JSON.stringify([stale])));
const { service, calls } = makeService((args) => {
if (args[1] === "list") return ok(JSON.stringify([stale]));
if (args[1] === "view") return ok(JSON.stringify(stale));
if (args[0] === "api") return ok("{}");
throw new Error(`unexpected call: ${args.join(" ")}`);
});
await expect(
service.getCurrentPullRequestStatus({
@@ -372,8 +622,8 @@ describe("createGitLabService", () => {
headRef: "dev",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toBeNull();
expect(calls).toEqual([currentMrListArgs("dev")]);
).resolves.toMatchObject({ number: stale.iid, state: "merged" });
expect(calls[0]).toEqual(currentMrListArgs("dev"));
});
it("looks up a numeric current branch through the source-branch list filter", async () => {
+121 -12
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { LRUCache } from "lru-cache";
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import { findExecutable } from "../executable-resolution/executable-resolution.js";
import {
@@ -17,6 +18,8 @@ import {
createUnavailableSearchResult,
normalizeForgeSearchKinds,
parseOptionalTime,
matchesHeadRepositoryEndpoint,
ForgeDiscoveryIncompleteError,
} from "./forge-service.js";
import type {
CheckDetails,
@@ -720,6 +723,7 @@ function toCurrentPullRequestStatus(
const projectPath = extractProjectPath(mr.references?.full);
return {
number: mr.iid,
...(mr.sha ? { headSha: mr.sha } : {}),
...(owner ? { repoOwner: owner } : {}),
...(name ? { repoName: name } : {}),
...(projectPath ? { projectPath } : {}),
@@ -911,6 +915,15 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
const runner = options.runner ?? runGlabCommand;
const resolveGlab = createCachedCliPathResolver(options.resolveGlabPath ?? resolveGlabPath);
const resolveRemoteUrl = options.resolveRemoteUrl ?? defaultResolveRemoteUrl;
const projectPaths = new LRUCache<
string,
{
id?: number;
path_with_namespace: string;
ssh_url_to_repo?: string | null;
http_url_to_repo?: string | null;
}
>({ max: 512, ttl: 120_000 });
async function run(args: string[], runOptions: GlabCommandRunnerOptions): Promise<string> {
const glabPath = await resolveGlab();
@@ -974,20 +987,114 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
);
}
async function resolveExpectedHeadProject(
input: Parameters<ForgeService["getCurrentPullRequestStatus"]>[0],
): Promise<string | null> {
if (input.headRepositoryPath) return input.headRepositoryPath;
if (!input.headRepositoryOwner) {
const remote = await resolveRemoteUrl(input.cwd);
return (
(remote ? parseGitRemoteLocation(remote)?.path : null) ??
(await readProject(input.cwd, ":fullpath", input.force === true)).path_with_namespace
);
}
if (!input.changeRequestNumber) return null;
const hint = await viewMergeRequest(input.cwd, String(input.changeRequestNumber));
if (hint.source_project_id == null) return null;
const path = (await readProject(input.cwd, hint.source_project_id, input.force === true))
.path_with_namespace;
return path.split("/")[0]?.toLowerCase() === input.headRepositoryOwner.toLowerCase()
? path
: null;
}
async function readProject(cwd: string, projectId: number | string, force = false) {
const key = JSON.stringify([cwd, await resolveRemoteUrl(cwd), projectId]);
const cached = force ? undefined : projectPaths.get(key);
if (cached) return cached;
const source = await runJson(
["api", `projects/${projectId === ":fullpath" ? projectId : encodeURIComponent(projectId)}`],
{ cwd },
z.object({
id: z.number().optional(),
path_with_namespace: z.string(),
ssh_url_to_repo: z.string().nullable().optional(),
http_url_to_repo: z.string().nullable().optional(),
}),
);
projectPaths.set(key, source);
return source;
}
async function resolveExpectedProjectId(input: {
cwd: string;
expectedPath: string;
mergeRequests: GitLabMergeRequest[];
force: boolean;
endpoint?: import("./forge-service.js").HeadRepositoryEndpoint;
}): Promise<number | null> {
const remote = await resolveRemoteUrl(input.cwd);
const originPath = remote ? parseGitRemoteLocation(remote)?.path : null;
const targetId =
!originPath || input.expectedPath.toLowerCase() === originPath.toLowerCase()
? input.mergeRequests.find(
(mr) =>
extractProjectPath(mr.references?.full)?.toLowerCase() ===
input.expectedPath.toLowerCase(),
)?.target_project_id
: undefined;
if (!input.endpoint && targetId != null) return targetId;
const project = await readProject(input.cwd, input.expectedPath, input.force);
if (
!matchesHeadRepositoryEndpoint(input.endpoint, [
project.ssh_url_to_repo,
project.http_url_to_repo,
])
)
return null;
return project.id ?? null;
}
async function resolveCurrentMergeRequest(
cwd: string,
headRef: string,
headSha?: string,
input: Parameters<ForgeService["getCurrentPullRequestStatus"]>[0],
): Promise<GitLabMergeRequest | null> {
const { cwd, headRef, headRepositoryEndpoint } = input;
const force = input.force === true;
const mergeRequests = await listMergeRequestsBySourceBranch(cwd, headRef);
const candidates = mergeRequests.filter((mr) => mr.source_branch === headRef);
if (mergeRequests.length === 0) return null;
const expectedPath = await resolveExpectedHeadProject(input);
if (!expectedPath) return null;
const expectedProjectId = await resolveExpectedProjectId({
cwd,
expectedPath,
mergeRequests,
force,
endpoint: headRepositoryEndpoint,
});
if (expectedProjectId == null) return null;
const candidates: GitLabMergeRequest[] = [];
for (const mr of mergeRequests) {
if (
mr.source_branch !== headRef ||
mr.source_project_id == null ||
mr.target_project_id == null
)
continue;
if (mr.source_project_id === expectedProjectId) {
if (mapMergeRequestState(mr.state) === "open") return viewMergeRequest(cwd, String(mr.iid));
candidates.push(mr);
}
}
candidates.sort(
(a, b) =>
parseGitLabTimestamp(b.updated_at) - parseGitLabTimestamp(a.updated_at) || b.iid - a.iid,
);
const match =
candidates.find((mr) => mapMergeRequestState(mr.state) === "open") ??
candidates.find(
(mr) =>
mapMergeRequestState(mr.state) !== "open" && headSha !== undefined && mr.sha === headSha,
) ??
null;
candidates.find((mr) => mapMergeRequestState(mr.state) === "open") ?? candidates[0] ?? null;
if (mergeRequests.length >= 100 && (!match || mapMergeRequestState(match.state) !== "open"))
throw new ForgeDiscoveryIncompleteError(
"GitLab merge request discovery exceeded its candidate window",
);
return match ? viewMergeRequest(cwd, String(match.iid)) : null;
}
@@ -1151,7 +1258,7 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
async getCurrentPullRequestStatus(input): Promise<CurrentPullRequestStatus | null> {
try {
const mr = await resolveCurrentMergeRequest(input.cwd, input.headRef, input.headSha);
const mr = await resolveCurrentMergeRequest(input);
if (!mr) {
return null;
}
@@ -1448,6 +1555,8 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
return { success: true };
},
invalidate(_input: { cwd: string }): void {},
invalidate(_input: { cwd: string }): void {
projectPaths.clear();
},
};
}
@@ -1623,13 +1623,26 @@ it.each([1, 4])(
async (version) => {
const paseoHome = createTemporaryHome();
const old = await leaveWorkerWithTerminal(paseoHome, version);
const [record] = await workerRecords(paseoHome);
const program = record!.terminals.find((entry) => entry.id === old.terminalId)!;
const inspector = createTerminalWorkerProcessInspector();
manager = await startRealWorkerManager({ paseoHome });
await waitForCondition(
() => !isProcessAlive(old.workerPid) && !isProcessAlive(old.programPid),
10000,
);
expect(await readUnconfirmedTerminalCleanup(paseoHome)).toEqual([]);
// Startup launches a replacement worker after cleanup. Either old PID may already
// belong to another process, so verify that the recorded birth identities are gone.
for (const original of [{ pid: record!.pid, ...record!.identity }, program]) {
await expect
.poll(
async () =>
!isProcessAlive(original.pid) ||
classifyTerminalProgramProcess(original, await inspector.inspect(original.pid))
.status === "unrelated",
{ timeout: 10000 },
)
.toBe(true);
}
expect(manager.getTerminal(old.terminalId)).toBeUndefined();
const workers = await workerRecords(paseoHome);
expect(workers).toHaveLength(1);
+246 -1
View File
@@ -47,10 +47,11 @@ import {
isDescendantPath,
warmCheckoutShortstatInBackground,
} from "./checkout-git.js";
import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js";
import { runGitCommand, startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.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 { ForgeDiscoveryIncompleteError } from "../services/forge-service.js";
import {
TeaAuthenticationError,
TeaCliMissingError,
@@ -178,6 +179,10 @@ interface RequestedPullRequestTarget {
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
headRepositoryHost?: string;
headRepositoryEndpoint?: import("../services/forge-service.js").HeadRepositoryEndpoint;
changeRequestNumber?: number;
}
interface RecordingPullRequestTargetsOptions {
@@ -194,6 +199,12 @@ function createGitHubServiceRecordingPullRequestTargets(
headRef: request.headRef,
...(request.headSha ? { headSha: request.headSha } : {}),
...(request.headRepositoryOwner ? { headRepositoryOwner: request.headRepositoryOwner } : {}),
...(request.headRepositoryPath ? { headRepositoryPath: request.headRepositoryPath } : {}),
...(request.headRepositoryHost ? { headRepositoryHost: request.headRepositoryHost } : {}),
...(request.headRepositoryEndpoint
? { headRepositoryEndpoint: request.headRepositoryEndpoint }
: {}),
...(request.changeRequestNumber ? { changeRequestNumber: request.changeRequestNumber } : {}),
});
return createPullRequestStatus({
...options.statusOverrides,
@@ -1085,6 +1096,9 @@ const x = 1;
}
expect(status.aheadOfOrigin).toBeNull();
expect(status.behindOfOrigin).toBeNull();
const facts = await getCheckoutSnapshotFacts(repoDir);
expect(facts.isGit && facts.upstreamKnown).toBe(true);
expect(facts.isGit && facts.upstreamStatus).toBeNull();
});
it("does not report full history as unpushed for fresh no-track Paseo worktrees", async () => {
@@ -3205,6 +3219,124 @@ const x = 1;
});
});
it("carries the full nested head repository path from the tracking remote", async () => {
execFileSync(
"git",
["remote", "add", "origin", "https://gitlab.example.com/base/project.git"],
{ cwd: repoDir },
);
execFileSync(
"git",
["remote", "add", "fork", "https://gitlab.example.com/team/subgroup/renamed.git"],
{ cwd: repoDir },
);
execFileSync("git", ["checkout", "-b", "topic"], { cwd: repoDir });
execFileSync("git", ["config", "branch.topic.remote", "fork"], { cwd: repoDir });
execFileSync("git", ["config", "branch.topic.merge", "refs/heads/topic"], { cwd: repoDir });
expect(await readPullRequestLookupTargetFromFacts(repoDir, paseoHome)).toMatchObject({
headRef: "topic",
headRepositoryPath: "team/subgroup/renamed",
});
});
it("does not associate a tracked branch from a different forge host", async () => {
execFileSync("git", ["remote", "add", "origin", "https://github.com/acme/project.git"], {
cwd: repoDir,
});
execFileSync("git", ["remote", "add", "other", "https://gitea.example.com/acme/project.git"], {
cwd: repoDir,
});
execFileSync("git", ["checkout", "-b", "topic"], { cwd: repoDir });
execFileSync("git", ["config", "branch.topic.remote", "other"], { cwd: repoDir });
execFileSync("git", ["config", "branch.topic.merge", "refs/heads/topic"], { cwd: repoDir });
const targets: RequestedPullRequestTarget[] = [];
const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets: targets });
const facts = await getCheckoutSnapshotFacts(repoDir, { paseoHome });
expect(
(await getPullRequestStatus(repoDir, forge, undefined, { facts, paseoHome })).status,
).toBeNull();
expect(targets).toEqual([]);
});
it.each(["https://forge.example:4000/acme/project.git"])(
"does not infer a forge instance for an unverified endpoint %s",
async (headRemote) => {
execFileSync(
"git",
["remote", "add", "origin", "https://forge.example:3000/acme/project.git"],
{ cwd: repoDir },
);
execFileSync("git", ["remote", "add", "other", headRemote], { cwd: repoDir });
execFileSync("git", ["checkout", "-b", "topic"], { cwd: repoDir });
execFileSync("git", ["config", "branch.topic.remote", "other"], { cwd: repoDir });
execFileSync("git", ["config", "branch.topic.merge", "refs/heads/topic"], { cwd: repoDir });
const facts = await getCheckoutSnapshotFacts(repoDir, {
paseoHome,
resolveSshHostname: async (host) => host,
});
const targets: RequestedPullRequestTarget[] = [];
const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets: targets });
expect(
(await getPullRequestStatus(repoDir, forge, undefined, { facts, paseoHome })).status,
).toBeNull();
expect(targets).toEqual([]);
},
);
it("passes a normalized mixed-transport endpoint to the forge for proof", async () => {
execFileSync(
"git",
["remote", "add", "origin", "https://forge.example:3000/acme/project.git"],
{ cwd: repoDir },
);
execFileSync("git", ["remote", "add", "other", "ssh://git@work-alias:2222/acme/project.git"], {
cwd: repoDir,
});
execFileSync("git", ["checkout", "-b", "topic"], { cwd: repoDir });
execFileSync("git", ["config", "branch.topic.remote", "other"], { cwd: repoDir });
execFileSync("git", ["config", "branch.topic.merge", "refs/heads/topic"], { cwd: repoDir });
const facts = await getCheckoutSnapshotFacts(repoDir, {
paseoHome,
resolveSshHostname: async () => "forge.example",
});
const targets: RequestedPullRequestTarget[] = [];
const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets: targets });
await getPullRequestStatus(repoDir, forge, undefined, { facts, paseoHome });
expect(targets).toEqual([
expect.objectContaining({
headRepositoryEndpoint: {
transport: "ssh",
host: "forge.example",
port: "2222",
path: "acme/project",
},
}),
]);
});
it("recognizes an SSH alias for the same forge host", async () => {
execFileSync("git", ["remote", "add", "origin", "https://github.com/acme/project.git"], {
cwd: repoDir,
});
execFileSync("git", ["remote", "add", "fork", "git@work-alias:acme/project.git"], {
cwd: repoDir,
});
execFileSync("git", ["checkout", "-b", "topic"], { cwd: repoDir });
execFileSync("git", ["config", "branch.topic.remote", "fork"], { cwd: repoDir });
execFileSync("git", ["config", "branch.topic.merge", "refs/heads/topic"], { cwd: repoDir });
const context = {
paseoHome,
resolveSshHostname: async (host: string) => (host === "work-alias" ? "github.com" : host),
};
const facts = await getCheckoutSnapshotFacts(repoDir, context);
const targets: RequestedPullRequestTarget[] = [];
const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets: targets });
expect(
(await getPullRequestStatus(repoDir, forge, undefined, { ...context, facts })).status,
).not.toBeNull();
expect(targets).toHaveLength(1);
});
it.each([
"git@github.com:contributor/paseo.git",
"https://github.com/contributor/paseo.git",
@@ -3231,6 +3363,8 @@ const x = 1;
expect(await readPullRequestLookupTargetFromFacts(repoDir, paseoHome)).toEqual({
headRef: "topic",
headRepositoryOwner: "contributor",
headRepositoryPath: "contributor/paseo",
headRepositoryHost: "github.com:",
headSha,
});
});
@@ -3472,6 +3606,22 @@ const x = 1;
expect(callCount).toBe(1);
});
it("distinguishes a failed upstream read from a successful missing upstream", async () => {
const known = await getCheckoutSnapshotFacts(repoDir, { paseoHome });
expect(known.isGit && known.upstreamKnown).toBe(true);
expect(known.isGit && known.upstreamStatus).toBeNull();
const failed = await getCheckoutSnapshotFacts(repoDir, {
paseoHome,
runGitCommand: async (args, options) => {
if (args[0] === "for-each-ref" && args.some((arg) => arg.includes("%(upstream:track")))
throw new Error("upstream unavailable");
return runGitCommand(args, options);
},
});
expect(failed.isGit && failed.upstreamKnown).toBe(false);
expect(failed.isGit && failed.upstreamStatus).toBeNull();
});
it("does not reuse a PR status cache entry after HEAD changes on the same branch", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
@@ -3499,6 +3649,29 @@ const x = 1;
expect(requestedShas[0]).not.toBe(requestedShas[1]);
});
it("does not reuse a PR status after changing branch or head repository at the same commit", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://github.com/acme/first.git"], {
cwd: repoDir,
});
const targets: RequestedPullRequestTarget[] = [];
const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets: targets });
await getPullRequestStatus(repoDir, forge);
execFileSync("git", ["checkout", "-b", "other"], { cwd: repoDir });
await getPullRequestStatus(repoDir, forge);
execFileSync("git", ["remote", "set-url", "origin", "https://github.com/acme/second.git"], {
cwd: repoDir,
});
await getPullRequestStatus(repoDir, forge);
expect(targets.map(({ headRef, headRepositoryPath }) => [headRef, headRepositoryPath])).toEqual(
[
["feature", "acme/first"],
["other", "acme/first"],
["other", "acme/second"],
],
);
});
it("passes forced PR status reads through to the GitHub service", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
@@ -3627,6 +3800,78 @@ const x = 1;
}
});
it("preserves known status on incomplete discovery but refuses a forced archive read", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://gitea.example.com/acme/repo.git"], {
cwd: repoDir,
});
__setPullRequestStatusCacheTtlForTests(50);
try {
let calls = 0;
const service = createGitHubServiceForStatus(null);
service.getCurrentPullRequestStatus = async () => {
if (calls++ === 0) return createPullRequestStatus();
throw new ForgeDiscoveryIncompleteError("candidate window full");
};
const fresh = await getPullRequestStatus(repoDir, service);
await sleep(80);
expect(await getPullRequestStatus(repoDir, service)).toEqual(fresh);
await expect(
getPullRequestStatus(repoDir, service, { force: true, reason: "archive" }),
).rejects.toBeInstanceOf(ForgeDiscoveryIncompleteError);
} finally {
__resetPullRequestStatusCacheForTests();
}
});
it("passes the last observed request to later status reads for revalidation", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://gitea.example.com/acme/repo.git"], {
cwd: repoDir,
});
__setPullRequestStatusCacheTtlForTests(50);
try {
const seen: Array<{ number?: number; url: string; state: string } | null | undefined> = [];
const service = createGitHubServiceForStatus(null);
service.getCurrentPullRequestStatus = async (request) => {
seen.push(request.previousPullRequest);
return createPullRequestStatus({
number: 2,
url: "https://gitea.example.com/acme/repo/pulls/2",
});
};
await getPullRequestStatus(repoDir, service);
await sleep(80);
await getPullRequestStatus(repoDir, service);
await getPullRequestStatus(repoDir, service, { force: true, reason: "archive" });
expect(seen[0]).toBeUndefined();
expect(seen.slice(1)).toEqual([
expect.objectContaining({ number: 2, state: "open" }),
expect.objectContaining({ number: 2, state: "open" }),
]);
} finally {
__resetPullRequestStatusCacheForTests();
}
});
it("uses the workspace association instead of a standalone cached result", 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);
const open = { number: 2, url: "https://gitea.example.com/acme/repo/pulls/2", state: "open" };
service.getCurrentPullRequestStatus = async (input) =>
createPullRequestStatus(
input.previousPullRequest?.number === 2 ? open : { number: 1, state: "merged" },
);
expect((await getPullRequestStatus(repoDir, service)).status?.number).toBe(1);
expect(
(await getPullRequestStatus(repoDir, service, { previousPullRequest: open })).status?.number,
).toBe(2);
expect((await getPullRequestStatus(repoDir, service)).status?.number).toBe(1);
});
it("does not use stale PR status fallback for forced GitHub errors", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
+236 -68
View File
@@ -14,7 +14,7 @@ import { tmpdir } from "node:os";
import { setImmediate } from "node:timers/promises";
import { TTLCache } from "@isaacs/ttlcache";
import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages";
import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import { maxSecuredPlaintextByteLength } from "@getpaseo/relay";
import type { Logger } from "pino";
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
@@ -23,8 +23,8 @@ import {
parseAndHighlightDiff,
parseDiff,
} from "../server/utils/diff-highlighter.js";
import { parseGitHubRepoFromRemote } from "../server/workspace-git-metadata.js";
import { createGitHubService } from "../services/github-service.js";
import { ForgeDiscoveryIncompleteError } from "../services/forge-service.js";
import type {
CurrentPullRequestStatus,
ForgeAuthState,
@@ -39,6 +39,7 @@ import {
ForgeCommandError,
} from "../services/forge-cli-command.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { resolveSshHostname } from "./ssh-hostname.js";
import { runGitCommand, type RunGitCommand } from "./run-git-command.js";
import { readGitFileContents } from "./git-file-contents.js";
import { unquoteGitPath } from "./git-quoted-path.js";
@@ -97,12 +98,19 @@ const shortstatInFlight = new Map<string, Promise<CheckoutShortstat | null>>();
interface CheckoutReadCacheOptions {
force?: boolean;
reason?: string;
previousPullRequest?: { number?: number; url: string; state: string } | null;
}
interface PullRequestStatusLookupTarget {
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
headRepositoryHost?: string;
headRepositoryEndpoint?: import("../services/forge-service.js").HeadRepositoryEndpoint;
headRepositoryRemoteUrl?: string;
forgeHost?: string;
changeRequestNumber?: number;
}
interface PullRequestLookupTargetBranchConfig {
@@ -151,8 +159,22 @@ function createShortstatCache(ttlMs: number) {
});
}
function getPullRequestStatusCacheKey(cwd: string, headSha: string | null): string {
return `${resolve(cwd)}\u0000${headSha ?? ""}`;
function getPullRequestStatusCacheKey(
cwd: string,
remoteUrl: string | null,
target: PullRequestStatusLookupTarget,
): string {
return JSON.stringify([
resolve(cwd),
remoteUrl,
target.headRef,
target.headSha ?? null,
target.headRepositoryPath ?? null,
target.headRepositoryHost ?? null,
target.headRepositoryEndpoint ?? null,
target.headRepositoryOwner ?? null,
target.changeRequestNumber ?? null,
]);
}
function rememberPullRequestStatus(cacheKey: string, status: PullRequestStatusResult): void {
@@ -944,6 +966,7 @@ export interface CheckoutContext {
logger?: Pick<Logger, "trace" | "warn">;
facts?: CheckoutSnapshotFacts | null;
runGitCommand?: RunGitCommand;
resolveSshHostname?: typeof resolveSshHostname;
}
export type CheckoutSnapshotFacts =
@@ -965,6 +988,7 @@ export type CheckoutSnapshotFacts =
branchRemoteName: string | null;
branchMergeRef: string | null;
upstreamStatus: UpstreamStatus | null;
upstreamKnown?: boolean;
pullRequestLookupTarget: PullRequestStatusLookupTarget | null;
};
@@ -1484,25 +1508,6 @@ async function resolvePullRequestStatusLookupTarget(
branchMergeRef = await getGitConfigValue(cwd, `branch.${currentBranch}.merge`, context);
}
const localBranchTarget = buildPullRequestLookupTargetFromBranchConfig({
currentBranch,
branchRemoteName,
branchMergeRef,
branchRemoteUrl: null,
originRemoteUrl: null,
resolvedBaseRef: null,
});
if (localBranchTarget.headRef === currentBranch) {
const pushTarget = await resolvePullRequestLookupTargetFromPushConfig(
cwd,
currentBranch,
null,
null,
context,
);
return pushTarget ?? localBranchTarget;
}
const [branchRemoteUrl, originRemoteUrl, resolvedBaseRef] = await Promise.all([
branchRemoteName ? resolveGitRemoteUrl({ cwd, remote: branchRemoteName, context }) : null,
getGitConfigValue(cwd, "remote.origin.url", context),
@@ -1822,7 +1827,7 @@ async function getUpstreamStatus(
cwd: string,
currentBranch: string,
context?: CheckoutContext,
): Promise<UpstreamStatus | null> {
): Promise<{ status: UpstreamStatus | null; known: boolean }> {
try {
const { stdout } = await getRunGitCommand(context)(
[
@@ -1834,13 +1839,13 @@ async function getUpstreamStatus(
);
const [ref = "", track = ""] = stdout.trim().split("\0", 2);
if (!ref || track === "gone") {
return null;
return { status: null, known: true };
}
const ahead = Number.parseInt(track.match(/ahead (\d+)/)?.[1] ?? "0", 10);
const behind = Number.parseInt(track.match(/behind (\d+)/)?.[1] ?? "0", 10);
return { ref, aheadBehind: { ahead, behind } };
return { status: { ref, aheadBehind: { ahead, behind } }, known: true };
} catch {
return null;
return { status: null, known: false };
}
}
@@ -1889,12 +1894,14 @@ function buildPullRequestLookupTargetFromBranchConfig(
): PullRequestStatusLookupTarget {
const trackedHeadRef = parseBranchMergeHeadRef(input.branchMergeRef);
if (!input.branchRemoteName || !trackedHeadRef) {
return { headRef: input.currentBranch };
const origin = parseRepositoryIdentityFromRemote(input.originRemoteUrl);
return { headRef: input.currentBranch, ...(origin ? { headRepositoryPath: origin } : {}) };
}
const remoteRepo = parseRepositoryIdentityFromRemote(input.branchRemoteUrl);
const remoteHost = getRepositoryHost(input.branchRemoteUrl);
const originRepo = parseRepositoryIdentityFromRemote(input.originRemoteUrl);
const isSameRepo = areSameGitHubRepository(remoteRepo, originRepo);
const isSameRepo = areSameRepositoryPath(remoteRepo, originRepo);
const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null;
const normalizedBaseRef = input.resolvedBaseRef ? branchNameFromRef(input.resolvedBaseRef) : null;
if (
@@ -1909,12 +1916,83 @@ function buildPullRequestLookupTargetFromBranchConfig(
}
if (isSameRepo) {
return { headRef: trackedHeadRef };
return {
headRef: trackedHeadRef,
...(remoteRepo ? { headRepositoryPath: remoteRepo } : {}),
...(remoteHost ? { headRepositoryHost: remoteHost } : {}),
...(input.branchRemoteUrl ? { headRepositoryRemoteUrl: input.branchRemoteUrl } : {}),
};
}
return {
headRef: trackedHeadRef,
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
...(remoteRepo ? { headRepositoryPath: remoteRepo } : {}),
...(remoteHost ? { headRepositoryHost: remoteHost } : {}),
...(input.branchRemoteUrl ? { headRepositoryRemoteUrl: input.branchRemoteUrl } : {}),
};
}
function getRepositoryHost(remoteUrl: string | null): string | null {
const location = remoteUrl ? parseGitRemoteLocation(remoteUrl) : null;
return location ? `${location.host}:${location.port ?? ""}` : null;
}
export function isPullRequestTargetOnForge(
remoteUrl: string | null,
target: PullRequestStatusLookupTarget,
): boolean {
return (
!target.headRepositoryHost ||
target.headRepositoryHost === (target.forgeHost ?? getRepositoryHost(remoteUrl)) ||
Boolean(target.headRepositoryEndpoint)
);
}
async function resolveRepositoryHost(
remoteUrl: string | null,
resolveHost: typeof resolveSshHostname,
): Promise<string | null> {
const location = remoteUrl ? parseGitRemoteLocation(remoteUrl) : null;
if (!location) return null;
const host =
location.transport === "ssh" || location.transport === "scp"
? ((await resolveHost(location.host)) ?? location.host)
: location.host;
return `${host}:${location.port ?? ""}`;
}
async function normalizePullRequestTargetHost(
target: PullRequestStatusLookupTarget,
remoteUrl: string | null,
context?: CheckoutContext,
): Promise<PullRequestStatusLookupTarget> {
if (!target.headRepositoryRemoteUrl) return target;
const resolveHost = context?.resolveSshHostname ?? resolveSshHostname;
const [headRepositoryHost, forgeHost] = await Promise.all([
resolveRepositoryHost(target.headRepositoryRemoteUrl, resolveHost),
resolveRepositoryHost(remoteUrl, resolveHost),
]);
const { headRepositoryRemoteUrl: _headRepositoryRemoteUrl, ...normalized } = target;
const head = parseGitRemoteLocation(target.headRepositoryRemoteUrl);
const origin = remoteUrl ? parseGitRemoteLocation(remoteUrl) : null;
const headHost = headRepositoryHost?.split(":")[0];
const originHost = forgeHost?.split(":")[0];
const isSsh = (transport: string) => transport === "ssh" || transport === "scp";
const endpoint =
head &&
origin &&
headHost &&
headHost === originHost &&
headRepositoryHost !== forgeHost &&
isSsh(head.transport) !== isSsh(origin.transport)
? { ...head, host: headHost }
: undefined;
return {
...normalized,
...(endpoint ? { headRepositoryEndpoint: endpoint } : {}),
...(headRepositoryHost ? { headRepositoryHost } : {}),
...(forgeHost && forgeHost !== getRepositoryHost(remoteUrl) ? { forgeHost } : {}),
};
}
@@ -1923,10 +2001,10 @@ function parseRepositoryIdentityFromRemote(remoteUrl: string | null): string | n
return null;
}
const location = parseGitRemoteLocation(remoteUrl);
return location ? (parseGitHubRemoteIdentity(location.path)?.repo ?? null) : null;
return location?.path ?? null;
}
function areSameGitHubRepository(left: string | null, right: string | null): boolean {
function areSameRepositoryPath(left: string | null, right: string | null): boolean {
return Boolean(left && right && left.toLowerCase() === right.toLowerCase());
}
@@ -1949,16 +2027,17 @@ function doesLocalBranchNameIdentifyTrackedHead(
function buildPullRequestLookupTargetFromPushConfig(
input: PullRequestLookupTargetPushConfig,
): PullRequestStatusLookupTarget | null {
const pushedHeadRef = parseHeadPushRefspec(input.pushRefspec);
if (!input.pushRemoteName || !pushedHeadRef || pushedHeadRef === input.currentBranch) {
const configuredHeadRef = parseHeadPushRefspec(input.pushRefspec);
if (input.pushRefspec && !configuredHeadRef) return null;
const pushedHeadRef = configuredHeadRef ?? input.currentBranch;
if (!input.pushRemoteName) {
return null;
}
const remoteRepo = input.pushRemoteUrl ? parseGitHubRepoFromRemote(input.pushRemoteUrl) : null;
const originRepo = input.originRemoteUrl
? parseGitHubRepoFromRemote(input.originRemoteUrl)
: null;
const isSameRepo = areSameGitHubRepository(remoteRepo, originRepo);
const remoteRepo = parseRepositoryIdentityFromRemote(input.pushRemoteUrl);
const remoteHost = getRepositoryHost(input.pushRemoteUrl);
const originRepo = parseRepositoryIdentityFromRemote(input.originRemoteUrl);
const isSameRepo = areSameRepositoryPath(remoteRepo, originRepo);
const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null;
const normalizedBaseRef = input.resolvedBaseRef ? branchNameFromRef(input.resolvedBaseRef) : null;
if (pushedHeadRef === normalizedBaseRef && !headRepositoryOwner) {
@@ -1968,6 +2047,9 @@ function buildPullRequestLookupTargetFromPushConfig(
return {
headRef: pushedHeadRef,
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
...(remoteRepo ? { headRepositoryPath: remoteRepo } : {}),
...(remoteHost ? { headRepositoryHost: remoteHost } : {}),
...(input.pushRemoteUrl ? { headRepositoryRemoteUrl: input.pushRemoteUrl } : {}),
};
}
@@ -1982,6 +2064,7 @@ function buildPullRequestLookupTargetFromMetadata(
return {
headRef: target.headRef,
...(target.headRepositoryOwner ? { headRepositoryOwner: target.headRepositoryOwner } : {}),
...(target.changeRequestNumber ? { changeRequestNumber: target.changeRequestNumber } : {}),
};
}
@@ -2053,7 +2136,7 @@ async function resolvePullRequestLookupTargetFromPushConfig(
});
}
async function resolveFactsPullRequestLookupTarget(input: {
interface PullRequestFactsLookupInput {
cwd: string;
inspected: CheckoutInspectionContext;
metadata: PaseoWorktreeMetadata | null;
@@ -2062,13 +2145,56 @@ async function resolveFactsPullRequestLookupTarget(input: {
branchRemoteUrl: string | null;
resolvedBaseRef: string | null;
context?: CheckoutContext;
}): Promise<PullRequestStatusLookupTarget | null> {
}
async function resolveMetadataPullRequestTarget(
input: PullRequestFactsLookupInput,
metadataTarget: PullRequestStatusLookupTarget,
currentBranch: string,
): Promise<PullRequestStatusLookupTarget> {
const { cwd, inspected, context } = input;
const pushTarget = await resolvePullRequestLookupTargetFromPushConfig(
cwd,
currentBranch,
inspected.remoteUrl,
input.resolvedBaseRef,
context,
);
if (pushTarget?.headRef === metadataTarget.headRef && pushTarget.headRepositoryPath) {
return { ...pushTarget, changeRequestNumber: metadataTarget.changeRequestNumber };
}
const trackedHeadMatches =
parseBranchMergeHeadRef(input.branchMergeRef) === metadataTarget.headRef;
let remoteUrl = trackedHeadMatches
? (input.branchRemoteUrl ?? inspected.remoteUrl)
: inspected.remoteUrl;
if (metadataTarget.headRepositoryOwner) {
remoteUrl =
!metadataTarget.changeRequestNumber && trackedHeadMatches ? input.branchRemoteUrl : null;
}
const remotePath = parseRepositoryIdentityFromRemote(remoteUrl);
const remoteHost = getRepositoryHost(remoteUrl);
return remotePath &&
(!metadataTarget.headRepositoryOwner ||
remotePath.split("/")[0] === metadataTarget.headRepositoryOwner)
? {
...metadataTarget,
headRepositoryPath: remotePath,
...(remoteHost ? { headRepositoryHost: remoteHost } : {}),
...(remoteUrl ? { headRepositoryRemoteUrl: remoteUrl } : {}),
}
: metadataTarget;
}
async function resolveFactsPullRequestLookupTarget(
input: PullRequestFactsLookupInput,
): Promise<PullRequestStatusLookupTarget | null> {
const { cwd, inspected, metadata, context } = input;
const metadataTarget = inspected.currentBranch
? buildPullRequestLookupTargetFromMetadata(metadata, inspected.currentBranch)
: null;
if (metadataTarget) {
return metadataTarget;
if (metadataTarget && inspected.currentBranch) {
return resolveMetadataPullRequestTarget(input, metadataTarget, inspected.currentBranch);
}
let target = buildInitialPullRequestLookupTarget({
@@ -2093,6 +2219,10 @@ async function resolveFactsPullRequestLookupTarget(input: {
context,
)) ?? target;
}
if (target && !target.headRepositoryPath && !target.headRepositoryOwner) {
const origin = parseRepositoryIdentityFromRemote(inspected.remoteUrl);
if (origin) target = { ...target, headRepositoryPath: origin };
}
return target;
}
@@ -2135,7 +2265,7 @@ export async function getCheckoutSnapshotFacts(
let branchRemoteUrl: string | null = null;
const upstreamStatusPromise = inspected.currentBranch
? getUpstreamStatus(cwd, inspected.currentBranch, context)
: Promise.resolve(null);
: Promise.resolve({ status: null, known: false });
if (inspected.currentBranch) {
branchRemoteName = await getGitConfigValue(
cwd,
@@ -2166,7 +2296,14 @@ export async function getCheckoutSnapshotFacts(
pullRequestLookupTarget,
context,
);
const upstreamStatus = await upstreamStatusPromise;
if (pullRequestLookupTarget) {
pullRequestLookupTarget = await normalizePullRequestTargetHost(
pullRequestLookupTarget,
inspected.remoteUrl,
context,
);
}
const upstream = await upstreamStatusPromise;
return {
isGit: true,
@@ -2182,7 +2319,8 @@ export async function getCheckoutSnapshotFacts(
comparisonBaseRef,
branchRemoteName,
branchMergeRef,
upstreamStatus,
upstreamStatus: upstream.status,
upstreamKnown: upstream.known,
pullRequestLookupTarget,
};
}
@@ -4307,14 +4445,49 @@ export async function createPullRequest(
return result;
}
async function resolvePullRequestStatusRead(
cwd: string,
context?: CheckoutContext,
): Promise<{ target: PullRequestStatusLookupTarget; remoteUrl: string | null } | null> {
if (!context?.facts?.isGit) await requireGitRepo(cwd, context);
const head = context?.facts?.isGit
? context.facts.currentBranch
: await getCurrentBranch(cwd, context);
if (!head) return null;
const headSha = await getCurrentHeadSha(cwd, context);
const resolvedTarget = await resolvePullRequestStatusLookupTarget(cwd, head, context);
let target = headSha && !resolvedTarget.headSha ? { ...resolvedTarget, headSha } : resolvedTarget;
const remoteUrl = context?.facts?.isGit
? context.facts.remoteUrl
: await getOriginRemoteUrl(cwd, context);
if (!context?.facts?.isGit)
target = await normalizePullRequestTargetHost(target, remoteUrl, context);
return { target, remoteUrl };
}
export async function getPullRequestStatus(
cwd: string,
forgeService: ForgeService = createGitHubService(),
options?: CheckoutReadCacheOptions,
context?: CheckoutContext,
): Promise<PullRequestStatusResult> {
const headSha = await getCurrentHeadSha(cwd, context);
const cacheKey = getPullRequestStatusCacheKey(cwd, headSha);
const unavailable = getUnavailablePullRequestStatus(context?.facts);
if (unavailable) return unavailable;
const read = await resolvePullRequestStatusRead(cwd, context);
if (!read) return buildPullRequestStatusResult(null, "no_remote");
const { target, remoteUrl } = read;
if (!isPullRequestTargetOnForge(remoteUrl, target))
return buildPullRequestStatusResult(null, "authenticated");
if (options && "previousPullRequest" in options) {
return getPullRequestStatusUncached(
cwd,
forgeService,
target,
options,
options.previousPullRequest,
);
}
const cacheKey = getPullRequestStatusCacheKey(cwd, remoteUrl, target);
if (!options?.force) {
const cached = pullRequestStatusCache.get(cacheKey);
if (cached) {
@@ -4327,14 +4500,23 @@ export async function getPullRequestStatus(
}
}
const lookup = getPullRequestStatusUncached(cwd, forgeService, options, context, headSha)
const lookup = getPullRequestStatusUncached(
cwd,
forgeService,
target,
options,
lastSuccessfulPullRequestStatus.get(cacheKey)?.status,
)
.then((status) => {
pullRequestStatusCache.set(cacheKey, status);
rememberPullRequestStatus(cacheKey, status);
return status;
})
.catch((error) => {
if (!options?.force && error instanceof ForgeCommandError) {
if (
!options?.force &&
(error instanceof ForgeCommandError || error instanceof ForgeDiscoveryIncompleteError)
) {
const stale = lastSuccessfulPullRequestStatus.get(cacheKey);
if (stale) {
return stale;
@@ -4353,27 +4535,11 @@ export async function getPullRequestStatus(
async function getPullRequestStatusUncached(
cwd: string,
forgeService: ForgeService,
lookupTarget: PullRequestStatusLookupTarget,
options?: CheckoutReadCacheOptions,
context?: CheckoutContext,
headSha?: string | null,
previousPullRequest?: { number?: number; url: string; state: string } | null,
): Promise<PullRequestStatusResult> {
const unavailable = getUnavailablePullRequestStatus(context?.facts);
if (unavailable) return unavailable;
if (!context?.facts?.isGit) {
await requireGitRepo(cwd, context);
}
const head = context?.facts?.isGit
? context.facts.currentBranch
: await getCurrentBranch(cwd, context);
if (!head) {
return buildPullRequestStatusResult(null, "no_remote");
}
try {
const resolvedLookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head, context);
const lookupTarget =
headSha && !resolvedLookupTarget.headSha
? { ...resolvedLookupTarget, headSha }
: resolvedLookupTarget;
let status: CurrentPullRequestStatus | null;
if (options?.force) {
const reason = options.reason;
@@ -4383,6 +4549,7 @@ async function getPullRequestStatusUncached(
status = await forgeService.getCurrentPullRequestStatus({
cwd,
...lookupTarget,
...(previousPullRequest ? { previousPullRequest } : {}),
force: true,
reason,
});
@@ -4390,6 +4557,7 @@ async function getPullRequestStatusUncached(
status = await forgeService.getCurrentPullRequestStatus({
cwd,
...lookupTarget,
...(previousPullRequest ? { previousPullRequest } : {}),
reason: options?.reason,
});
}
+40 -1
View File
@@ -6,7 +6,7 @@ const desktopName =
async function openHomepage(page: Page) {
await page.setViewportSize({ width: 1512, height: 930 });
await page.goto("/");
await expect(page.getByRole("button", { name: "Build", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Parallel agents", exact: true })).toBeVisible();
}
async function viewPhones(page: Page) {
@@ -59,3 +59,42 @@ test("desktop mockup corners shrink in proportion on a phone", async ({ page })
await resizeToPhone(page);
await expect.poll(() => cornerProportion(mockup)).toBeCloseTo(desktopCorner, 3);
});
/** Opacity as rendered: the element's own times every ancestor's. */
async function renderedOpacity(locator: Locator) {
return locator.evaluate((element) => {
let opacity = 1;
for (let node: Element | null = element; node; node = node.parentElement) {
opacity *= Number.parseFloat(getComputedStyle(node).opacity);
}
return opacity;
});
}
test("the plugin demo plays through to the panel the agent built", async ({ page }) => {
await openHomepage(page);
const builtPanelRow = page.getByText("Add Gruvbox theme", { exact: true });
expect(await renderedOpacity(builtPanelRow)).toBe(0);
// Hydration can reset the scroll position, so keep the demo in view until it finishes.
const heading = page.getByRole("heading", { name: "Ask Paseo to change Paseo" });
await expect(async () => {
await heading.scrollIntoViewIfNeeded();
expect(await renderedOpacity(builtPanelRow)).toBe(1);
}).toPass({ timeout: 15_000 });
expect(await renderedOpacity(page.getByText("Review queue is in your sidebar."))).toBe(1);
});
test("the plugin demo shows its finished state when motion is reduced", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await openHomepage(page);
const builtPanelRow = page.getByText("Add Gruvbox theme", { exact: true });
const panel = builtPanelRow.locator("xpath=ancestor::div[contains(@class, 'md:border-l')]");
const heading = page.getByRole("heading", { name: "Ask Paseo to change Paseo" });
await expect(async () => {
await heading.scrollIntoViewIfNeeded();
expect(await renderedOpacity(builtPanelRow)).toBe(1);
}).toPass({ timeout: 2_000 });
expect(await panel.evaluate((element) => getComputedStyle(element).transform)).toBe("none");
});
+209 -47
View File
@@ -1,4 +1,6 @@
import { PLUGINS_LINKED } from "~/plugins/launch";
import { categoryHref } from "~/plugins/links";
import type { CategorySlug } from "~/plugins/categories";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import {
@@ -10,7 +12,7 @@ import {
ExternalLink,
Gem,
GitFork,
LayoutPanelLeft,
GitPullRequest,
Laptop,
Lock,
Merge,
@@ -19,16 +21,17 @@ import {
Smartphone,
Terminal,
Users,
Workflow,
type LucideIcon,
} from "lucide-react";
import {
motion,
AnimatePresence,
useInView,
useReducedMotion,
useScroll,
useTransform,
type Transition,
type Variants,
} from "framer-motion";
// Shared motion presets — hoisted so every JSX site receives the same object
@@ -86,7 +89,14 @@ import {
PiIcon,
} from "~/components/agent-icons";
import { DiscordIcon, GitHubIcon, SlackIcon } from "~/components/brand-icons";
import { MobileChat, MobileDiff, MobileSidebar, PhoneFrame } from "~/components/mockup";
import {
MobileChat,
MobileDiff,
MobileSidebar,
PhoneFrame,
type ToolCall,
ToolCallRow,
} from "~/components/mockup";
import { FAQItem } from "~/components/faq-item";
import { SiteFooter } from "~/components/site-footer";
import { SponsorSection, SponsorsSection } from "~/components/sponsorship";
@@ -132,8 +142,8 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
<SocialProofWall />
<MultiProviderSection />
<TurnkeySection />
<AutomationSection />
<ExtensibleSection />
<AutomationSection />
<SponsorsSection placement="page" />
<SponsorSection />
<PhilosophySection />
@@ -703,41 +713,50 @@ function AutomationDetail({ kind }: { kind: AutomationKind }) {
);
}
const MCP_AGENT_CALLS: ToolCall[] = [
{ kind: "paseo", label: "Create agent", detail: "Fix #412: retry flaky relay handshake" },
{ kind: "paseo", label: "Create agent", detail: "Fix #417: group schedules by project" },
];
function McpAutomationTranscript() {
const { t } = useTranslation();
return (
<AgentTurn request={t("landing.automation.transcriptRequest")}>
<AgentText>{t("landing.automation.transcriptPlan")}</AgentText>
<ToolCalls calls={MCP_AGENT_CALLS} />
<AgentText>{t("landing.automation.transcriptDone")}</AgentText>
</AgentTurn>
);
}
/** A user message and the agent's reply, laid out like the app's timeline. */
function AgentTurn({ request, children }: { request: string; children: React.ReactNode }) {
return (
<div className="space-y-5">
<div className="ml-auto w-fit max-w-xl rounded-xl rounded-tr-none bg-white/[0.07] px-4 py-3">
<p className="text-sm leading-relaxed text-white/75">
{t("landing.automation.transcriptRequest")}
</p>
</div>
<div className="flex items-start gap-3">
<Bot className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" strokeWidth={1.5} />
<div className="min-w-0 flex-1 space-y-4">
<p className="text-sm leading-relaxed text-white/55">
{t("landing.automation.transcriptPlan")}
</p>
<div className="space-y-2 font-mono text-[11px]">
<McpAgentCall issue="#412" provider="claude/opus-4.6" />
<McpAgentCall issue="#417" provider="codex/gpt-5.6-sol" />
</div>
<p className="text-sm leading-relaxed text-white/55">
{t("landing.automation.transcriptDone")}
</p>
</div>
</div>
<UserMessage>{request}</UserMessage>
<div className="space-y-3">{children}</div>
</div>
);
}
function McpAgentCall({ issue, provider }: { issue: string; provider: string }) {
function UserMessage({ children }: { children: React.ReactNode }) {
return (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md border border-white/[0.07] bg-white/[0.025] px-3 py-2">
<span className="text-sky-300/80">create_agent</span>
<span className="text-white/25">{issue}</span>
<span className="text-white/35">{provider}</span>
<span className="text-white/25">worktree</span>
<div className="ml-auto w-fit max-w-md rounded-xl bg-white/[0.07] px-4 py-3">
<p className="text-sm leading-relaxed text-white/80">{children}</p>
</div>
);
}
function AgentText({ children }: { children: React.ReactNode }) {
return <p className="text-sm leading-relaxed text-white/80">{children}</p>;
}
function ToolCalls({ calls }: { calls: ReadonlyArray<ToolCall> }) {
return (
<div>
{calls.map((call) => (
<ToolCallRow key={`${call.kind}:${call.detail}`} call={call} />
))}
</div>
);
}
@@ -842,17 +861,16 @@ function ExtensibleSection() {
title={t("landing.plugins.title")}
description={t("landing.plugins.description")}
>
<div className="space-y-8">
<ProductLead icon={Puzzle} title={t("landing.plugins.lead")} actions={pluginActions} />
<div className="grid gap-4 md:grid-cols-3">
{PLUGIN_CAPABILITIES.map((capability) => (
<FeatureCard
key={capability.key}
icon={capability.icon}
title={t(`landing.plugins.${capability.key}Title`)}
description={t(`landing.plugins.${capability.key}Description`)}
/>
))}
<div className="space-y-12">
<SelfModifyDemo />
<div className="space-y-6">
<ProductLead
icon={Puzzle}
title={t("landing.plugins.libraryTitle")}
description={t("landing.plugins.libraryDescription")}
actions={pluginActions}
/>
<PluginCategoryLinks />
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-white/45">{t("landing.plugins.license")}</p>
@@ -872,6 +890,156 @@ function ExtensibleSection() {
);
}
const PLUGIN_BUILD_CALLS: ToolCall[] = [
{ kind: "read", detail: "paseo-plugin/SKILL.md" },
{ kind: "shell", detail: "paseo plugin init review-queue" },
{ kind: "write", detail: "review-queue/index.ts" },
{ kind: "write", detail: "review-queue/review-queue.client.tsx" },
{ kind: "shell", detail: "paseo plugin install ./review-queue" },
];
// The demo plays once when it scrolls into view: the prompt, then each timeline
// item in turn, then the panel the agent built slides in and fills.
const DEMO_STEP_SECONDS = 0.45;
const DEMO_STEPS = {
prompt: 0,
plan: 1.4,
firstCall: 2.2,
} as const;
const DEMO_DONE_STEP = DEMO_STEPS.firstCall + PLUGIN_BUILD_CALLS.length;
const DEMO_PANEL_STEP = DEMO_DONE_STEP + 1;
const DEMO_ITEM_VARIANTS: Variants = {
hidden: { opacity: 0, y: 6 },
shown: (step: number) => ({
opacity: 1,
y: 0,
transition: { delay: step * DEMO_STEP_SECONDS, duration: 0.35, ease: "easeOut" },
}),
};
const DEMO_PANEL_VARIANTS: Variants = {
hidden: { opacity: 0, x: "100%" },
shown: {
opacity: 1,
x: 0,
transition: {
delay: DEMO_PANEL_STEP * DEMO_STEP_SECONDS,
duration: 0.6,
ease: [0.22, 0.61, 0.36, 1],
},
},
};
/** One piece of the demo that fades in at its step. */
function DemoItem({ step, children }: { step: number; children: React.ReactNode }) {
return (
<motion.div variants={DEMO_ITEM_VARIANTS} custom={step}>
{children}
</motion.div>
);
}
/** The ask on the left, the panel it produced on the right. */
function SelfModifyDemo() {
const { t } = useTranslation();
// Visitors who ask for reduced motion get the finished demo, with no slide or staging.
const reduceMotion = useReducedMotion();
return (
<motion.div
initial={reduceMotion ? "shown" : "hidden"}
whileInView="shown"
viewport={VIEWPORT_60}
className="grid min-h-[22rem] grid-cols-[minmax(0,1fr)] overflow-hidden rounded-xl border border-white/10 bg-black/20 md:grid-cols-[minmax(0,1fr)_17rem]"
>
<div className="space-y-5 p-5 md:p-6">
<DemoItem step={DEMO_STEPS.prompt}>
<UserMessage>{t("landing.plugins.request")}</UserMessage>
</DemoItem>
<div className="space-y-3">
<DemoItem step={DEMO_STEPS.plan}>
<AgentText>{t("landing.plugins.plan")}</AgentText>
</DemoItem>
<div>
{PLUGIN_BUILD_CALLS.map((call, index) => (
<DemoItem key={`${call.kind}:${call.detail}`} step={DEMO_STEPS.firstCall + index}>
<ToolCallRow call={call} />
</DemoItem>
))}
</div>
<DemoItem step={DEMO_DONE_STEP}>
<AgentText>{t("landing.plugins.done")}</AgentText>
</DemoItem>
</div>
</div>
<ReviewQueuePreview />
</motion.div>
);
}
const REVIEW_QUEUE = [
{ number: "#418", title: "Retry flaky relay handshake", author: "ana", age: "12m" },
{ number: "#415", title: "Group schedules by project", author: "kenji", age: "1h" },
{ number: "#409", title: "Add Gruvbox theme", author: "lea", age: "3h" },
] as const;
/** The panel the agent just built, docked like a real sidebar panel. */
function ReviewQueuePreview() {
const { t } = useTranslation();
return (
<motion.div
variants={DEMO_PANEL_VARIANTS}
className="border-t border-white/10 bg-white/[0.025] p-4 md:border-t-0 md:border-l"
>
<div className="mb-3 flex items-center gap-2">
<GitPullRequest className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
<span className="text-sm font-medium text-white/85">{t("landing.plugins.panelTitle")}</span>
<span className="ml-auto rounded-full bg-white/[0.07] px-2 py-0.5 text-[10px] text-white/55">
{REVIEW_QUEUE.length}
</span>
</div>
<div className="space-y-1">
{REVIEW_QUEUE.map((pr, index) => (
<DemoItem key={pr.number} step={DEMO_PANEL_STEP + 1.2 + index * 0.4}>
<div className="rounded-md px-2 py-2 hover:bg-white/[0.04]">
<p className="truncate text-xs text-white/80">{pr.title}</p>
<p className="mt-0.5 font-mono text-[10px] text-white/35">
{pr.number} · {pr.author} · {pr.age}
</p>
</div>
</DemoItem>
))}
</div>
</motion.div>
);
}
const SHOWCASE_CATEGORIES = [
"themes",
"sidebar",
"workspaces",
"providers",
"orchestration",
"git",
] as const satisfies ReadonlyArray<CategorySlug>;
function PluginCategoryLinks() {
const { t } = useTranslation();
const localize = useLocalizePath();
return (
<div className="flex flex-wrap gap-2">
{SHOWCASE_CATEGORIES.map((category) => (
<a
key={category}
href={localize(categoryHref(category))}
className="rounded-full border border-white/10 bg-white/[0.025] px-3 py-1.5 text-xs text-white/70 transition-colors hover:border-white/15 hover:bg-white/[0.06] hover:text-white"
>
{t(`plugins.categories.${category}.label`)}
</a>
))}
</div>
);
}
const PLUGINS_BROWSE = PLUGINS_LINKED
? { href: "/plugins", external: false }
: { href: "https://paseo.cafe", external: true };
@@ -899,12 +1067,6 @@ function PluginActions() {
);
}
const PLUGIN_CAPABILITIES = [
{ icon: LayoutPanelLeft, key: "ui" },
{ icon: Workflow, key: "lifecycle" },
{ icon: Bot, key: "providers" },
] as const;
const PRINCIPLES = [
{ icon: Lock, key: "private" },
{ icon: Compass, key: "independent" },
+39 -47
View File
@@ -1,14 +1,14 @@
import {
AudioLines,
BookOpen,
Brain,
ChevronDown,
CircleDot,
Copy,
FilePenLine,
Eye,
LoaderCircle,
ListTodo,
Mic,
Pencil,
Plus,
ShieldOff,
Split,
@@ -18,7 +18,7 @@ import {
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { DiffStat } from "./atoms";
import { CodexIcon } from "./icons";
import { CodexIcon, PaseoMark } from "./icons";
// Icons passed as props are hoisted so they are not rebuilt on every render.
const MODEL_ICON = <CodexIcon size={13} />;
@@ -94,70 +94,62 @@ export function AgentTranscript() {
);
}
interface PluginToolCall {
kind: "read" | "shell" | "write";
label: string;
detail: string;
/**
* A collapsed tool call as the app's timeline shows it. Built-in tools carry a
* fixed label; Paseo MCP tools name their action, such as "Create agent".
*/
export type ToolCall =
| { kind: "read" | "write" | "shell"; detail: string }
| { kind: "paseo"; label: string; detail: string };
const BUILT_IN_LABELS = { read: "Read", write: "Write", shell: "Shell" } as const;
function toolCallLabel(call: ToolCall): string {
return call.kind === "paseo" ? call.label : BUILT_IN_LABELS[call.kind];
}
const PLUGIN_TOOL_CALLS: PluginToolCall[] = [
{ kind: "read", label: "Read", detail: "paseo-plugin/SKILL.md" },
{
kind: "shell",
label: "Shell",
detail: "paseo plugin init ~/dev/release-radar",
},
{
kind: "write",
label: "Write",
detail: "~/dev/release-radar/index.ts",
},
{
kind: "write",
label: "Write",
detail: "~/dev/release-radar/release-radar.client.tsx",
},
{
kind: "shell",
label: "Shell",
detail: "npm install && npm run typecheck",
},
{
kind: "shell",
label: "Shell",
detail: "paseo plugin install ~/dev/release-radar",
},
const PLUGIN_TOOL_CALLS: ToolCall[] = [
{ kind: "read", detail: "paseo-plugin/SKILL.md" },
{ kind: "shell", detail: "paseo plugin init ~/dev/release-radar" },
{ kind: "write", detail: "~/dev/release-radar/index.ts" },
{ kind: "write", detail: "~/dev/release-radar/release-radar.client.tsx" },
{ kind: "shell", detail: "npm install && npm run typecheck" },
{ kind: "shell", detail: "paseo plugin install ~/dev/release-radar" },
];
const PLUGIN_DISCOVERY_CALLS = PLUGIN_TOOL_CALLS.slice(0, 1);
const PLUGIN_BUILD_CALLS = PLUGIN_TOOL_CALLS.slice(1, 4);
const PLUGIN_INSTALL_CALLS = PLUGIN_TOOL_CALLS.slice(4);
function PluginToolIcon({ kind }: { kind: PluginToolCall["kind"] }) {
if (kind === "read") return <BookOpen size={14} />;
if (kind === "write") return <FilePenLine size={14} />;
function ToolCallIcon({ kind }: { kind: ToolCall["kind"] }) {
if (kind === "read") return <Eye size={14} />;
if (kind === "write") return <Pencil size={14} />;
if (kind === "paseo") return <PaseoMark size={14} />;
return <SquareTerminal size={14} />;
}
function PluginToolRow({ call }: { call: PluginToolCall }) {
/**
* One tool call row, drawn like the app's collapsed timeline item
* (`packages/app/src/components/message.tsx`): icon, label, then the summary,
* all muted and in the UI font.
*/
export function ToolCallRow({ call }: { call: ToolCall }) {
return (
<div className="flex items-center gap-[9px] py-[5px] text-[12.5px] leading-[18px]">
<span className="text-mock-fg-muted">
<PluginToolIcon kind={call.kind} />
</span>
<span className="shrink-0 text-mock-fg">{call.label}</span>
<span className="min-w-0 truncate font-mono text-[11.5px] text-mock-fg-muted">
{call.detail}
<div className="flex items-center gap-[8px] py-[5px] text-[12.5px] leading-[18px] text-mock-fg-muted">
<span className="shrink-0">
<ToolCallIcon kind={call.kind} />
</span>
<span className="shrink-0">{toolCallLabel(call)}</span>
<span className="min-w-0 truncate">{call.detail}</span>
</div>
);
}
function PluginToolGroup({ calls }: { calls: PluginToolCall[] }) {
function PluginToolGroup({ calls }: { calls: ToolCall[] }) {
return (
<div>
{calls.map((call) => (
<PluginToolRow key={`${call.label}:${call.detail}`} call={call} />
<ToolCallRow key={`${call.kind}:${call.detail}`} call={call} />
))}
</div>
);
@@ -1,4 +1,5 @@
export { ClaudeIcon, CodexIcon, SourceControlIcon } from "./icons";
export { type ToolCall, ToolCallRow } from "./chat";
export { DESIGN_HEIGHT, DESIGN_WIDTH } from "./geometry";
export { PhoneFrame } from "./mobile/frame";
export { MobileChat } from "./mobile/chat";
+13 -14
View File
@@ -375,19 +375,18 @@
"otherHostComment": "# Target another host"
},
"plugins": {
"title": "Extend it with plugins",
"description": "Add new panels and functionality that work in desktop, web and mobile",
"lead": "Plugins",
"title": "Ask Paseo to change Paseo",
"description": "Agents in Paseo can change the whole UI without a restart, even on mobile.",
"request": "Add a sidebar panel with the PRs waiting on my review.",
"plan": "I'll build it as a local plugin and install it.",
"done": "Review queue is in your sidebar.",
"panelTitle": "Review queue",
"libraryTitle": "400+ community plugins",
"libraryDescription": "Built by developers on the same plugin API your agents use",
"browse": "Browse plugins",
"docs": "Plugin docs",
"license": "Paseo is Apache 2.0.",
"fork": "Fork the repo",
"uiTitle": "UI",
"uiDescription": "Screens, workspace panels, sidebar items, slash commands, and themes",
"lifecycleTitle": "Agent lifecycle",
"lifecycleDescription": "Change config and MCP servers, answer permissions, and follow up",
"providersTitle": "Providers",
"providersDescription": "Add a coding agent as a provider, used like the built-in ones"
"fork": "Fork the repo"
},
"philosophy": {
"title": "Philosophy",
@@ -447,10 +446,10 @@
},
"mockup": {
"alt": "Paseo desktop app with coding agents, a conversation, and a code diff open side by side",
"build": "Build",
"review": "Review",
"ship": "Ship",
"extend": "Extend",
"build": "Parallel agents",
"review": "Code review",
"ship": "Pull requests",
"extend": "Plugins",
"app": {
"newWorkspace": "New workspace",
"history": "History",
+13 -14
View File
@@ -375,19 +375,18 @@
"otherHostComment": "# 指定另一台主机"
},
"plugins": {
"title": "用插件扩展",
"description": "添加新的面板和功能,在桌面端、网页端和移动端都能使用",
"lead": "插件",
"title": "让 Paseo 修改 Paseo",
"description": "Paseo 里的 Agent 可以修改整个界面,无需重启,在移动端也一样。",
"request": "在侧边栏加一个面板,列出等待我审查的 PR。",
"plan": "我会把它做成本地插件并安装。",
"done": "审查队列已出现在你的侧边栏。",
"panelTitle": "审查队列",
"libraryTitle": "400 多个社区插件",
"libraryDescription": "开发者用的是和你的 Agent 相同的插件 API",
"browse": "浏览插件",
"docs": "插件文档",
"license": "Paseo 采用 Apache 2.0 许可证。",
"fork": "Fork 仓库",
"uiTitle": "界面",
"uiDescription": "页面、工作区面板、侧边栏项目、斜杠命令和主题",
"lifecycleTitle": "Agent 生命周期",
"lifecycleDescription": "修改配置和 MCP 服务器、响应权限请求、发送后续消息",
"providersTitle": "Provider",
"providersDescription": "把编程 Agent 添加为 Provider,用法与内置 Provider 相同"
"fork": "Fork 仓库"
},
"philosophy": {
"title": "理念",
@@ -447,10 +446,10 @@
},
"mockup": {
"alt": "Paseo 桌面应用,并排打开了编程 Agent、一段对话和一份代码 diff",
"build": "构建",
"review": "审查",
"ship": "发布",
"extend": "扩展",
"build": "并行 Agent",
"review": "代码审查",
"ship": "Pull Request",
"extend": "插件",
"app": {
"newWorkspace": "新建工作区",
"history": "历史",