mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b28aafa1df | ||
|
|
5feb68da4f | ||
|
|
24ef4c48cc | ||
|
|
a7d5f99a1c | ||
|
|
f1e262a142 | ||
|
|
dc7ade5264 | ||
|
|
da8d58bfee | ||
|
|
7c2812dde6 | ||
|
|
b474beedce | ||
|
|
e2f0779379 | ||
|
|
a166a3317a | ||
|
|
efc64a5443 | ||
|
|
7efa14238d | ||
|
|
dbbcb103ba | ||
|
|
7f318bf7cf | ||
|
|
04fe4be40f | ||
|
|
2d75cc80b2 | ||
|
|
bfac132920 | ||
|
|
34f996b32a | ||
|
|
3e83f433da | ||
|
|
674530152e | ||
|
|
919e44b319 | ||
|
|
4fe44038e3 | ||
|
|
9b0542d127 | ||
|
|
8130147426 | ||
|
|
fa89738e3a |
@@ -135,32 +135,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# Backport electron-builder#10172 to the pinned build tool only.
|
|
||||||
# Remove this step when app-builder-lib is upgraded to a release containing it.
|
|
||||||
- name: Repair temporary keychain password in signing tool
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
node <<'KEYCHAIN_FIX'
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const manifestPath = require.resolve('app-builder-lib/package.json');
|
|
||||||
if (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version !== '26.8.1') {
|
|
||||||
throw new Error('Reassess/remove the signing backport for the new app-builder-lib version');
|
|
||||||
}
|
|
||||||
const sourcePath = path.join(path.dirname(manifestPath), 'out/codeSign/macCodeSign.js');
|
|
||||||
let source = fs.readFileSync(sourcePath, 'utf8');
|
|
||||||
const replacements = [
|
|
||||||
['importCerts(keychainFile, certPaths, cscPasswords)', 'importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)'],
|
|
||||||
['importCerts(keychainFile, paths, keyPasswords)', 'importCerts(keychainFile, paths, keyPasswords, keychainPassword)'],
|
|
||||||
['["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]'],
|
|
||||||
];
|
|
||||||
for (const [before, after] of replacements) {
|
|
||||||
if (source.split(before).length !== 2) throw new Error('Unexpected signing tool source; refusing to patch');
|
|
||||||
source = source.replace(before, after);
|
|
||||||
}
|
|
||||||
fs.writeFileSync(sourcePath, source);
|
|
||||||
KEYCHAIN_FIX
|
|
||||||
|
|
||||||
- name: Set desktop package version from tag
|
- name: Set desktop package version from tag
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -406,10 +406,7 @@
|
|||||||
{
|
{
|
||||||
"files": [
|
"files": [
|
||||||
"packages/app/src/app/_layout.tsx",
|
"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/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-list.tsx",
|
||||||
"packages/app/src/components/agent-status-dot.tsx",
|
"packages/app/src/components/agent-status-dot.tsx",
|
||||||
"packages/app/src/components/attachment-lightbox.tsx",
|
"packages/app/src/components/attachment-lightbox.tsx",
|
||||||
@@ -428,7 +425,6 @@
|
|||||||
"packages/app/src/components/hosts/host-picker.tsx",
|
"packages/app/src/components/hosts/host-picker.tsx",
|
||||||
"packages/app/src/components/icons/paseo-logo.tsx",
|
"packages/app/src/components/icons/paseo-logo.tsx",
|
||||||
"packages/app/src/components/left-sidebar.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/plan-card.tsx",
|
||||||
"packages/app/src/components/provider-diagnostic-sheet.tsx",
|
"packages/app/src/components/provider-diagnostic-sheet.tsx",
|
||||||
"packages/app/src/components/question-form-card.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-skills/selection-sheet.tsx",
|
||||||
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
|
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
|
||||||
"packages/app/src/agent-stream/view.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/changelog/internal/changelog-sheet.tsx",
|
||||||
"packages/app/src/command-center/command-center.tsx",
|
"packages/app/src/command-center/command-center.tsx",
|
||||||
"packages/app/src/components/adaptive-modal-sheet.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-project-flow.tsx",
|
||||||
"packages/app/src/components/add-remote-ssh-host-modal.tsx",
|
|
||||||
"packages/app/src/components/agent-list.tsx",
|
"packages/app/src/components/agent-list.tsx",
|
||||||
"packages/app/src/components/app-diagnostic-sheet.tsx",
|
"packages/app/src/components/app-diagnostic-sheet.tsx",
|
||||||
"packages/app/src/components/attachment-lightbox.tsx",
|
"packages/app/src/components/attachment-lightbox.tsx",
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ registrations remain inspectable; a changed destination requires explicit identi
|
|||||||
|
|
||||||
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
|
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
|
||||||
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
|
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
|
||||||
operations; requesting this feature gives update guidance.
|
operations; requesting this feature gives update guidance. The registry retries an older destination
|
||||||
|
with capped backoff, so it comes online once updated. Self, duplicate, changed-identity, and
|
||||||
|
authentication failures stay in `error` until an update changes the connection, `enabled`, or
|
||||||
|
`replaceIdentity`, or A restarts.
|
||||||
|
|
||||||
Disposing a routed API releases its observations and session. It does not remove the saved host or
|
Disposing a routed API releases its observations and session. It does not remove the saved host or
|
||||||
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
|
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
|
||||||
|
|||||||
+4
-4
@@ -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:
|
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`).
|
- **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`).
|
- **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.
|
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`).
|
`<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.
|
`<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
|
## 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.
|
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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ restore the same workspace and could repeat the crash.
|
|||||||
|
|
||||||
## App-Wide Route Hops
|
## 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
|
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
|
||||||
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
|
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
|
||||||
branch on its current route.
|
branch on its current route.
|
||||||
|
|||||||
+20
-11
@@ -97,19 +97,28 @@ Register the adapter in `defaultForgeRegistry` with:
|
|||||||
- `matchesHost` from manifest `cloudHosts`
|
- `matchesHost` from manifest `cloudHosts`
|
||||||
- `probeHost` when self-hosted/Enterprise detection is supported
|
- `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
|
For mixed SSH and HTTPS ports on one host, require the source repository's native
|
||||||
match. Its remote head SHA may differ because the checkout can be ahead,
|
clone URL to match the normalized checkout endpoint. Different HTTP ports remain
|
||||||
behind, or contain commits that have not been pushed yet.
|
distinct forge instances. Unknown clone URLs do not establish identity.
|
||||||
- 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.
|
|
||||||
|
|
||||||
Thread the checkout head SHA through adapter cache and poll identities as well
|
Auto-archive has a narrower contract. It requires a fresh, observed open-to-merged
|
||||||
as the lookup itself. Otherwise a commit made on the same branch can inherit the
|
transition and an exact match between the checkout HEAD and the forge's recorded
|
||||||
previous commit's cached terminal status until the cache expires.
|
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
|
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
|
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ buildNpmPackage {
|
|||||||
# The Nix output is not a distributable DMG, so leave it unsigned and
|
# The Nix output is not a distributable DMG, so leave it unsigned and
|
||||||
# disable the hardened runtime that requires a matching signature.
|
# disable the hardened runtime that requires a matching signature.
|
||||||
CSC_IDENTITY_AUTO_DISCOVERY=false \
|
CSC_IDENTITY_AUTO_DISCOVERY=false \
|
||||||
../../node_modules/.bin/electron-builder \
|
npm exec --offline -- electron-builder \
|
||||||
--config electron-builder.yml \
|
--config electron-builder.yml \
|
||||||
--dir \
|
--dir \
|
||||||
--mac \
|
--mac \
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU=
|
sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ=
|
||||||
|
|||||||
Generated
+1018
-1295
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import path from "node:path";
|
|||||||
import { test, expect } from "../support/fixtures";
|
import { test, expect } from "../support/fixtures";
|
||||||
import {
|
import {
|
||||||
addProjectFlow,
|
addProjectFlow,
|
||||||
|
chooseAddHostFromProjectFlow,
|
||||||
addProjectFlowBack,
|
addProjectFlowBack,
|
||||||
addProjectFlowHost,
|
addProjectFlowHost,
|
||||||
addProjectFlowInput,
|
addProjectFlowInput,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
} from "../support/helpers/add-project-flow";
|
} from "../support/helpers/add-project-flow";
|
||||||
import { gotoAppShell } from "../support/helpers/app";
|
import { gotoAppShell } from "../support/helpers/app";
|
||||||
import {
|
import {
|
||||||
|
disconnectAllHostsAndReload,
|
||||||
addConnectedHostAndReload,
|
addConnectedHostAndReload,
|
||||||
addConnectedHostsAndReload,
|
addConnectedHostsAndReload,
|
||||||
addOfflineHostAndReload,
|
addOfflineHostAndReload,
|
||||||
@@ -69,6 +71,19 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
|
|||||||
test.describe("Add Project command-center flow", () => {
|
test.describe("Add Project command-center flow", () => {
|
||||||
test.describe.configure({ timeout: 180_000 });
|
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 ({
|
test("method selection shows the daemon's available project sources without search", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
composerLocator,
|
composerLocator,
|
||||||
expectComposerDraft,
|
expectComposerDraft,
|
||||||
expectComposerVisible,
|
expectComposerVisible,
|
||||||
submitMessage,
|
fillComposerDraft,
|
||||||
typeIntoFocusedComposer,
|
typeIntoFocusedComposer,
|
||||||
} from "../support/helpers/composer";
|
} from "../support/helpers/composer";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
@@ -12,8 +12,14 @@ function userMessages(page: Page) {
|
|||||||
return page.getByTestId("user-message");
|
return page.getByTestId("user-message");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendDraftWhenReady(page: Page) {
|
||||||
|
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
||||||
|
await pressInComposer(page, "Enter");
|
||||||
|
}
|
||||||
|
|
||||||
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
||||||
await submitMessage(page, text);
|
await fillComposerDraft(page, text);
|
||||||
|
await sendDraftWhenReady(page);
|
||||||
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
||||||
await expectComposerDraft(page, "");
|
await expectComposerDraft(page, "");
|
||||||
}
|
}
|
||||||
@@ -49,7 +55,7 @@ test.describe("Composer recall", () => {
|
|||||||
await pressInComposer(page, "ArrowUp");
|
await pressInComposer(page, "ArrowUp");
|
||||||
await expectComposerDraft(page, "add tests for login");
|
await expectComposerDraft(page, "add tests for login");
|
||||||
|
|
||||||
await pressInComposer(page, "Enter");
|
await sendDraftWhenReady(page);
|
||||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -294,10 +294,17 @@ test("creates, renames, copies, and deletes entries through the file explorer",
|
|||||||
|
|
||||||
await entry("renamed.txt").click({ button: "right" });
|
await entry("renamed.txt").click({ button: "right" });
|
||||||
const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/);
|
const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/);
|
||||||
const deleteLabelColor = await deleteAction
|
await expect
|
||||||
.getByText("Delete", { exact: true })
|
.poll(async () => {
|
||||||
.evaluate((element) => getComputedStyle(element).color);
|
const labelColor = await deleteAction
|
||||||
await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor);
|
.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) => {
|
const cancelledConfirmation = new Promise<string>((resolve) => {
|
||||||
page.once("dialog", async (dialog) => {
|
page.once("dialog", async (dialog) => {
|
||||||
const message = dialog.message();
|
const message = dialog.message();
|
||||||
|
|||||||
@@ -122,10 +122,14 @@ test.describe("New workspace entry points", () => {
|
|||||||
});
|
});
|
||||||
const hostTrigger = page.getByTestId("host-picker-trigger");
|
const hostTrigger = page.getByTestId("host-picker-trigger");
|
||||||
await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 });
|
await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 });
|
||||||
|
const originalUrl = page.url();
|
||||||
await hostTrigger.click();
|
await hostTrigger.click();
|
||||||
await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible();
|
await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible();
|
||||||
await page.getByTestId("new-workspace-host-picker-add-host").click();
|
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 {
|
} finally {
|
||||||
await seeded.cleanup();
|
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 { readFile, mkdir, rename, rm } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
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 expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
|
||||||
await editRestartRecoveryPrompt(page, "Resume working.");
|
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);
|
.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> {
|
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");
|
await composerInput(page).press("Control+Enter");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ if (origin === fixtureRemote) {
|
|||||||
labels: [],
|
labels: [],
|
||||||
baseRefName: "main",
|
baseRefName: "main",
|
||||||
headRefName: isFork ? "pr-branch-2" : "pr-branch-1",
|
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"
|
updatedAt: "2026-01-01T00:00:00Z"
|
||||||
};
|
};
|
||||||
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
|
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
|
||||||
|
|||||||
@@ -273,3 +273,25 @@ export async function reloadPreservingHostRegistry(page: Page): Promise<void> {
|
|||||||
);
|
);
|
||||||
await page.reload();
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AddHostProvider, AddHostFlowHost } from "@/hosts/add-host";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
|
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
|
||||||
@@ -465,6 +466,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
|||||||
const surface = (
|
const surface = (
|
||||||
<View style={layoutStyles.surfaceFill}>
|
<View style={layoutStyles.surfaceFill}>
|
||||||
{workspaceChrome}
|
{workspaceChrome}
|
||||||
|
<AddHostFlowHost />
|
||||||
<AppearanceStyleBoundary>
|
<AppearanceStyleBoundary>
|
||||||
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
|
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
|
||||||
<WindowChromeRegion corners="top-left">
|
<WindowChromeRegion corners="top-left">
|
||||||
@@ -805,7 +807,6 @@ function RootStack() {
|
|||||||
<Stack.Screen name="open-project" />
|
<Stack.Screen name="open-project" />
|
||||||
<Stack.Screen name="sessions" />
|
<Stack.Screen name="sessions" />
|
||||||
<Stack.Screen name="schedules" />
|
<Stack.Screen name="schedules" />
|
||||||
<Stack.Screen name="pair-scan" />
|
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
<Stack.Screen name="h/[serverId]" />
|
<Stack.Screen name="h/[serverId]" />
|
||||||
<Stack.Screen name="settings/hosts/[serverId]/index" />
|
<Stack.Screen name="settings/hosts/[serverId]/index" />
|
||||||
@@ -890,11 +891,13 @@ function RootAppTree() {
|
|||||||
<GestureHandlerRootView style={flexStyle}>
|
<GestureHandlerRootView style={flexStyle}>
|
||||||
<View style={layoutStyles.surfaceFill}>
|
<View style={layoutStyles.surfaceFill}>
|
||||||
<LucideProvider strokeWidth={ICON_STROKE_WIDTH}>
|
<LucideProvider strokeWidth={ICON_STROKE_WIDTH}>
|
||||||
<RootProviders>
|
<AddHostProvider>
|
||||||
<RuntimeProviders>
|
<RootProviders>
|
||||||
<AppShell />
|
<RuntimeProviders>
|
||||||
</RuntimeProviders>
|
<AppShell />
|
||||||
</RootProviders>
|
</RuntimeProviders>
|
||||||
|
</RootProviders>
|
||||||
|
</AddHostProvider>
|
||||||
</LucideProvider>
|
</LucideProvider>
|
||||||
</View>
|
</View>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
|
|||||||
@@ -35,10 +35,9 @@ function SettingsDaemonRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsSectionRoute() {
|
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 rawSection = typeof params.section === "string" ? params.section : "";
|
||||||
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general";
|
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]);
|
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
|
||||||
|
|
||||||
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
|
// 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 { Modal } from "@/components/ui/modal";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
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 { AddProjectFlowRequest } from "@/stores/add-project-flow-store";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { buildNewWorkspaceRoute, buildSettingsAddHostRoute } from "@/utils/host-routes";
|
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
interface AddProjectFlowProps {
|
interface AddProjectFlowProps {
|
||||||
request: AddProjectFlowRequest;
|
request: AddProjectFlowRequest;
|
||||||
@@ -325,6 +326,7 @@ function setPageStatus(
|
|||||||
// The product flow is intentionally one cohesive page-stack state machine.
|
// The product flow is intentionally one cohesive page-stack state machine.
|
||||||
// eslint-disable-next-line complexity
|
// eslint-disable-next-line complexity
|
||||||
export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||||
|
const addHost = useAddHost();
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
||||||
const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds);
|
const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds);
|
||||||
@@ -616,9 +618,9 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
|||||||
icon: Plus,
|
icon: Plus,
|
||||||
testID: "add-project-flow-add-host",
|
testID: "add-project-flow-add-host",
|
||||||
select: () => {
|
select: () => {
|
||||||
|
// The native project Modal must release its window before the root sheet opens.
|
||||||
onClose();
|
onClose();
|
||||||
usePanelStore.getState().showMobileAgent();
|
addHost();
|
||||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -717,6 +719,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
|||||||
directoryPaths,
|
directoryPaths,
|
||||||
githubQuery.data,
|
githubQuery.data,
|
||||||
host,
|
host,
|
||||||
|
addHost,
|
||||||
onClose,
|
onClose,
|
||||||
openAddedProject,
|
openAddedProject,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useAddHost } from "@/hosts/add-host";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import {
|
import {
|
||||||
CircleGauge,
|
CircleGauge,
|
||||||
@@ -62,7 +63,7 @@ import { usePanelStore } from "@/stores/panel-store";
|
|||||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
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 { openHostOverview } from "@/navigation/settings-navigation";
|
||||||
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
|
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
|
||||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||||
@@ -177,14 +178,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
|||||||
router.push(buildSettingsRoute());
|
router.push(buildSettingsRoute());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAddHostMobile = useCallback(() => {
|
const handleAddHostMobile = useAddHost();
|
||||||
showMobileAgent();
|
const handleAddHostDesktop = handleAddHostMobile;
|
||||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
|
||||||
}, [showMobileAgent]);
|
|
||||||
|
|
||||||
const handleAddHostDesktop = useCallback(() => {
|
|
||||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleOpenHostSettingsMobile = useCallback(
|
const handleOpenHostSettingsMobile = useCallback(
|
||||||
(serverId: string) => {
|
(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&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 | 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&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&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&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([
|
it.each([
|
||||||
"src/components/message.tsx",
|
"src/components/message.tsx",
|
||||||
"C:\\repo\\file.ts",
|
"C:\\repo\\file.ts",
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ export function prepareMarkdownSource(
|
|||||||
!source.includes("![") &&
|
!source.includes("![") &&
|
||||||
!source.includes("||") &&
|
!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("]("))
|
!(workspaceRoot && source.includes("]("))
|
||||||
) {
|
) {
|
||||||
return { images, ...metadata, render: () => source };
|
return { images, ...metadata, render: () => source };
|
||||||
@@ -212,11 +216,25 @@ export function prepareMarkdownSource(
|
|||||||
if (lineEnd < end) split(lineEnd);
|
if (lineEnd < end) split(lineEnd);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function visit(node: Nodes) {
|
function visit(node: Nodes, previous?: Nodes) {
|
||||||
const start = node.position?.start.offset;
|
const position = node.position;
|
||||||
const end = node.position?.end.offset;
|
if (position?.start.offset !== undefined && position.end.offset !== undefined) {
|
||||||
if (start !== undefined && end !== undefined) {
|
const start = position.start.offset;
|
||||||
|
const end = position.end.offset;
|
||||||
if (node.type === "paragraph") splitStandaloneImages(node, start, end);
|
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") {
|
if (node.type === "link") {
|
||||||
const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot);
|
const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot);
|
||||||
if (title !== null)
|
if (title !== null)
|
||||||
@@ -244,7 +262,8 @@ export function prepareMarkdownSource(
|
|||||||
if (text !== source.slice(start, end)) edits.push({ start, end, text });
|
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());
|
visit(getTree());
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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 { useTranslation } from "react-i18next";
|
||||||
import { Pressable, Text, View, ScrollView } from "react-native";
|
import { Pressable, Text, View, ScrollView } from "react-native";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
@@ -12,11 +13,7 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
|
||||||
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { resolveAppVersion } from "@/utils/app-version";
|
import { resolveAppVersion } from "@/utils/app-version";
|
||||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||||
@@ -162,20 +159,13 @@ function useAnyHostOnline(serverIds: string[]): string | null {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WelcomeScreenProps {
|
export function WelcomeScreen() {
|
||||||
onHostAdded?: (profile: HostProfile) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const appVersion = resolveAppVersion();
|
const appVersion = resolveAppVersion();
|
||||||
const appVersionText = formatVersionWithPrefix(appVersion);
|
const appVersionText = formatVersionWithPrefix(appVersion);
|
||||||
const [isDirectOpen, setIsDirectOpen] = useState(false);
|
|
||||||
const [isRemoteSshOpen, setIsRemoteSshOpen] = useState(false);
|
|
||||||
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
|
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
|
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
|
||||||
|
|
||||||
@@ -196,23 +186,10 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
router.push("/settings");
|
router.push("/settings");
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const handleOpenDirect = useCallback(() => setIsDirectOpen(true), []);
|
const handleOpenDirect = useAddHost({ method: "direct", onAdded: finishOnboarding });
|
||||||
const handleCloseDirect = useCallback(() => setIsDirectOpen(false), []);
|
const handleOpenRemoteSsh = useAddHost({ method: "ssh", onAdded: finishOnboarding });
|
||||||
const handleOpenRemoteSsh = useCallback(() => setIsRemoteSshOpen(true), []);
|
const handleOpenPasteLink = useAddHost({ method: "link", onAdded: finishOnboarding });
|
||||||
const handleCloseRemoteSsh = useCallback(() => setIsRemoteSshOpen(false), []);
|
const handleScanQr = useAddHost({ method: "scan", onAdded: finishOnboarding });
|
||||||
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 actions: WelcomeAction[] =
|
const actions: WelcomeAction[] =
|
||||||
isWeb || isFdroidBuild
|
isWeb || isFdroidBuild
|
||||||
@@ -316,24 +293,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.versionLabel}>{appVersionText}</Text>
|
<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>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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%"];
|
||||||
+11
-10
@@ -1,9 +1,10 @@
|
|||||||
|
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { QrCode, Link2, ClipboardPaste, Terminal } from "lucide-react-native";
|
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 { isFdroidBuild } from "@/constants/build-profile";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
import { isElectronRuntime } from "@/desktop/host";
|
import { isElectronRuntime } from "@/desktop/host";
|
||||||
@@ -84,7 +85,7 @@ export function AddHostMethodModal({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
testID="add-host-method-modal"
|
testID="add-host-method-modal"
|
||||||
>
|
>
|
||||||
<Pressable
|
<PressHighlight
|
||||||
style={styles.option}
|
style={styles.option}
|
||||||
onPress={handleDirect}
|
onPress={handleDirect}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -98,10 +99,10 @@ export function AddHostMethodModal({
|
|||||||
{t("pairing.connectionMethods.direct.description")}
|
{t("pairing.connectionMethods.direct.description")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</PressHighlight>
|
||||||
|
|
||||||
{isElectronRuntime() ? (
|
{isElectronRuntime() ? (
|
||||||
<Pressable
|
<PressHighlight
|
||||||
style={styles.option}
|
style={styles.option}
|
||||||
onPress={handleRemoteSsh}
|
onPress={handleRemoteSsh}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -115,11 +116,11 @@ export function AddHostMethodModal({
|
|||||||
{t("pairing.connectionMethods.remoteSsh.description")}
|
{t("pairing.connectionMethods.remoteSsh.description")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</PressHighlight>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isNative && !isFdroidBuild ? (
|
{isNative && !isFdroidBuild ? (
|
||||||
<Pressable
|
<PressHighlight
|
||||||
style={styles.option}
|
style={styles.option}
|
||||||
onPress={handleScan}
|
onPress={handleScan}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -132,10 +133,10 @@ export function AddHostMethodModal({
|
|||||||
{t("pairing.connectionMethods.scanQr.description")}
|
{t("pairing.connectionMethods.scanQr.description")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</PressHighlight>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Pressable
|
<PressHighlight
|
||||||
style={styles.option}
|
style={styles.option}
|
||||||
onPress={handlePaste}
|
onPress={handlePaste}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -149,7 +150,7 @@ export function AddHostMethodModal({
|
|||||||
{t("pairing.connectionMethods.pasteLink.description")}
|
{t("pairing.connectionMethods.pasteLink.description")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</PressHighlight>
|
||||||
</AdaptiveModalSheet>
|
</AdaptiveModalSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+47
-30
@@ -1,17 +1,22 @@
|
|||||||
|
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||||
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
|
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Pressable, Text, View } from "react-native";
|
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 { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
|
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||||
import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints";
|
import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints";
|
||||||
import { buildConnectionFailureCopy } from "./connection-failure-copy";
|
import { buildConnectionFailureCopy } from "@/components/connection-failure-copy";
|
||||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
import {
|
||||||
|
AdaptiveModalSheet,
|
||||||
|
AdaptiveTextInput,
|
||||||
|
type SheetHeader,
|
||||||
|
} from "@/components/adaptive-modal-sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
import { PairingTargetTracker } from "@/components/pair-link-credentials";
|
||||||
import { buildDirectConnectionUri } from "./direct-connection-host";
|
import { buildDirectConnectionUri } from "@/components/direct-connection-host";
|
||||||
|
|
||||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
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) {
|
function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { probeAndUpsertDirectConnection, beginLinkPairing } = useHostMutations();
|
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 [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
|
||||||
const advancedTarget = useRef(new PairingTargetTracker("", true));
|
const advancedTarget = useRef(new PairingTargetTracker("", true));
|
||||||
|
|
||||||
const connectIcon = useMemo(
|
const connectIcon = useMemo(() => <ThemedLink2 size={16} />, []);
|
||||||
() => <Link2 size={16} color={theme.colors.accentForeground} />,
|
|
||||||
[theme.colors.accentForeground],
|
|
||||||
);
|
|
||||||
const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
|
const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
|
||||||
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);
|
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);
|
||||||
const checkboxStyle = useMemo(
|
const checkboxStyle = useMemo(
|
||||||
@@ -415,8 +416,8 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
setIsAdvancedOpen(false);
|
setIsAdvancedOpen(false);
|
||||||
}, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]);
|
}, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]);
|
||||||
|
|
||||||
const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight;
|
const AdvancedIcon = isAdvancedOpen ? ThemedChevronDown : ThemedChevronRight;
|
||||||
const PasswordIcon = isPasswordVisible ? EyeOff : Eye;
|
const PasswordIcon = isPasswordVisible ? ThemedEyeOff : ThemedEye;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
@@ -430,7 +431,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
<View style={styles.portRow}>
|
<View style={styles.portRow}>
|
||||||
<View style={hostFieldStyle}>
|
<View style={hostFieldStyle}>
|
||||||
<Text style={styles.label}>{t("pairing.direct.fields.host")}</Text>
|
<Text style={styles.label}>{t("pairing.direct.fields.host")}</Text>
|
||||||
<AdaptiveTextInput
|
<ThemedAdaptiveTextInput
|
||||||
testID="direct-host-input"
|
testID="direct-host-input"
|
||||||
nativeID="direct-host-input"
|
nativeID="direct-host-input"
|
||||||
accessibilityLabel={t("pairing.direct.fields.host")}
|
accessibilityLabel={t("pairing.direct.fields.host")}
|
||||||
@@ -438,7 +439,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
resetKey={`direct-host-${inputResetKey}`}
|
resetKey={`direct-host-${inputResetKey}`}
|
||||||
onChangeText={setHost}
|
onChangeText={setHost}
|
||||||
placeholder="localhost"
|
placeholder="localhost"
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
@@ -449,7 +449,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
</View>
|
</View>
|
||||||
<View style={portFieldStyle}>
|
<View style={portFieldStyle}>
|
||||||
<Text style={styles.label}>{t("pairing.direct.fields.port")}</Text>
|
<Text style={styles.label}>{t("pairing.direct.fields.port")}</Text>
|
||||||
<AdaptiveTextInput
|
<ThemedAdaptiveTextInput
|
||||||
testID="direct-port-input"
|
testID="direct-port-input"
|
||||||
nativeID="direct-port-input"
|
nativeID="direct-port-input"
|
||||||
accessibilityLabel={t("pairing.direct.fields.port")}
|
accessibilityLabel={t("pairing.direct.fields.port")}
|
||||||
@@ -457,7 +457,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
resetKey={`direct-port-${inputResetKey}`}
|
resetKey={`direct-port-${inputResetKey}`}
|
||||||
onChangeText={setPort}
|
onChangeText={setPort}
|
||||||
placeholder="6767"
|
placeholder="6767"
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
@@ -469,7 +468,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Pressable
|
<PressHighlight
|
||||||
style={styles.checkboxRow}
|
style={styles.checkboxRow}
|
||||||
onPress={handleToggleUseTls}
|
onPress={handleToggleUseTls}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
@@ -482,17 +481,17 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
<View style={checkboxStyle}>
|
<View style={checkboxStyle}>
|
||||||
{useTls ? (
|
{useTls ? (
|
||||||
<View testID="direct-ssl-toggle-checked">
|
<View testID="direct-ssl-toggle-checked">
|
||||||
<Check size={14} color={theme.colors.accentForeground} />
|
<ThemedCheck size={14} />
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.label}>{t("pairing.direct.fields.useSsl")}</Text>
|
<Text style={styles.label}>{t("pairing.direct.fields.useSsl")}</Text>
|
||||||
</Pressable>
|
</PressHighlight>
|
||||||
|
|
||||||
<View style={styles.field}>
|
<View style={styles.field}>
|
||||||
<Text style={styles.label}>{t("pairing.direct.fields.password")}</Text>
|
<Text style={styles.label}>{t("pairing.direct.fields.password")}</Text>
|
||||||
<View style={styles.passwordRow}>
|
<View style={styles.passwordRow}>
|
||||||
<AdaptiveTextInput
|
<ThemedAdaptiveTextInput
|
||||||
testID="direct-password-input"
|
testID="direct-password-input"
|
||||||
nativeID="direct-password-input"
|
nativeID="direct-password-input"
|
||||||
accessibilityLabel={t("pairing.direct.fields.password")}
|
accessibilityLabel={t("pairing.direct.fields.password")}
|
||||||
@@ -500,7 +499,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
resetKey={`direct-password-${inputResetKey}`}
|
resetKey={`direct-password-${inputResetKey}`}
|
||||||
onChangeText={setPassword}
|
onChangeText={setPassword}
|
||||||
placeholder={t("pairing.direct.fields.optional")}
|
placeholder={t("pairing.direct.fields.optional")}
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
|
||||||
style={passwordInputStyle}
|
style={passwordInputStyle}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
@@ -509,7 +507,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
onSubmitEditing={handleSubmitEditing}
|
onSubmitEditing={handleSubmitEditing}
|
||||||
/>
|
/>
|
||||||
<Pressable
|
<PressHighlight
|
||||||
style={styles.iconButton}
|
style={styles.iconButton}
|
||||||
onPress={handleTogglePasswordVisibility}
|
onPress={handleTogglePasswordVisibility}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
@@ -521,13 +519,13 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
}
|
}
|
||||||
testID="direct-password-visibility-toggle"
|
testID="direct-password-visibility-toggle"
|
||||||
>
|
>
|
||||||
<PasswordIcon size={18} color={theme.colors.foregroundMuted} />
|
<PasswordIcon size={18} />
|
||||||
</Pressable>
|
</PressHighlight>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.field}>
|
<View style={styles.field}>
|
||||||
<Pressable
|
<PressHighlight
|
||||||
style={styles.advancedToggle}
|
style={styles.advancedToggle}
|
||||||
onPress={handleToggleAdvanced}
|
onPress={handleToggleAdvanced}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
@@ -537,11 +535,11 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
}
|
}
|
||||||
testID="direct-host-advanced-toggle"
|
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>
|
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
|
||||||
</Pressable>
|
</PressHighlight>
|
||||||
{isAdvancedOpen ? (
|
{isAdvancedOpen ? (
|
||||||
<AdaptiveTextInput
|
<ThemedAdaptiveTextInput
|
||||||
testID="direct-host-uri-input"
|
testID="direct-host-uri-input"
|
||||||
nativeID="direct-host-uri-input"
|
nativeID="direct-host-uri-input"
|
||||||
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
||||||
@@ -549,7 +547,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
resetKey={`direct-host-uri-${inputResetKey}`}
|
resetKey={`direct-host-uri-${inputResetKey}`}
|
||||||
onChangeText={handleChangeAdvancedUri}
|
onChangeText={handleChangeAdvancedUri}
|
||||||
placeholder="tcp://localhost:6767?ssl=true"
|
placeholder="tcp://localhost:6767?ssl=true"
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
@@ -585,3 +582,23 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
</AdaptiveModalSheet>
|
</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,
|
||||||
|
}));
|
||||||
+5
-5
@@ -1,7 +1,7 @@
|
|||||||
|
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Pressable,
|
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
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 FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||||
const ThemedTerminal = withUnistyles(Terminal);
|
const ThemedTerminal = withUnistyles(Terminal);
|
||||||
@@ -174,11 +174,11 @@ export function AddRemoteSshHostModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
clear();
|
clear();
|
||||||
onClose();
|
|
||||||
onSaved?.({
|
onSaved?.({
|
||||||
...result,
|
...result,
|
||||||
isNewHost: !hosts.some((profile) => profile.serverId === result.serverId),
|
isNewHost: !hosts.some((profile) => profile.serverId === result.serverId),
|
||||||
});
|
});
|
||||||
|
onClose();
|
||||||
}, [clear, hosts, isSaving, onClose, onSaved, probeAndUpsertRemoteSshConnection, t]);
|
}, [clear, hosts, isSaving, onClose, onSaved, probeAndUpsertRemoteSshConnection, t]);
|
||||||
const handleTargetChange = useCallback((value: string) => {
|
const handleTargetChange = useCallback((value: string) => {
|
||||||
targetRef.current = value;
|
targetRef.current = value;
|
||||||
@@ -260,7 +260,7 @@ export function AddRemoteSshHostModal({
|
|||||||
onSubmitEditing={handleSubmit}
|
onSubmitEditing={handleSubmit}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Pressable
|
<PressHighlight
|
||||||
style={iconButtonStyle}
|
style={iconButtonStyle}
|
||||||
onPress={handleTogglePasswordVisibility}
|
onPress={handleTogglePasswordVisibility}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
@@ -273,7 +273,7 @@ export function AddRemoteSshHostModal({
|
|||||||
testID="remote-ssh-password-visibility-toggle"
|
testID="remote-ssh-password-visibility-toggle"
|
||||||
>
|
>
|
||||||
{isPasswordVisible ? <ThemedEyeOff size={18} /> : <ThemedEye size={18} />}
|
{isPasswordVisible ? <ThemedEyeOff size={18} /> : <ThemedEye size={18} />}
|
||||||
</Pressable>
|
</PressHighlight>
|
||||||
</View>
|
</View>
|
||||||
</Field>
|
</Field>
|
||||||
<View style={styles.actions}>
|
<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) });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+16
-11
@@ -1,16 +1,20 @@
|
|||||||
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
|
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Text, View } from "react-native";
|
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 { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { Link } from "lucide-react-native";
|
import { Link } from "lucide-react-native";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime";
|
import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime";
|
||||||
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
|
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
|
||||||
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
|
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 { 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 { Button } from "@/components/ui/button";
|
||||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||||
|
|
||||||
@@ -100,7 +104,6 @@ function PairLinkModalContent({
|
|||||||
onCancel,
|
onCancel,
|
||||||
onSaved,
|
onSaved,
|
||||||
}: PairLinkModalProps) {
|
}: PairLinkModalProps) {
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { beginLinkPairing } = useHostMutations();
|
const { beginLinkPairing } = useHostMutations();
|
||||||
@@ -126,10 +129,7 @@ function PairLinkModalContent({
|
|||||||
resetPasswordInput();
|
resetPasswordInput();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const pairIcon = useMemo(
|
const pairIcon = useMemo(() => <ThemedLink size={16} />, []);
|
||||||
() => <Link size={16} color={theme.colors.accentForeground} />,
|
|
||||||
[theme.colors.accentForeground],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
@@ -212,7 +212,7 @@ function PairLinkModalContent({
|
|||||||
|
|
||||||
<View style={styles.field}>
|
<View style={styles.field}>
|
||||||
<Text style={styles.label}>{t("pairing.link.label")}</Text>
|
<Text style={styles.label}>{t("pairing.link.label")}</Text>
|
||||||
<AdaptiveTextInput
|
<ThemedAdaptiveTextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
initialValue={initialUrl}
|
initialValue={initialUrl}
|
||||||
testID="pair-link-input"
|
testID="pair-link-input"
|
||||||
@@ -220,7 +220,6 @@ function PairLinkModalContent({
|
|||||||
accessibilityLabel={t("pairing.link.label")}
|
accessibilityLabel={t("pairing.link.label")}
|
||||||
onChangeText={handleChangeOfferUrl}
|
onChangeText={handleChangeOfferUrl}
|
||||||
placeholder="https://app.paseo.sh/#offer=..."
|
placeholder="https://app.paseo.sh/#offer=..."
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
autoFocus
|
autoFocus
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
@@ -235,7 +234,7 @@ function PairLinkModalContent({
|
|||||||
<Text style={styles.label}>
|
<Text style={styles.label}>
|
||||||
{t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })}
|
{t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })}
|
||||||
</Text>
|
</Text>
|
||||||
<AdaptiveTextInput
|
<ThemedAdaptiveTextInput
|
||||||
testID="pair-link-password-input"
|
testID="pair-link-password-input"
|
||||||
resetKey={`pair-link-password-${passwordResetKey}`}
|
resetKey={`pair-link-password-${passwordResetKey}`}
|
||||||
accessibilityLabel={t("pairing.hostPassword.label")}
|
accessibilityLabel={t("pairing.hostPassword.label")}
|
||||||
@@ -274,3 +273,9 @@ function PairLinkModalContent({
|
|||||||
</AdaptiveModalSheet>
|
</AdaptiveModalSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ThemedLink = withUnistyles(Link, (theme) => ({ color: theme.colors.accentForeground }));
|
||||||
|
|
||||||
|
const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({
|
||||||
|
placeholderTextColor: theme.colors.foregroundMuted,
|
||||||
|
}));
|
||||||
+24
-59
@@ -1,17 +1,14 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
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 { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
|
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
|
||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
import { openPairScan } from "@/hosts/pair-scan-model";
|
import { openPairScan } from "./pair-scan-model";
|
||||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { BackHeader } from "@/components/headers/back-header";
|
import { PairLinkModal } from "./pair-link-modal";
|
||||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
@@ -113,50 +110,26 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export default function PairScanScreen() {
|
export function PairScanContent({
|
||||||
const { theme } = useUnistyles();
|
onConnected,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
onConnected: (serverId: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
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 [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(() =>
|
const [scan] = useState(() =>
|
||||||
openPairScan({
|
openPairScan({
|
||||||
importConnectionLink: (link) =>
|
importConnectionLink: (link) => getHostRuntimeStore().importConnectionLink(link, "hostRoot"),
|
||||||
getHostRuntimeStore().importConnectionLink(
|
onConnected,
|
||||||
link,
|
|
||||||
source === "onboarding" ? "hostRoot" : "hostSettings",
|
|
||||||
),
|
|
||||||
onConnected: navigateToPairedHost,
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState);
|
const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState);
|
||||||
useEffect(() => () => scan.close(), [scan]);
|
useEffect(() => () => scan.close(), [scan]);
|
||||||
|
|
||||||
const closeToSource = useCallback(() => {
|
|
||||||
try {
|
|
||||||
router.back();
|
|
||||||
} catch {
|
|
||||||
router.replace("/" as Href);
|
|
||||||
}
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWeb) return;
|
if (isWeb) return;
|
||||||
if (permission && permission.granted) return;
|
if (permission && permission.granted) return;
|
||||||
@@ -169,35 +142,29 @@ export default function PairScanScreen() {
|
|||||||
[scan],
|
[scan],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRouterBack = useCallback(() => router.back(), [router]);
|
|
||||||
const savePasswordPairing = useCallback(
|
const savePasswordPairing = useCallback(
|
||||||
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
|
({ serverId }: { serverId: string }) => onConnected(serverId),
|
||||||
[navigateToPairedHost],
|
[onConnected],
|
||||||
);
|
);
|
||||||
const handleRequestPermission = useCallback(() => {
|
const handleRequestPermission = useCallback(() => {
|
||||||
void requestPermission();
|
void requestPermission();
|
||||||
}, [requestPermission]);
|
}, [requestPermission]);
|
||||||
|
|
||||||
const bodyStyle = useMemo(
|
const bodyStyle = useMemo(
|
||||||
() => [styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }],
|
() => [styles.body, { paddingBottom: insets.bottom + 24 }],
|
||||||
[insets.bottom, theme.spacing],
|
[insets.bottom],
|
||||||
);
|
|
||||||
const helperTextStyle = useMemo(
|
|
||||||
() => [styles.helperText, { color: theme.colors.foreground }],
|
|
||||||
[theme.colors.foreground],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<BackHeader title={t("pairing.scan.title")} onBack={handleRouterBack} />
|
|
||||||
<View style={bodyStyle}>
|
<View style={bodyStyle}>
|
||||||
<View style={styles.permissionCard}>
|
<View style={styles.permissionCard}>
|
||||||
<Text style={styles.permissionTitle}>{t("pairing.scan.webUnavailableTitle")}</Text>
|
<Text style={styles.permissionTitle}>{t("pairing.scan.webUnavailableTitle")}</Text>
|
||||||
<Text style={styles.permissionBody}>{t("pairing.scan.webUnavailableBody")}</Text>
|
<Text style={styles.permissionBody}>{t("pairing.scan.webUnavailableBody")}</Text>
|
||||||
<Pressable style={styles.permissionButton} onPress={closeToSource}>
|
<Button style={styles.permissionButton} onPress={onClose}>
|
||||||
<Text style={styles.permissionButtonText}>{t("pairing.scan.backToSettings")}</Text>
|
<Text style={styles.permissionButtonText}>{t("common.actions.back")}</Text>
|
||||||
</Pressable>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -208,16 +175,14 @@ export default function PairScanScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<BackHeader title={t("pairing.scan.title")} onBack={closeToSource} />
|
|
||||||
|
|
||||||
<View style={bodyStyle}>
|
<View style={bodyStyle}>
|
||||||
{!granted ? (
|
{!granted ? (
|
||||||
<View style={styles.permissionCard}>
|
<View style={styles.permissionCard}>
|
||||||
<Text style={styles.permissionTitle}>{t("pairing.scan.cameraPermissionTitle")}</Text>
|
<Text style={styles.permissionTitle}>{t("pairing.scan.cameraPermissionTitle")}</Text>
|
||||||
<Text style={styles.permissionBody}>{t("pairing.scan.cameraPermissionBody")}</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>
|
<Text style={styles.permissionButtonText}>{t("pairing.scan.grantPermission")}</Text>
|
||||||
</Pressable>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.cameraWrap}>
|
<View style={styles.cameraWrap}>
|
||||||
@@ -235,10 +200,10 @@ export default function PairScanScreen() {
|
|||||||
<View style={[styles.corner, styles.cornerBR]} />
|
<View style={[styles.corner, styles.cornerBR]} />
|
||||||
</View>
|
</View>
|
||||||
{scanState.status === "pairing" ? (
|
{scanState.status === "pairing" ? (
|
||||||
<Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text>
|
<Text style={styles.helperText}>{t("pairing.scan.pairing")}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{scanState.status === "stopped" && scanState.error ? (
|
{scanState.status === "stopped" && scanState.error ? (
|
||||||
<Text style={helperTextStyle}>{scanState.error}</Text>
|
<Text style={styles.helperText}>{scanState.error}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{scanState.status === "stopped" ? (
|
{scanState.status === "stopped" ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
|
import { useAddHost } from "@/hosts/add-host";
|
||||||
import { Modal } from "@/components/ui/modal";
|
import { Modal } from "@/components/ui/modal";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
|
||||||
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
||||||
import {
|
import {
|
||||||
EditingTextInput as TextInput,
|
EditingTextInput as TextInput,
|
||||||
type EditingTextInputHandle,
|
type EditingTextInputHandle,
|
||||||
} from "@/components/ui/text-input";
|
} from "@/components/ui/text-input";
|
||||||
import { router } from "expo-router";
|
|
||||||
import { Server } from "lucide-react-native";
|
import { Server } from "lucide-react-native";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -20,7 +19,6 @@ import {
|
|||||||
} from "@/lib/overlay-root";
|
} from "@/lib/overlay-root";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
|
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
|
||||||
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
|
|
||||||
|
|
||||||
type HostFilter = (host: HostProfile) => boolean;
|
type HostFilter = (host: HostProfile) => boolean;
|
||||||
type HostChoiceHandler = (serverId: string) => void | Promise<void>;
|
type HostChoiceHandler = (serverId: string) => void | Promise<void>;
|
||||||
@@ -65,6 +63,7 @@ function matchesHostQuery(host: HostProfile, query: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useHostChooser() {
|
export function useHostChooser() {
|
||||||
|
const addHost = useAddHost();
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
const localServerId = useLocalDaemonServerId();
|
const localServerId = useLocalDaemonServerId();
|
||||||
const open = useHostChooserStore((state) => state.open);
|
const open = useHostChooserStore((state) => state.open);
|
||||||
@@ -76,13 +75,7 @@ export function useHostChooser() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (availableHosts.length === 0) {
|
if (availableHosts.length === 0) {
|
||||||
(
|
(input.onNoHosts ?? addHost)();
|
||||||
input.onNoHosts ??
|
|
||||||
(() => {
|
|
||||||
usePanelStore.getState().showMobileAgent();
|
|
||||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
|
||||||
})
|
|
||||||
)();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +91,7 @@ export function useHostChooser() {
|
|||||||
});
|
});
|
||||||
return true;
|
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", () => {
|
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", () => {
|
it("does not expose mutable tool detail from stream state", () => {
|
||||||
const detail = { type: "read" as const, filePath: "/repo/original.ts" };
|
const detail = { type: "read" as const, filePath: "/repo/original.ts" };
|
||||||
const source: StreamItem = {
|
const source: StreamItem = {
|
||||||
|
|||||||
@@ -22,13 +22,14 @@ function cloneAndFreeze<T>(value: T): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
|
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
|
||||||
const { callId, name, status, error, detail, metadata } = data;
|
const { callId, name, status, error, detail, metadata, agentMessage } = data;
|
||||||
const base = {
|
const base = {
|
||||||
type: "tool_call" as const,
|
type: "tool_call" as const,
|
||||||
callId,
|
callId,
|
||||||
name,
|
name,
|
||||||
detail,
|
detail,
|
||||||
...(metadata ? { metadata } : {}),
|
...(metadata ? { metadata } : {}),
|
||||||
|
...(agentMessage ? { agentMessage } : {}),
|
||||||
};
|
};
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "running":
|
case "running":
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useAddHost } from "@/hosts/add-host";
|
||||||
import type {
|
import type {
|
||||||
CreateAgentRequestOptions,
|
CreateAgentRequestOptions,
|
||||||
CreateWorkspaceRequestOptions,
|
CreateWorkspaceRequestOptions,
|
||||||
@@ -8,7 +9,6 @@ import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
|||||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { router } from "expo-router";
|
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { createNameId } from "mnemonic-id";
|
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 { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||||
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
|
|
||||||
import {
|
import {
|
||||||
getHostProjectId,
|
getHostProjectId,
|
||||||
getWorktreeSupportForHostProject,
|
getWorktreeSupportForHostProject,
|
||||||
@@ -1166,9 +1165,7 @@ export function NewWorkspaceScreen({
|
|||||||
openAddProjectPicker(selectedServerId);
|
openAddProjectPicker(selectedServerId);
|
||||||
}, [openAddProjectPicker, selectedServerId]);
|
}, [openAddProjectPicker, selectedServerId]);
|
||||||
|
|
||||||
const handleAddHost = useCallback(() => {
|
const handleAddHost = useAddHost();
|
||||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const openPicker = useCallback(() => {
|
const openPicker = useCallback(() => {
|
||||||
setPickerOpen(true);
|
setPickerOpen(true);
|
||||||
|
|||||||
@@ -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 type { ComponentType, ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -74,10 +75,6 @@ import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window
|
|||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import { BackHeader } from "@/components/headers/back-header";
|
import { BackHeader } from "@/components/headers/back-header";
|
||||||
import { PageLayout } from "@/components/page-layout";
|
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 { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
|
||||||
import { EditorSection } from "@/screens/settings/editor-section";
|
import { EditorSection } from "@/screens/settings/editor-section";
|
||||||
import { FilesSection } from "@/screens/settings/files-section";
|
import { FilesSection } from "@/screens/settings/files-section";
|
||||||
@@ -1089,21 +1086,15 @@ function SettingsSidebar({
|
|||||||
|
|
||||||
export interface SettingsScreenProps {
|
export interface SettingsScreenProps {
|
||||||
view: SettingsView;
|
view: SettingsView;
|
||||||
openAddHostIntent?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsScreen({ view, openAddHostIntent = null }: SettingsScreenProps) {
|
export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const voiceAudioEngine = useVoiceAudioEngineOptional();
|
const voiceAudioEngine = useVoiceAudioEngineOptional();
|
||||||
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
|
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 [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false);
|
||||||
const [playbackTestResult, setPlaybackTestResult] = useState<string | null>(null);
|
const [playbackTestResult, setPlaybackTestResult] = useState<string | null>(null);
|
||||||
const lastOpenedAddHostIntentRef = useRef<string | null>(null);
|
|
||||||
const isDesktopApp = isElectronRuntime();
|
const isDesktopApp = isElectronRuntime();
|
||||||
const appVersion = resolveAppVersion();
|
const appVersion = resolveAppVersion();
|
||||||
const appVersionText = formatVersionWithPrefix(appVersion);
|
const appVersionText = formatVersionWithPrefix(appVersion);
|
||||||
@@ -1164,50 +1155,10 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
}
|
}
|
||||||
}, [isPlaybackTestRunning, t, voiceAudioEngine]);
|
}, [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(
|
const handleHostAdded = useCallback(
|
||||||
({ serverId }: { serverId: string }) => {
|
(serverId: string) => {
|
||||||
const target = buildSettingsHostSectionRoute(serverId, "connections");
|
selectSettingsHost(serverId);
|
||||||
|
const target = buildSettingsHostSectionRoute(serverId, "host");
|
||||||
if (isCompactLayout) {
|
if (isCompactLayout) {
|
||||||
router.push(target);
|
router.push(target);
|
||||||
} else {
|
} else {
|
||||||
@@ -1217,6 +1168,8 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
[isCompactLayout, router],
|
[isCompactLayout, router],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleAddHost = useAddHost({ onAdded: handleHostAdded });
|
||||||
|
|
||||||
const handleSelectSection = useCallback(
|
const handleSelectSection = useCallback(
|
||||||
(section: SettingsSectionSlug) => {
|
(section: SettingsSectionSlug) => {
|
||||||
const target = buildSettingsSectionRoute(section);
|
const target = buildSettingsSectionRoute(section);
|
||||||
@@ -1272,14 +1225,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
[activeHostServerId, handleAddHost, isCompactLayout, router],
|
[activeHostServerId, handleAddHost, isCompactLayout, router],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleScanQr = useCallback(() => {
|
|
||||||
closeAddConnectionFlow();
|
|
||||||
router.push({
|
|
||||||
pathname: "/pair-scan",
|
|
||||||
params: { source: "settings" },
|
|
||||||
});
|
|
||||||
}, [closeAddConnectionFlow, router]);
|
|
||||||
|
|
||||||
const handleHostRemoved = useCallback(() => {
|
const handleHostRemoved = useCallback(() => {
|
||||||
const fallback = buildSettingsSectionRoute("general");
|
const fallback = buildSettingsSectionRoute("general");
|
||||||
if (isCompactLayout) {
|
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.
|
// Mobile root: full-screen sidebar-as-list.
|
||||||
if (isCompactLayout && view.kind === "root") {
|
if (isCompactLayout && view.kind === "root") {
|
||||||
return (
|
return (
|
||||||
@@ -1436,7 +1350,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
layout="mobile"
|
layout="mobile"
|
||||||
/>
|
/>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
{addHostModals}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1447,7 +1360,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
<PageLayout title={detailTitle} onBack={handleBackFromDetail}>
|
<PageLayout title={detailTitle} onBack={handleBackFromDetail}>
|
||||||
{content}
|
{content}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
{addHostModals}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1482,7 +1394,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
</View>
|
</View>
|
||||||
</WindowChromeRegion>
|
</WindowChromeRegion>
|
||||||
</View>
|
</View>
|
||||||
{addHostModals}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -717,11 +717,13 @@ describe("terminal emulator runtime in a real browser", () => {
|
|||||||
{ name: "DA1-zero", bytes: "\x1b[0c" },
|
{ name: "DA1-zero", bytes: "\x1b[0c" },
|
||||||
{ name: "DA2", bytes: "\x1b[>c" },
|
{ name: "DA2", bytes: "\x1b[>c" },
|
||||||
{ name: "DA3", bytes: "\x1b[=c" },
|
{ name: "DA3", bytes: "\x1b[=c" },
|
||||||
|
{ name: "XTVERSION", bytes: "\x1b[>q" },
|
||||||
{ name: "DSR-5", bytes: "\x1b[5n" },
|
{ name: "DSR-5", bytes: "\x1b[5n" },
|
||||||
{ name: "DSR-6", bytes: "\x1b[6n" },
|
{ name: "DSR-6", bytes: "\x1b[6n" },
|
||||||
{ name: "DSR-?6", bytes: "\x1b[?6n" },
|
{ name: "DSR-?6", bytes: "\x1b[?6n" },
|
||||||
{ name: "DECRQM", bytes: "\x1b[1$p" },
|
{ name: "DECRQM", bytes: "\x1b[1$p" },
|
||||||
{ name: "DECRQM-?", bytes: "\x1b[?1$p" },
|
{ name: "DECRQM-?", bytes: "\x1b[?1$p" },
|
||||||
|
{ name: "DECRQSS", bytes: "\x1bP$qm\x1b\\" },
|
||||||
{ name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" },
|
{ name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" },
|
||||||
{ name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" },
|
{ name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" },
|
||||||
{ name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" },
|
{ name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" },
|
||||||
|
|||||||
@@ -647,6 +647,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
terminal.parser.registerCsiHandler({ final: "c" }, () => true);
|
terminal.parser.registerCsiHandler({ final: "c" }, () => true);
|
||||||
terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true);
|
terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true);
|
||||||
terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true);
|
terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true);
|
||||||
|
terminal.parser.registerCsiHandler({ prefix: ">", final: "q" }, () => true);
|
||||||
terminal.parser.registerCsiHandler({ final: "n" }, () => true);
|
terminal.parser.registerCsiHandler({ final: "n" }, () => true);
|
||||||
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true);
|
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true);
|
||||||
terminal.parser.registerCsiHandler({ final: "R" }, () => true);
|
terminal.parser.registerCsiHandler({ final: "R" }, () => true);
|
||||||
@@ -655,6 +656,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
{ prefix: "?", intermediates: "$", final: "p" },
|
{ prefix: "?", intermediates: "$", final: "p" },
|
||||||
() => true,
|
() => true,
|
||||||
);
|
);
|
||||||
|
terminal.parser.registerDcsHandler({ intermediates: "$", final: "q" }, () => true);
|
||||||
for (const code of [10, 11, 12]) {
|
for (const code of [10, 11, 12]) {
|
||||||
terminal.parser.registerOscHandler(code, (data) => data.trim() === "?");
|
terminal.parser.registerOscHandler(code, (data) => data.trim() === "?");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
buildOpenProjectRoute,
|
buildOpenProjectRoute,
|
||||||
resolveKnownHostRoute,
|
resolveKnownHostRoute,
|
||||||
buildSessionsRoute,
|
buildSessionsRoute,
|
||||||
buildSettingsAddHostRoute,
|
|
||||||
buildProjectSettingsRoute,
|
buildProjectSettingsRoute,
|
||||||
buildProjectsSettingsRoute,
|
buildProjectsSettingsRoute,
|
||||||
decodeFilePathFromPathSegment,
|
decodeFilePathFromPathSegment,
|
||||||
@@ -185,14 +184,6 @@ describe("workspace route parsing", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("projects settings routes", () => {
|
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", () => {
|
it("buildProjectsSettingsRoute scopes the list to a host", () => {
|
||||||
expect(buildProjectsSettingsRoute("host a")).toBe("/settings/hosts/host%20a/projects");
|
expect(buildProjectsSettingsRoute("host a")).toBe("/settings/hosts/host%20a/projects");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -553,10 +553,6 @@ export function buildSettingsSectionRoute(section: SettingsSectionSlug) {
|
|||||||
return `/settings/${section}` as const;
|
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) {
|
export function buildSettingsHostRoute(serverId: string) {
|
||||||
const normalized = trimNonEmpty(serverId);
|
const normalized = trimNonEmpty(serverId);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
|
|||||||
@@ -204,15 +204,16 @@ export function createHostTransportFactory(
|
|||||||
}
|
}
|
||||||
export function createHostClient(
|
export function createHostClient(
|
||||||
connection: string,
|
connection: string,
|
||||||
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions,
|
config: Omit<DaemonClientConfig, "url"> & HostTransportOptions,
|
||||||
budget?: HostTransportBudget,
|
budget?: HostTransportBudget,
|
||||||
): DaemonClient {
|
): DaemonClient {
|
||||||
const target = parseHostTarget(connection);
|
const target = parseHostTarget(connection);
|
||||||
const { webSocketAgent, ...clientConfig } = config;
|
const { webSocketAgent, transportFactory, ...clientConfig } = config;
|
||||||
return new DaemonClient({
|
return new DaemonClient({
|
||||||
...clientConfig,
|
...clientConfig,
|
||||||
url: "ws://host-connector/ws",
|
url: "ws://host-connector/ws",
|
||||||
...(target.password ? { password: target.password } : {}),
|
...(target.password ? { password: target.password } : {}),
|
||||||
transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }),
|
transportFactory:
|
||||||
|
transportFactory ?? createHostTransportFactory(connection, budget, { webSocketAgent }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { experimental_createMCPClient } from "ai";
|
import { experimental_createMCPClient } from "ai";
|
||||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||||
import { chromium } from "playwright";
|
import { chromium } from "playwright";
|
||||||
|
import { expect } from "@playwright/test";
|
||||||
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
|
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
|
||||||
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
|
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
|
||||||
import { runAdaptiveThemeRegression } from "./appearance-theme.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() {
|
async function startTargetPage() {
|
||||||
const server = createServer((_request, response) => {
|
const server = createServer((_request, response) => {
|
||||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||||
@@ -1208,6 +1223,7 @@ async function main() {
|
|||||||
const page = await waitForAppPage(browser, expoPort);
|
const page = await waitForAppPage(browser, expoPort);
|
||||||
await hideDeveloperToasts(page);
|
await hideDeveloperToasts(page);
|
||||||
const status = await waitForDesktopStatus(page);
|
const status = await waitForDesktopStatus(page);
|
||||||
|
await settleDesktopStartup(page);
|
||||||
|
|
||||||
const checkPluginLinks = () =>
|
const checkPluginLinks = () =>
|
||||||
runPluginLinksRegression({
|
runPluginLinksRegression({
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
"@types/node": "24.6.0",
|
"@types/node": "24.6.0",
|
||||||
"@types/ws": "^8.5.14",
|
"@types/ws": "^8.5.14",
|
||||||
"electron": "44.2.0",
|
"electron": "44.2.0",
|
||||||
"electron-builder": "26.8.1",
|
"electron-builder": "26.18.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"unzip-crx-3": "^0.2.0",
|
"unzip-crx-3": "^0.2.0",
|
||||||
"vitest": "^4.1.6",
|
"vitest": "^4.1.6",
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||||
"test": "npm run test:unit && npm run test:integration",
|
"test": "npm run test:unit && npm run test:integration",
|
||||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/create-agent-mode.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||||
"test:integration:all": "npm run test:e2e",
|
"test:integration:all": "npm run test:e2e",
|
||||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
availableModes: OPENCODE_MODES,
|
availableModes: OPENCODE_MODES,
|
||||||
}),
|
}),
|
||||||
).toThrow(
|
).toThrow(
|
||||||
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, plan",
|
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode in settings.modeId. Available modes for 'opencode': build, plan",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
availableModes: CODEX_MODES,
|
availableModes: CODEX_MODES,
|
||||||
}),
|
}),
|
||||||
).toThrow(
|
).toThrow(
|
||||||
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
|
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
targetUnattendedMode: "full-access",
|
targetUnattendedMode: "full-access",
|
||||||
}),
|
}),
|
||||||
).toThrow(
|
).toThrow(
|
||||||
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
|
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
targetUnattendedMode: undefined,
|
targetUnattendedMode: undefined,
|
||||||
}),
|
}),
|
||||||
).toThrow(
|
).toThrow(
|
||||||
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode. Available modes for 'zai-custom': unknown",
|
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode in settings.modeId. Available modes for 'zai-custom': unknown",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function resolveAndValidateCreateAgentMode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
|
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode in settings.modeId. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||||
|
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
|
||||||
|
import { ClaudeAgentClient } from "./agent.js";
|
||||||
|
import type { ClaudeQueryFactory } from "./query.js";
|
||||||
|
|
||||||
|
const LIMIT = "You've hit your session limit · resets 5:20pm (Asia/Singapore)";
|
||||||
|
|
||||||
|
interface ScriptedTurn {
|
||||||
|
// An API error reply as Claude Code writes it: a synthetic assistant message with `error`.
|
||||||
|
errorReply?: string;
|
||||||
|
// Assistant text the model wrote before or after it.
|
||||||
|
before?: string;
|
||||||
|
after?: string;
|
||||||
|
isError: boolean;
|
||||||
|
usage?: {
|
||||||
|
inputTokens: number;
|
||||||
|
cachedInputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
totalCostUsd: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A query dependency that speaks Claude Code's stream interface, one scripted turn per prompt.
|
||||||
|
function scriptedQuery(turns: ScriptedTurn[]): ClaudeQueryFactory {
|
||||||
|
return ({ prompt }) => {
|
||||||
|
let index = 0;
|
||||||
|
const assistant = (id: string, text: string, error?: string) => ({
|
||||||
|
type: "assistant",
|
||||||
|
session_id: "error-session",
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
uuid: id,
|
||||||
|
message: {
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
model: error ? "<synthetic>" : "claude-opus-5-5",
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
stop_reason: "stop_sequence",
|
||||||
|
usage: { input_tokens: 0, output_tokens: 0 },
|
||||||
|
},
|
||||||
|
...(error ? { error } : {}),
|
||||||
|
});
|
||||||
|
const messages = (async function* () {
|
||||||
|
if (typeof prompt === "string") throw new Error("Expected streaming input");
|
||||||
|
for await (const _message of prompt) {
|
||||||
|
const turn = turns[index];
|
||||||
|
index += 1;
|
||||||
|
yield {
|
||||||
|
type: "system",
|
||||||
|
subtype: "init",
|
||||||
|
session_id: "error-session",
|
||||||
|
permissionMode: "default",
|
||||||
|
model: "claude-opus-5-5",
|
||||||
|
};
|
||||||
|
if (turn.before) yield assistant(`before-${index}`, turn.before);
|
||||||
|
if (turn.errorReply) yield assistant(`error-${index}`, turn.errorReply, "rate_limit");
|
||||||
|
if (turn.after) yield assistant(`after-${index}`, turn.after);
|
||||||
|
yield {
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
session_id: "error-session",
|
||||||
|
duration_ms: 1,
|
||||||
|
duration_api_ms: 0,
|
||||||
|
is_error: turn.isError,
|
||||||
|
num_turns: 1,
|
||||||
|
result: turn.isError ? (turn.errorReply ?? "") : (turn.after ?? turn.errorReply ?? ""),
|
||||||
|
stop_reason: "stop_sequence",
|
||||||
|
total_cost_usd: turn.usage?.totalCostUsd ?? 0,
|
||||||
|
usage: {
|
||||||
|
input_tokens: turn.usage?.inputTokens ?? 0,
|
||||||
|
output_tokens: turn.usage?.outputTokens ?? 0,
|
||||||
|
cache_read_input_tokens: turn.usage?.cachedInputTokens ?? 0,
|
||||||
|
},
|
||||||
|
modelUsage: {},
|
||||||
|
permission_denials: [],
|
||||||
|
uuid: `result-${index}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return Object.assign(messages, {
|
||||||
|
close: () => {
|
||||||
|
void messages.return();
|
||||||
|
},
|
||||||
|
interrupt: async () => undefined,
|
||||||
|
setModel: async () => undefined,
|
||||||
|
setPermissionMode: async () => undefined,
|
||||||
|
supportedModels: async () => [],
|
||||||
|
supportedCommands: async () => [],
|
||||||
|
}) as ReturnType<ClaudeQueryFactory>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTurns(turns: ScriptedTurn[]) {
|
||||||
|
const client = new ClaudeAgentClient({
|
||||||
|
logger: createTestLogger(),
|
||||||
|
queryFactory: scriptedQuery(turns),
|
||||||
|
resolveBinary: async () => process.execPath,
|
||||||
|
});
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "claude",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const results = [];
|
||||||
|
for (const [index] of turns.entries()) {
|
||||||
|
results.push(await collectSessionTurnEvents(session, `turn ${index + 1}`));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantTexts(events: Awaited<ReturnType<typeof runTurns>>[number]): string[] {
|
||||||
|
return events.flatMap((event) =>
|
||||||
|
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminal(events: Awaited<ReturnType<typeof runTurns>>[number]) {
|
||||||
|
return events.find(
|
||||||
|
(event) =>
|
||||||
|
event.type === "turn_completed" ||
|
||||||
|
event.type === "turn_failed" ||
|
||||||
|
event.type === "turn_canceled",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a turn Claude Code ends on an API error fails with the error, shown once", async () => {
|
||||||
|
const [limited, overloaded, next] = await runTurns([
|
||||||
|
{ errorReply: LIMIT, isError: true },
|
||||||
|
{
|
||||||
|
before: "Checking the deploy doc.",
|
||||||
|
errorReply: "API Error: 529 Overloaded",
|
||||||
|
isError: true,
|
||||||
|
},
|
||||||
|
{ after: "Done.", isError: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(terminal(limited)).toMatchObject({
|
||||||
|
type: "turn_failed",
|
||||||
|
error: LIMIT,
|
||||||
|
});
|
||||||
|
expect(assistantTexts(limited)).toEqual([]);
|
||||||
|
|
||||||
|
expect(terminal(overloaded)).toMatchObject({
|
||||||
|
type: "turn_failed",
|
||||||
|
error: "API Error: 529 Overloaded",
|
||||||
|
});
|
||||||
|
expect(assistantTexts(overloaded)).toEqual(["Checking the deploy doc."]);
|
||||||
|
|
||||||
|
// The session keeps working after a failed turn.
|
||||||
|
expect(terminal(next)).toMatchObject({ type: "turn_completed" });
|
||||||
|
expect(assistantTexts(next)).toEqual(["Done."]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed turn retains its final token counts and cost", async () => {
|
||||||
|
const usage = {
|
||||||
|
inputTokens: 120,
|
||||||
|
cachedInputTokens: 80,
|
||||||
|
outputTokens: 30,
|
||||||
|
totalCostUsd: 0.012,
|
||||||
|
};
|
||||||
|
const [failed] = await runTurns([
|
||||||
|
{ before: "Checking the deploy doc.", errorReply: LIMIT, isError: true, usage },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(failed).toContainEqual(
|
||||||
|
expect.objectContaining({ type: "usage_updated", usage: expect.objectContaining(usage) }),
|
||||||
|
);
|
||||||
|
expect(terminal(failed)).toMatchObject({ type: "turn_failed", error: LIMIT });
|
||||||
|
expect(assistantTexts(failed)).toEqual(["Checking the deploy doc."]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an error reply the turn moves past is still shown", async () => {
|
||||||
|
const [recovered, completed] = await runTurns([
|
||||||
|
{
|
||||||
|
errorReply: "API Error: 529 Overloaded",
|
||||||
|
after: "Recovered.",
|
||||||
|
isError: false,
|
||||||
|
},
|
||||||
|
{ errorReply: LIMIT, isError: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(terminal(recovered)).toMatchObject({ type: "turn_completed" });
|
||||||
|
expect(assistantTexts(recovered)).toEqual(["API Error: 529 Overloaded", "Recovered."]);
|
||||||
|
|
||||||
|
expect(terminal(completed)).toMatchObject({ type: "turn_completed" });
|
||||||
|
expect(assistantTexts(completed)).toEqual([LIMIT]);
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { constants as bufferConstants } from "node:buffer";
|
||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
@@ -236,4 +237,64 @@ describe("importing a renamed Claude session", () => {
|
|||||||
|
|
||||||
await expect(importSession(sessionId)).rejects.toThrow();
|
await expect(importSession(sessionId)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reads the rename from a transcript too large to hold as one string", async () => {
|
||||||
|
const sessionId = "oversized-transcript";
|
||||||
|
const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir });
|
||||||
|
await fs.mkdir(projectDir, { recursive: true });
|
||||||
|
const transcript = path.join(projectDir, `${sessionId}.jsonl`);
|
||||||
|
// A hole stands in for the long session this used to fail on: the file only
|
||||||
|
// has to be longer than a string can be, and the rename sits at its end.
|
||||||
|
await fs.writeFile(transcript, "");
|
||||||
|
await fs.truncate(transcript, bufferConstants.MAX_STRING_LENGTH + 1);
|
||||||
|
await fs.appendFile(
|
||||||
|
transcript,
|
||||||
|
`\n${[
|
||||||
|
userRecord({ sessionId, content: "Trace the slow query" }),
|
||||||
|
{ type: "custom-title", customTitle: "Slow query hunt", sessionId },
|
||||||
|
]
|
||||||
|
.map((record) => JSON.stringify(record))
|
||||||
|
.join("\n")}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const imported = await importSession(sessionId);
|
||||||
|
|
||||||
|
expect(imported.config.title).toBe("Slow query hunt");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads the rename from behind more records than one read covers", async () => {
|
||||||
|
const sessionId = "late-records-rename";
|
||||||
|
await writeSession({
|
||||||
|
sessionId,
|
||||||
|
records: [
|
||||||
|
userRecord({ sessionId, content: "Review this project" }),
|
||||||
|
{ type: "custom-title", customTitle: "Scrollback review", sessionId },
|
||||||
|
// One record longer than a single read, then enough records to leave the
|
||||||
|
// rename several reads back from the end of the file.
|
||||||
|
userRecord({ sessionId, content: "x".repeat(128 * 1024) }),
|
||||||
|
...Array.from({ length: 64 }, (_, index) =>
|
||||||
|
userRecord({ sessionId, content: `${"y".repeat(4 * 1024)} ${index}` }),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const imported = await importSession(sessionId);
|
||||||
|
|
||||||
|
expect(imported.config.title).toBe("Scrollback review");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("imports a large record after the last rename without repeatedly copying it", async () => {
|
||||||
|
const sessionId = "large-record-after-rename";
|
||||||
|
await writeSession({
|
||||||
|
sessionId,
|
||||||
|
records: [
|
||||||
|
{ type: "custom-title", customTitle: "Large attachment review", sessionId },
|
||||||
|
userRecord({ sessionId, content: "x".repeat(96 * 1024 * 1024) }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const imported = await importSession(sessionId);
|
||||||
|
|
||||||
|
expect(imported.config.title).toBe("Large attachment review");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import pino from "pino";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { ClaudeAgentClient } from "./agent.js";
|
||||||
|
|
||||||
|
describe("Claude runtime info before a turn", () => {
|
||||||
|
const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) });
|
||||||
|
|
||||||
|
test("reports the explicitly configured model before the first turn", async () => {
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "claude",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
model: "claude-haiku-5-5",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(await session.getRuntimeInfo()).toMatchObject({ model: "claude-haiku-5-5" });
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports the persisted configured model after rebuilding an idle session", async () => {
|
||||||
|
const session = await client.resumeSession({
|
||||||
|
provider: "claude",
|
||||||
|
sessionId: "runtime-model-before-turn",
|
||||||
|
metadata: { cwd: process.cwd(), model: "claude-haiku-5-5" },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(await session.getRuntimeInfo()).toMatchObject({
|
||||||
|
sessionId: "runtime-model-before-turn",
|
||||||
|
model: "claude-haiku-5-5",
|
||||||
|
});
|
||||||
|
expect((await session.getRuntimeInfo()).model).toBe("claude-haiku-5-5");
|
||||||
|
expect((await session.getRuntimeInfo()).extra?.runtimeModel).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves the model unknown when Claude will choose its default", async () => {
|
||||||
|
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
|
||||||
|
try {
|
||||||
|
expect((await session.getRuntimeInfo()).model).toBeNull();
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2389,6 +2389,12 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private mainTurnInFlight = false;
|
private mainTurnInFlight = false;
|
||||||
private foregroundHasVisibleActivity = false;
|
private foregroundHasVisibleActivity = false;
|
||||||
private activeTurnHasAssistantText = false;
|
private activeTurnHasAssistantText = false;
|
||||||
|
/**
|
||||||
|
* The reply Claude Code writes when a turn stops on an API error ("You've hit your session
|
||||||
|
* limit", "API Error: 529 ..."), held back until the result says whether the turn failed: a
|
||||||
|
* failed turn shows the same text as its error, so showing both would repeat it.
|
||||||
|
*/
|
||||||
|
private withheldErrorReply: AgentTimelineItem[] = [];
|
||||||
private readonly contextUsage: ClaudeContextUsageState;
|
private readonly contextUsage: ClaudeContextUsageState;
|
||||||
private userMessageIds: string[] = [];
|
private userMessageIds: string[] = [];
|
||||||
private readonly emittedUserMessageIds = new Set<string>();
|
private readonly emittedUserMessageIds = new Set<string>();
|
||||||
@@ -2460,7 +2466,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
const info: AgentRuntimeInfo = {
|
const info: AgentRuntimeInfo = {
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
sessionId: this.claudeSessionId,
|
sessionId: this.claudeSessionId,
|
||||||
model: this.lastOptionsModel,
|
model: this.lastOptionsModel ?? this.config.model ?? null,
|
||||||
modeId: this.currentMode ?? null,
|
modeId: this.currentMode ?? null,
|
||||||
...(this.lastRuntimeModel
|
...(this.lastRuntimeModel
|
||||||
? {
|
? {
|
||||||
@@ -2531,6 +2537,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.activeForegroundTurnId = turnId;
|
this.activeForegroundTurnId = turnId;
|
||||||
this.foregroundHasVisibleActivity = false;
|
this.foregroundHasVisibleActivity = false;
|
||||||
this.activeTurnHasAssistantText = false;
|
this.activeTurnHasAssistantText = false;
|
||||||
|
this.withheldErrorReply = [];
|
||||||
this.contextUsage.beginTurn();
|
this.contextUsage.beginTurn();
|
||||||
this.transitionTurnState("foreground", "foreground turn started");
|
this.transitionTurnState("foreground", "foreground turn started");
|
||||||
this.clearRecentStderr();
|
this.clearRecentStderr();
|
||||||
@@ -4419,6 +4426,21 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}) satisfies AgentStreamEvent,
|
}) satisfies AgentStreamEvent,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!readClaudeParentToolUseId(message)) {
|
||||||
|
if (message.type === "assistant" && message.error) {
|
||||||
|
this.withheldErrorReply.push(...assistantTimelineEvents.map((event) => event.item));
|
||||||
|
return messageEvents;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
message.type === "assistant" ||
|
||||||
|
message.type === "user" ||
|
||||||
|
message.type === "stream_event"
|
||||||
|
) {
|
||||||
|
const released: AgentStreamEvent[] = [];
|
||||||
|
this.releaseWithheldErrorReply(released);
|
||||||
|
return [...released, ...messageEvents, ...assistantTimelineEvents];
|
||||||
|
}
|
||||||
|
}
|
||||||
return [...messageEvents, ...assistantTimelineEvents];
|
return [...messageEvents, ...assistantTimelineEvents];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4966,6 +4988,23 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
events: AgentStreamEvent[],
|
events: AgentStreamEvent[],
|
||||||
): void {
|
): void {
|
||||||
const usage = this.convertUsage(message);
|
const usage = this.convertUsage(message);
|
||||||
|
// Claude Code reports a turn that stopped on an API error (a usage limit, an overloaded or
|
||||||
|
// rejected request) as `success` with `is_error`, carrying the error text in `result`.
|
||||||
|
if (message.subtype === "success" && message.is_error) {
|
||||||
|
const withheld = this.withheldErrorReply;
|
||||||
|
this.withheldErrorReply = [];
|
||||||
|
const resultText = typeof message.result === "string" ? message.result.trim() : "";
|
||||||
|
const withheldText = withheld
|
||||||
|
.map((item) => (item.type === "assistant_message" ? item.text : ""))
|
||||||
|
.join("\n")
|
||||||
|
.trim();
|
||||||
|
if (usage) events.push({ type: "usage_updated", provider: "claude", usage });
|
||||||
|
events.push(...this.sidechainTracker.finishAll("failed"));
|
||||||
|
events.push(this.buildTurnFailedEvent(resultText || withheldText || "Claude run failed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const releasedErrorReply = this.withheldErrorReply.length > 0;
|
||||||
|
this.releaseWithheldErrorReply(events);
|
||||||
if (message.subtype === "success") {
|
if (message.subtype === "success") {
|
||||||
events.push(...this.sidechainTracker.finishAll("completed"));
|
events.push(...this.sidechainTracker.finishAll("completed"));
|
||||||
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
|
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
|
||||||
@@ -4975,7 +5014,12 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
// accounting from provider gateways does not duplicate streamed output.
|
// accounting from provider gateways does not duplicate streamed output.
|
||||||
const resultText = typeof message.result === "string" ? message.result.trim() : "";
|
const resultText = typeof message.result === "string" ? message.result.trim() : "";
|
||||||
const outputTokens = message.usage?.output_tokens;
|
const outputTokens = message.usage?.output_tokens;
|
||||||
if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) {
|
if (
|
||||||
|
resultText.length > 0 &&
|
||||||
|
outputTokens === 0 &&
|
||||||
|
!this.activeTurnHasAssistantText &&
|
||||||
|
!releasedErrorReply
|
||||||
|
) {
|
||||||
events.push({
|
events.push({
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
@@ -4997,6 +5041,14 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
events.push(this.buildTurnFailedEvent(errorMessage));
|
events.push(this.buildTurnFailedEvent(errorMessage));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private releaseWithheldErrorReply(events: AgentStreamEvent[]): void {
|
||||||
|
if (this.withheldErrorReply.length === 0) return;
|
||||||
|
for (const item of this.withheldErrorReply) {
|
||||||
|
events.push({ type: "timeline", item, provider: "claude" });
|
||||||
|
}
|
||||||
|
this.withheldErrorReply = [];
|
||||||
|
}
|
||||||
|
|
||||||
private createClaudeSessionChangedNotice(
|
private createClaudeSessionChangedNotice(
|
||||||
oldSessionId: string,
|
oldSessionId: string,
|
||||||
newSessionId: string,
|
newSessionId: string,
|
||||||
@@ -6853,40 +6905,79 @@ function normalizeClaudeSessionTitle(title: string | null): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title";
|
const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title";
|
||||||
|
const CLAUDE_CUSTOM_TITLE_NEEDLE = Buffer.from(`"${CLAUDE_CUSTOM_TITLE_TYPE}"`);
|
||||||
|
const CLAUDE_TITLE_SCAN_CHUNK_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
function readClaudeCustomTitleFromRecord(record: Buffer): string | null {
|
||||||
|
// The needle also matches a prompt that quotes it, so a record still has to
|
||||||
|
// parse as a `custom-title` entry carrying a usable name. Records without the
|
||||||
|
// needle are never decoded, so a long one costs a byte search and nothing more.
|
||||||
|
if (!record.includes(CLAUDE_CUSTOM_TITLE_NEEDLE)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(record.toString("utf8").trim());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const entry = toObjectRecord(parsed);
|
||||||
|
if (!entry || entry.isSidechain || entry.type !== CLAUDE_CUSTOM_TITLE_TYPE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalizeClaudeSessionTitle(
|
||||||
|
typeof entry.customTitle === "string" ? entry.customTitle : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `custom-title` record is re-emitted every time the session is saved, so
|
* The `custom-title` record is re-emitted every time the session is saved, so
|
||||||
* the current name is the last usable one in the file. Searching backwards finds it without
|
* the current name is the last usable one in the file. Reading backwards a chunk
|
||||||
* walking the transcript, which matters because sessions reach tens of
|
* at a time finds it without walking the transcript, which matters because
|
||||||
* megabytes.
|
* sessions reach tens of megabytes, and never holds the file as one string, so a
|
||||||
|
* transcript past V8's maximum string length still imports.
|
||||||
*/
|
*/
|
||||||
function readLastClaudeCustomTitle(content: string): string | null {
|
async function readLastClaudeCustomTitle(filePath: string): Promise<string | null> {
|
||||||
const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`;
|
const handle = await fsPromises.open(filePath, "r");
|
||||||
let at = content.lastIndexOf(needle);
|
try {
|
||||||
while (at >= 0) {
|
const stats = await handle.stat();
|
||||||
const lineStart = content.lastIndexOf("\n", at) + 1;
|
if (!stats.isFile()) {
|
||||||
const lineEnd = content.indexOf("\n", at);
|
// Opening a directory succeeds on Windows even though reading it does not,
|
||||||
const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd);
|
// so reject the way reading the transcript as a file would.
|
||||||
let parsed: unknown;
|
throw Object.assign(
|
||||||
try {
|
new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`),
|
||||||
parsed = JSON.parse(line.trim());
|
{ code: "EISDIR", path: filePath },
|
||||||
} catch {
|
|
||||||
parsed = undefined;
|
|
||||||
}
|
|
||||||
const entry = toObjectRecord(parsed);
|
|
||||||
if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) {
|
|
||||||
const title = normalizeClaudeSessionTitle(
|
|
||||||
typeof entry.customTitle === "string" ? entry.customTitle : null,
|
|
||||||
);
|
);
|
||||||
if (title) {
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// The needle also matches a prompt that quotes it, and a record can carry no
|
let end = stats.size;
|
||||||
// usable name, so keep looking backwards for one that does.
|
// Retain unfinished record pieces in reverse file order. Assemble each
|
||||||
at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1);
|
// complete record once, so a large tool/image record costs linear work.
|
||||||
|
let pieces: Buffer[] = [];
|
||||||
|
while (end > 0) {
|
||||||
|
const length = Math.min(CLAUDE_TITLE_SCAN_CHUNK_BYTES, end);
|
||||||
|
const chunk = Buffer.alloc(length);
|
||||||
|
await handle.read(chunk, 0, length, end - length);
|
||||||
|
let recordEnd = chunk.length;
|
||||||
|
for (
|
||||||
|
let at = chunk.lastIndexOf(10);
|
||||||
|
at >= 0;
|
||||||
|
at = at === 0 ? -1 : chunk.lastIndexOf(10, at - 1)
|
||||||
|
) {
|
||||||
|
pieces.push(chunk.subarray(at + 1, recordEnd));
|
||||||
|
const record = Buffer.concat(pieces.toReversed());
|
||||||
|
const title = readClaudeCustomTitleFromRecord(record);
|
||||||
|
if (title) return title;
|
||||||
|
pieces = [];
|
||||||
|
recordEnd = at;
|
||||||
|
}
|
||||||
|
if (recordEnd > 0) pieces.push(chunk.subarray(0, recordEnd));
|
||||||
|
end -= length;
|
||||||
|
}
|
||||||
|
// The first record in the file has no newline in front of it.
|
||||||
|
return readClaudeCustomTitleFromRecord(Buffer.concat(pieces.toReversed()));
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
||||||
@@ -6898,9 +6989,8 @@ async function readClaudeRenamedSessionTitle(input: {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
configDir: string;
|
configDir: string;
|
||||||
}): Promise<string | null> {
|
}): Promise<string | null> {
|
||||||
let content: string;
|
|
||||||
try {
|
try {
|
||||||
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
|
return await readLastClaudeCustomTitle(await claudeTranscriptPath(input));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// A session with no transcript on disk has no rename to carry over. Every
|
// A session with no transcript on disk has no rename to carry over. Every
|
||||||
// other read failure is a real fault and stays explicit.
|
// other read failure is a real fault and stays explicit.
|
||||||
@@ -6909,7 +6999,6 @@ async function readClaudeRenamedSessionTitle(input: {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return readLastClaudeCustomTitle(content);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeImportablePromptPreview(text: string): string | null {
|
function normalizeImportablePromptPreview(text: string): string | null {
|
||||||
|
|||||||
@@ -150,6 +150,15 @@ function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolea
|
|||||||
|
|
||||||
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
||||||
const INTERRUPT_TIMEOUT_MS = 2_000;
|
const INTERRUPT_TIMEOUT_MS = 2_000;
|
||||||
|
// `turn/interrupt` ends a turn but leaves the shell commands it started running.
|
||||||
|
// Each terminate is bounded by the first constant. A turn start waits for the
|
||||||
|
// interrupted turn's commands to end, at most the interrupt's own bound plus one
|
||||||
|
// terminate's and a margin, so it gives up only when a request outlives its timeout.
|
||||||
|
const INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS = 2_000;
|
||||||
|
const INTERRUPTED_TURN_CLEANUP_WAIT_MS =
|
||||||
|
INTERRUPT_TIMEOUT_MS + INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS + 500;
|
||||||
|
// Turns whose commands are remembered; older turns are forgotten first.
|
||||||
|
const INTERRUPTED_TURN_TRACKED_TURNS = 16;
|
||||||
const CODEX_PROVIDER = "codex" as const;
|
const CODEX_PROVIDER = "codex" as const;
|
||||||
// These native provider identities belong to Codex, not to an older custom
|
// These native provider identities belong to Codex, not to an older custom
|
||||||
// Paseo provider. Never include them in a custom provider's legacy import scan.
|
// Paseo provider. Never include them in a custom provider's legacy import scan.
|
||||||
@@ -3476,6 +3485,14 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
|
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
|
||||||
private pendingAssistantMessageBoundary = false;
|
private pendingAssistantMessageBoundary = false;
|
||||||
private terminalCommandByProcessId = new Map<string, string>();
|
private terminalCommandByProcessId = new Map<string, string>();
|
||||||
|
// The root thread's still-running command process ids, keyed by the native turn that
|
||||||
|
// started them. Codex's `thread/backgroundTerminals/list` carries no turn, so
|
||||||
|
// `item/started` is the only record of which turn owns a process.
|
||||||
|
private terminalProcessIdsByTurnId = new Map<
|
||||||
|
string,
|
||||||
|
{ processIds: Set<string>; terminatedProcessIds: Set<string>; interruptAccepted: boolean }
|
||||||
|
>();
|
||||||
|
private interruptedTurnCleanup: Promise<void> | null = null;
|
||||||
private pendingUnlabeledTerminalInteractions = new Map<
|
private pendingUnlabeledTerminalInteractions = new Map<
|
||||||
string,
|
string,
|
||||||
Array<{ callId: string; stdin: string | null }>
|
Array<{ callId: string; stdin: string | null }>
|
||||||
@@ -4431,6 +4448,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
|
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
|
||||||
hasCodexConfig: turnStart.hasCodexConfig,
|
hasCodexConfig: turnStart.hasCodexConfig,
|
||||||
});
|
});
|
||||||
|
// The interrupted turn's commands end before this turn begins, so none of them
|
||||||
|
// outlives it into the new turn.
|
||||||
|
await this.waitForInterruptedTurnCleanup();
|
||||||
if (pendingStart.cancelRequested) {
|
if (pendingStart.cancelRequested) {
|
||||||
throw new Error("Codex turn start was interrupted before reaching Codex");
|
throw new Error("Codex turn start was interrupted before reaching Codex");
|
||||||
}
|
}
|
||||||
@@ -5036,15 +5056,27 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) {
|
if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) {
|
||||||
throw new Error("Cannot interrupt Codex before turn/started identifies the active turn");
|
throw new Error("Cannot interrupt Codex before turn/started identifies the active turn");
|
||||||
}
|
}
|
||||||
|
const client = this.client;
|
||||||
|
const threadId = this.currentThreadId;
|
||||||
|
const interruptRequest = client.request(
|
||||||
|
"turn/interrupt",
|
||||||
|
{
|
||||||
|
threadId,
|
||||||
|
turnId,
|
||||||
|
},
|
||||||
|
INTERRUPT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
// Once Codex accepts the interrupt, end the commands this turn started. Registered
|
||||||
|
// before the await, so a turn start racing this interrupt waits for it. An already-idle
|
||||||
|
// turn was not interrupted, and what it left running stays.
|
||||||
|
this.trackInterruptedTurnCleanup(
|
||||||
|
interruptRequest.then(
|
||||||
|
() => this.endInterruptedTurnProcesses(client, threadId, turnId),
|
||||||
|
() => undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await this.client.request(
|
await interruptRequest;
|
||||||
"turn/interrupt",
|
|
||||||
{
|
|
||||||
threadId: this.currentThreadId,
|
|
||||||
turnId,
|
|
||||||
},
|
|
||||||
INTERRUPT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isCodexAlreadyIdleInterrupt(error)) {
|
if (!isCodexAlreadyIdleInterrupt(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -5085,6 +5117,137 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
return !this.client.hasPendingRequests();
|
return !this.client.hasPendingRequests();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remember which turn started a command, so an interrupt ends that turn's commands and
|
||||||
|
// no other turn's. Only the root thread's: a subagent's commands live on its own thread.
|
||||||
|
// A command that starts after its turn's interrupt was accepted is ended as soon as it
|
||||||
|
// is seen.
|
||||||
|
private recordTurnTerminalProcess(
|
||||||
|
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
|
||||||
|
): void {
|
||||||
|
const processId = parsed.item.processId;
|
||||||
|
if (
|
||||||
|
parsed.source !== "item" ||
|
||||||
|
!parsed.turnId ||
|
||||||
|
!parsed.threadId ||
|
||||||
|
parsed.threadId !== this.currentThreadId ||
|
||||||
|
typeof processId !== "string" ||
|
||||||
|
!processId ||
|
||||||
|
normalizeCodexThreadItemType(
|
||||||
|
typeof parsed.item.type === "string" ? parsed.item.type : undefined,
|
||||||
|
) !== "commandExecution"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const record = this.terminalProcessIdsByTurnId.get(parsed.turnId);
|
||||||
|
if (parsed.kind === "item_completed") {
|
||||||
|
// Only a completed command has exited. A failed one can still be running:
|
||||||
|
// Codex reports an interrupted turn's live command as failed.
|
||||||
|
if (parsed.item.status === "completed") record?.processIds.delete(processId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = record ?? this.getTurnTerminalRecord(parsed.turnId);
|
||||||
|
current.processIds.add(processId);
|
||||||
|
if (current.interruptAccepted && this.client) {
|
||||||
|
this.trackInterruptedTurnCleanup(
|
||||||
|
this.endInterruptedTurnProcesses(this.client, parsed.threadId, parsed.turnId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An accepted interrupt needs a record even before any of its turn's commands is seen:
|
||||||
|
// an `item/started` can still be queued behind an earlier notification's conversion.
|
||||||
|
private getTurnTerminalRecord(turnId: string) {
|
||||||
|
const existing = this.terminalProcessIdsByTurnId.get(turnId);
|
||||||
|
if (existing) return existing;
|
||||||
|
const record = {
|
||||||
|
processIds: new Set<string>(),
|
||||||
|
terminatedProcessIds: new Set<string>(),
|
||||||
|
interruptAccepted: false,
|
||||||
|
};
|
||||||
|
this.terminalProcessIdsByTurnId.set(turnId, record);
|
||||||
|
for (const oldestTurnId of this.terminalProcessIdsByTurnId.keys()) {
|
||||||
|
if (this.terminalProcessIdsByTurnId.size <= INTERRUPTED_TURN_TRACKED_TURNS) break;
|
||||||
|
this.terminalProcessIdsByTurnId.delete(oldestTurnId);
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A later sweep joins the pending one, so a turn start waits for both.
|
||||||
|
private trackInterruptedTurnCleanup(cleanup: Promise<void>): void {
|
||||||
|
const tracked = Promise.all([this.interruptedTurnCleanup, cleanup])
|
||||||
|
.then(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
if (this.interruptedTurnCleanup === tracked) this.interruptedTurnCleanup = null;
|
||||||
|
});
|
||||||
|
this.interruptedTurnCleanup = tracked;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminate each process the interrupted turn started, by its own id, once each.
|
||||||
|
// `thread/backgroundTerminals/clean` is never used: it ends every terminal on the thread,
|
||||||
|
// an earlier turn's included. A process already gone answers `terminated: false`. Never
|
||||||
|
// rejects; failures are logged.
|
||||||
|
private async endInterruptedTurnProcesses(
|
||||||
|
client: CodexAppServerClient,
|
||||||
|
threadId: string | null,
|
||||||
|
turnId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!threadId) return;
|
||||||
|
const record = this.getTurnTerminalRecord(turnId);
|
||||||
|
record.interruptAccepted = true;
|
||||||
|
const processIds = [...record.processIds].filter(
|
||||||
|
(processId) => !record.terminatedProcessIds.has(processId),
|
||||||
|
);
|
||||||
|
for (const processId of processIds) record.terminatedProcessIds.add(processId);
|
||||||
|
const logContext = { agentId: this.agentId, provider: CODEX_PROVIDER, threadId, turnId };
|
||||||
|
await Promise.all(
|
||||||
|
processIds.map(async (processId) => {
|
||||||
|
try {
|
||||||
|
const response = await client.request(
|
||||||
|
"thread/backgroundTerminals/terminate",
|
||||||
|
{ threadId, processId },
|
||||||
|
INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
this.logger.info(
|
||||||
|
{ ...logContext, processId, terminated: toObjectRecord(response)?.terminated ?? null },
|
||||||
|
"provider.codex.interrupt.terminal_terminated",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ ...logContext, processId, err: error },
|
||||||
|
"provider.codex.interrupt.terminal_terminate_failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new turn starts only after every pending sweep of interrupted turns' commands has
|
||||||
|
// ended, or after the bound, whichever is first.
|
||||||
|
private async waitForInterruptedTurnCleanup(): Promise<void> {
|
||||||
|
if (!this.interruptedTurnCleanup) return;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const timedOut = await Promise.race([
|
||||||
|
(async () => {
|
||||||
|
while (this.interruptedTurnCleanup) await this.interruptedTurnCleanup;
|
||||||
|
return false;
|
||||||
|
})(),
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
timer = setTimeout(() => resolve(true), INTERRUPTED_TURN_CLEANUP_WAIT_MS);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (timedOut) {
|
||||||
|
this.logger.warn(
|
||||||
|
{
|
||||||
|
agentId: this.agentId,
|
||||||
|
provider: CODEX_PROVIDER,
|
||||||
|
waitMs: INTERRUPTED_TURN_CLEANUP_WAIT_MS,
|
||||||
|
},
|
||||||
|
"provider.codex.interrupt.terminal_cleanup_timed_out",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
this.clearPendingPermissions();
|
this.clearPendingPermissions();
|
||||||
@@ -6209,6 +6372,11 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.completePendingRootCompactions();
|
this.completePendingRootCompactions();
|
||||||
|
// What a turn that was not interrupted leaves running, it left on purpose; no later
|
||||||
|
// interrupt may end it.
|
||||||
|
if (parsed.status !== "interrupted" && this.currentTurnId) {
|
||||||
|
this.terminalProcessIdsByTurnId.delete(this.currentTurnId);
|
||||||
|
}
|
||||||
if (parsed.status === "failed") {
|
if (parsed.status === "failed") {
|
||||||
this.emitEvent({
|
this.emitEvent({
|
||||||
type: "turn_failed",
|
type: "turn_failed",
|
||||||
@@ -6613,6 +6781,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private async handleItemCompletedNotification(
|
private async handleItemCompletedNotification(
|
||||||
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
|
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
this.recordTurnTerminalProcess(parsed);
|
||||||
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
|
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
|
||||||
// and canonical `item/*`. Render ordinary items only from the canonical
|
// and canonical `item/*`. Render ordinary items only from the canonical
|
||||||
// channel, but accept a legacy-only child announcement so it can establish
|
// channel, but accept a legacy-only child announcement so it can establish
|
||||||
@@ -6775,6 +6944,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private async handleItemStartedNotification(
|
private async handleItemStartedNotification(
|
||||||
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
|
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
this.recordTurnTerminalProcess(parsed);
|
||||||
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
|
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { AgentSession } from "../agent-sdk-types.js";
|
||||||
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
|
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
|
||||||
|
import {
|
||||||
|
createFakeCodexAppServer,
|
||||||
|
type FakeCodexAppServer,
|
||||||
|
waitForNextEvent,
|
||||||
|
waitForTimelineToolCall,
|
||||||
|
} from "./codex/test-utils/fake-app-server.js";
|
||||||
|
|
||||||
|
const THREAD = "thread-1";
|
||||||
|
const TERMINATE = "thread/backgroundTerminals/terminate";
|
||||||
|
|
||||||
|
type Handler = (params: unknown) => unknown;
|
||||||
|
|
||||||
|
function deferred<T = void>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function paramsOf(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessions: AgentSession[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const session of sessions) await session.close().catch(() => undefined);
|
||||||
|
sessions = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Codex app-server whose interrupt ends the turn the way Codex does: an `interrupted`
|
||||||
|
* turn completion, then the response. `terminate` answers per process.
|
||||||
|
*/
|
||||||
|
function createHarness(
|
||||||
|
options: {
|
||||||
|
terminate?: (processId: string) => unknown;
|
||||||
|
interruptResponse?: (completeTurn: () => void) => unknown;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const handlers: Record<string, Handler> = {
|
||||||
|
"turn/interrupt": () => {
|
||||||
|
const completeTurn = () =>
|
||||||
|
appServer.completeTurn({ threadId: THREAD, status: "interrupted" });
|
||||||
|
if (options.interruptResponse) return options.interruptResponse(completeTurn);
|
||||||
|
completeTurn();
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
[TERMINATE]: (params) =>
|
||||||
|
options.terminate?.(String(paramsOf(params).processId)) ?? { terminated: true },
|
||||||
|
};
|
||||||
|
const appServer: FakeCodexAppServer = createFakeCodexAppServer(handlers);
|
||||||
|
const session = new CodexAppServerAgentSession(
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/workspace/project",
|
||||||
|
modeId: "auto",
|
||||||
|
model: "gpt-5.4",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
createTestLogger(),
|
||||||
|
async () => appServer.child,
|
||||||
|
);
|
||||||
|
sessions.push(session);
|
||||||
|
|
||||||
|
function requests(method: string): Record<string, unknown>[] {
|
||||||
|
return appServer
|
||||||
|
.requests()
|
||||||
|
.filter((message) => message.method === method && "id" in message)
|
||||||
|
.map((message) => paramsOf(message.params));
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminated(): string[] {
|
||||||
|
return requests(TERMINATE)
|
||||||
|
.map((params) => String(params.processId))
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starts a turn and lets Codex identify it as `nativeTurnId`.
|
||||||
|
async function startTurn(prompt: string, nativeTurnId: string): Promise<void> {
|
||||||
|
const before = requests("turn/start").length;
|
||||||
|
const started = session.startTurn(prompt);
|
||||||
|
await vi.waitFor(() => expect(requests("turn/start")).toHaveLength(before + 1), {
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
const turnStarted = waitForNextEvent(session, "turn_started");
|
||||||
|
appServer.startsTurn({ threadId: THREAD, turnId: nativeTurnId });
|
||||||
|
await started;
|
||||||
|
await turnStarted;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runsCommand(params: { turnId: string; processId: string }): Promise<void> {
|
||||||
|
const itemId = `exec-${params.processId}`;
|
||||||
|
const shown = waitForTimelineToolCall(session, itemId);
|
||||||
|
appServer.startsTerminalCommand({
|
||||||
|
threadId: THREAD,
|
||||||
|
turnId: params.turnId,
|
||||||
|
itemId,
|
||||||
|
processId: params.processId,
|
||||||
|
command: `sleep ${params.processId}`,
|
||||||
|
});
|
||||||
|
await shown;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completesTurn(): Promise<void> {
|
||||||
|
const completed = waitForNextEvent(session, "turn_completed");
|
||||||
|
appServer.completeTurn({ threadId: THREAD });
|
||||||
|
await completed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interrupts the running turn and waits for the session to report it canceled, as a
|
||||||
|
// caller does before it starts the next turn: Codex notifications are handled in order
|
||||||
|
// but asynchronously, so the interrupt's answer can arrive before the turn has ended.
|
||||||
|
async function interrupts(): Promise<void> {
|
||||||
|
const canceled = waitForNextEvent(session, "turn_canceled");
|
||||||
|
await session.interrupt();
|
||||||
|
await canceled;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appServer,
|
||||||
|
session,
|
||||||
|
requests,
|
||||||
|
terminated,
|
||||||
|
startTurn,
|
||||||
|
runsCommand,
|
||||||
|
completesTurn,
|
||||||
|
interrupts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn A leaves a server running on purpose and completes. Turn B starts two
|
||||||
|
// commands, and a subagent on its own thread starts a third, then B is interrupted.
|
||||||
|
async function interruptTurnB(harness: ReturnType<typeof createHarness>): Promise<void> {
|
||||||
|
await harness.startTurn("start a server", "native-A");
|
||||||
|
await harness.runsCommand({ turnId: "native-A", processId: "101" });
|
||||||
|
await harness.completesTurn();
|
||||||
|
|
||||||
|
await harness.startTurn("run two commands", "native-B");
|
||||||
|
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
||||||
|
// A subagent thread's command shows no row here; the next command's row shows it
|
||||||
|
// was read, because notifications are read in order.
|
||||||
|
harness.appServer.startsTerminalCommand({
|
||||||
|
threadId: "child-thread",
|
||||||
|
turnId: "native-B",
|
||||||
|
itemId: "exec-209",
|
||||||
|
processId: "209",
|
||||||
|
command: "sleep 209",
|
||||||
|
});
|
||||||
|
await harness.runsCommand({ turnId: "native-B", processId: "202" });
|
||||||
|
await harness.interrupts();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("an interrupted Codex turn's commands end before the next turn", () => {
|
||||||
|
test("terminates exactly the processes the interrupted turn started, by id, and never cleans", async () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
await interruptTurnB(harness);
|
||||||
|
await harness.startTurn("correction", "native-C");
|
||||||
|
|
||||||
|
expect(harness.terminated()).toEqual(["201", "202"]);
|
||||||
|
expect(harness.requests(TERMINATE).every((params) => params.threadId === THREAD)).toBe(true);
|
||||||
|
// `clean` would end every terminal on the thread, an earlier turn's included.
|
||||||
|
expect(
|
||||||
|
harness.appServer
|
||||||
|
.requests()
|
||||||
|
.filter((message) => String(message.method).startsWith("thread/backgroundTerminals/"))
|
||||||
|
.map((message) => message.method),
|
||||||
|
).toEqual([TERMINATE, TERMINATE]);
|
||||||
|
harness.appServer.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves the next turn's commands running when Codex starts that turn before acknowledging the interrupt", async () => {
|
||||||
|
const acknowledge = deferred();
|
||||||
|
const harness = createHarness({
|
||||||
|
// Codex ends B, starts a turn of its own (a goal continuation), and only then
|
||||||
|
// answers the interrupt.
|
||||||
|
interruptResponse: (completeTurn) => {
|
||||||
|
completeTurn();
|
||||||
|
return acknowledge.promise.then(() => ({}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await harness.startTurn("run two commands", "native-B");
|
||||||
|
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
||||||
|
await harness.runsCommand({ turnId: "native-B", processId: "202" });
|
||||||
|
const interrupting = harness.session.interrupt();
|
||||||
|
await vi.waitFor(() => expect(harness.requests("turn/interrupt")).toHaveLength(1));
|
||||||
|
const autonomousTurn = waitForNextEvent(harness.session, "turn_started");
|
||||||
|
harness.appServer.startsTurn({ threadId: THREAD, turnId: "native-C" });
|
||||||
|
await autonomousTurn;
|
||||||
|
await harness.runsCommand({ turnId: "native-C", processId: "301" });
|
||||||
|
|
||||||
|
acknowledge.resolve();
|
||||||
|
await interrupting;
|
||||||
|
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
expect(harness.terminated()).toEqual(["201", "202"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sends the next turn/start only after the terminates are answered", async () => {
|
||||||
|
const answer = deferred();
|
||||||
|
const harness = createHarness({
|
||||||
|
terminate: () => answer.promise.then(() => ({ terminated: true })),
|
||||||
|
});
|
||||||
|
await interruptTurnB(harness);
|
||||||
|
const next = harness.startTurn("correction", "native-C");
|
||||||
|
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
expect(harness.requests("turn/start")).toHaveLength(2);
|
||||||
|
|
||||||
|
answer.resolve();
|
||||||
|
await next;
|
||||||
|
expect(harness.requests("turn/start")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["initial", "late"] as const)(
|
||||||
|
"tracks both cleanup sweeps when the %s answer arrives first",
|
||||||
|
async (answeredFirst) => {
|
||||||
|
const first = deferred();
|
||||||
|
const second = deferred();
|
||||||
|
const harness = createHarness({
|
||||||
|
terminate: (processId) =>
|
||||||
|
(processId === "201" ? first : second).promise.then(() => ({ terminated: true })),
|
||||||
|
});
|
||||||
|
await harness.startTurn("run commands", "native-B");
|
||||||
|
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
||||||
|
await harness.interrupts();
|
||||||
|
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201"]));
|
||||||
|
const next = harness.startTurn("correction", "native-C");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
expect(harness.requests("turn/start")).toHaveLength(1);
|
||||||
|
// The next turn is already waiting when another process is announced.
|
||||||
|
harness.appServer.startsTerminalCommand({
|
||||||
|
threadId: THREAD,
|
||||||
|
turnId: "native-B",
|
||||||
|
itemId: "late-202",
|
||||||
|
processId: "202",
|
||||||
|
command: "sleep 202",
|
||||||
|
});
|
||||||
|
harness.appServer.startsTerminalCommand({
|
||||||
|
threadId: THREAD,
|
||||||
|
turnId: "native-B",
|
||||||
|
itemId: "duplicate-202",
|
||||||
|
processId: "202",
|
||||||
|
command: "sleep 202",
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
||||||
|
(answeredFirst === "initial" ? first : second).resolve();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
expect(harness.requests("turn/start")).toHaveLength(1);
|
||||||
|
(answeredFirst === "initial" ? second : first).resolve();
|
||||||
|
await next;
|
||||||
|
expect(harness.terminated()).toEqual(["201", "202"]);
|
||||||
|
// Accepted state survives the completed sweep and a newer foreground turn.
|
||||||
|
harness.appServer.startsTerminalCommand({
|
||||||
|
threadId: THREAD,
|
||||||
|
turnId: "native-B",
|
||||||
|
itemId: "late-203",
|
||||||
|
processId: "203",
|
||||||
|
command: "sleep 203",
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202", "203"]));
|
||||||
|
harness.appServer.assertNoErrors();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("keeps the first sweep pending when a late sweep answers before the next start", async () => {
|
||||||
|
const first = deferred();
|
||||||
|
const harness = createHarness({
|
||||||
|
terminate: (processId) =>
|
||||||
|
processId === "201"
|
||||||
|
? first.promise.then(() => ({ terminated: true }))
|
||||||
|
: { terminated: true },
|
||||||
|
});
|
||||||
|
await harness.startTurn("run commands", "native-B");
|
||||||
|
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
||||||
|
await harness.interrupts();
|
||||||
|
harness.appServer.startsTerminalCommand({
|
||||||
|
threadId: THREAD,
|
||||||
|
turnId: "native-B",
|
||||||
|
itemId: "late-202",
|
||||||
|
processId: "202",
|
||||||
|
command: "sleep 202",
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
||||||
|
const next = harness.startTurn("correction", "native-C");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
expect(harness.requests("turn/start")).toHaveLength(1);
|
||||||
|
first.resolve();
|
||||||
|
await next;
|
||||||
|
harness.appServer.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forgets completed commands but retains failed commands at interrupt", async () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
await harness.startTurn("run commands", "native-B");
|
||||||
|
for (const processId of ["201", "202"]) {
|
||||||
|
await harness.runsCommand({ turnId: "native-B", processId });
|
||||||
|
harness.appServer.child.stdout.write(
|
||||||
|
JSON.stringify({
|
||||||
|
method: "item/completed",
|
||||||
|
params: {
|
||||||
|
threadId: THREAD,
|
||||||
|
turnId: "native-B",
|
||||||
|
item: {
|
||||||
|
id: `exec-${processId}`,
|
||||||
|
type: "commandExecution",
|
||||||
|
processId,
|
||||||
|
command: `sleep ${processId}`,
|
||||||
|
status: processId === "201" ? "completed" : "failed",
|
||||||
|
exitCode: processId === "201" ? 0 : null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) + "\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A following command's row is a barrier for both completion handlers.
|
||||||
|
await harness.runsCommand({ turnId: "native-B", processId: "203" });
|
||||||
|
await harness.interrupts();
|
||||||
|
await harness.startTurn("correction", "native-C");
|
||||||
|
expect(harness.terminated()).toEqual(["202", "203"]);
|
||||||
|
harness.appServer.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retains accepted interrupts for only the latest sixteen turns", async () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
for (let index = 0; index < 17; index++) {
|
||||||
|
await harness.startTurn("run a command", `native-${index}`);
|
||||||
|
await harness.interrupts();
|
||||||
|
}
|
||||||
|
await harness.startTurn("correction", "native-current");
|
||||||
|
harness.appServer.startsTerminalCommand({
|
||||||
|
threadId: THREAD,
|
||||||
|
turnId: "native-0",
|
||||||
|
itemId: "evicted-201",
|
||||||
|
processId: "201",
|
||||||
|
command: "sleep 201",
|
||||||
|
});
|
||||||
|
harness.appServer.startsTerminalCommand({
|
||||||
|
threadId: THREAD,
|
||||||
|
turnId: "native-16",
|
||||||
|
itemId: "retained-202",
|
||||||
|
processId: "202",
|
||||||
|
command: "sleep 202",
|
||||||
|
});
|
||||||
|
await harness.runsCommand({ turnId: "native-current", processId: "301" });
|
||||||
|
expect(harness.terminated()).toEqual(["202"]);
|
||||||
|
harness.appServer.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failing or unanswered terminate does not block or fail the next turn", async () => {
|
||||||
|
const harness = createHarness({
|
||||||
|
// 201 is refused while the next turn waits; 202 is never answered.
|
||||||
|
terminate: (processId) =>
|
||||||
|
processId === "201"
|
||||||
|
? new Promise((resolve) => setTimeout(resolve, 300)).then(() => ({
|
||||||
|
__jsonRpcError: { code: -32600, message: "invalid background terminal process id" },
|
||||||
|
}))
|
||||||
|
: new Promise(() => {}),
|
||||||
|
});
|
||||||
|
await interruptTurnB(harness);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await harness.startTurn("correction", "native-C");
|
||||||
|
|
||||||
|
expect(Date.now() - startedAt).toBeLessThan(5_000);
|
||||||
|
expect(harness.requests("turn/start")).toHaveLength(3);
|
||||||
|
expect(harness.terminated()).toEqual(["201", "202"]);
|
||||||
|
}, 15_000);
|
||||||
|
});
|
||||||
@@ -4,6 +4,102 @@ import { createTestLogger } from "../../../../../test-utils/test-logger.js";
|
|||||||
import type { V2Api } from "./api.js";
|
import type { V2Api } from "./api.js";
|
||||||
import { OpenCodeV2AgentClient } from "./agent.js";
|
import { OpenCodeV2AgentClient } from "./agent.js";
|
||||||
import { V2Harness } from "../test-utils/v2-harness.js";
|
import { V2Harness } from "../test-utils/v2-harness.js";
|
||||||
|
import type { AgentPermissionResponse } from "../../../agent-sdk-types.js";
|
||||||
|
|
||||||
|
describe("OpenCode v2 denial reasons", () => {
|
||||||
|
test.each<{
|
||||||
|
response: AgentPermissionResponse;
|
||||||
|
decision: "reject" | "once" | "always";
|
||||||
|
message?: string;
|
||||||
|
}>([
|
||||||
|
{
|
||||||
|
response: { behavior: "deny", message: "Use the existing file instead." },
|
||||||
|
decision: "reject",
|
||||||
|
message: "Use the existing file instead.",
|
||||||
|
},
|
||||||
|
{ response: { behavior: "deny" }, decision: "reject" },
|
||||||
|
{ response: { behavior: "allow" }, decision: "once" },
|
||||||
|
{ response: { behavior: "allow", selectedActionId: "always" }, decision: "always" },
|
||||||
|
])("preserves a permission response: $response", async ({ response, decision, message }) => {
|
||||||
|
const harness = new V2Harness();
|
||||||
|
let pending = true;
|
||||||
|
harness.api.permission.list = async () =>
|
||||||
|
pending
|
||||||
|
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
|
||||||
|
: [];
|
||||||
|
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
|
||||||
|
harness.api.permission.reply = async (input) => {
|
||||||
|
replies.push(input);
|
||||||
|
pending = false;
|
||||||
|
};
|
||||||
|
const client = new OpenCodeV2AgentClient({
|
||||||
|
logger: createTestLogger(),
|
||||||
|
runtime: harness.runtime,
|
||||||
|
});
|
||||||
|
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||||
|
try {
|
||||||
|
await session.respondToPermission("approval", response);
|
||||||
|
expect(replies).toEqual([
|
||||||
|
{
|
||||||
|
sessionID: "session",
|
||||||
|
requestID: "approval",
|
||||||
|
decision,
|
||||||
|
...(message === undefined ? {} : { message }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(session.getPendingPermissions()).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([true, false])(
|
||||||
|
"explains cards superseded by a steer when clearing is %s",
|
||||||
|
async (clearPendingPermissions) => {
|
||||||
|
const harness = new V2Harness();
|
||||||
|
harness.autoComplete = false;
|
||||||
|
let pending = true;
|
||||||
|
harness.api.permission.list = async () =>
|
||||||
|
pending
|
||||||
|
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
|
||||||
|
: [];
|
||||||
|
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
|
||||||
|
harness.api.permission.reply = async (input) => {
|
||||||
|
replies.push(input);
|
||||||
|
pending = false;
|
||||||
|
};
|
||||||
|
const client = new OpenCodeV2AgentClient({
|
||||||
|
logger: createTestLogger(),
|
||||||
|
runtime: harness.runtime,
|
||||||
|
});
|
||||||
|
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||||
|
try {
|
||||||
|
const { turnId } = await session.startTurn("Run pwd");
|
||||||
|
await expect.poll(() => harness.prompts).toEqual(["Run pwd"]);
|
||||||
|
await expect(
|
||||||
|
session.steerActiveTurn!("go ahead", { expectedTurnId: turnId, clearPendingPermissions }),
|
||||||
|
).resolves.toEqual({ status: "accepted" });
|
||||||
|
expect(harness.prompts).toEqual(["Run pwd", "go ahead"]);
|
||||||
|
expect(replies).toEqual(
|
||||||
|
clearPendingPermissions
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
sessionID: "session",
|
||||||
|
requestID: "approval",
|
||||||
|
decision: "reject",
|
||||||
|
message:
|
||||||
|
"The user answered with a message instead of approving. Their message follows.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
expect(session.getPendingPermissions()).toHaveLength(clearPendingPermissions ? 0 : 1);
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
describe("OpenCode v2 questions", () => {
|
describe("OpenCode v2 questions", () => {
|
||||||
test("maps selected labels to native values and parses numeric and boolean answers", async () => {
|
test("maps selected labels to native values and parses numeric and boolean answers", async () => {
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ export class SessionPermissions {
|
|||||||
sessionID: this.permissionOwners.get(requestId) ?? this.id,
|
sessionID: this.permissionOwners.get(requestId) ?? this.id,
|
||||||
requestID: requestId,
|
requestID: requestId,
|
||||||
decision: permissionReply(response),
|
decision: permissionReply(response),
|
||||||
|
...(response.behavior === "deny" && response.message !== undefined
|
||||||
|
? { message: response.message }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.resolvePending(requestId, response);
|
this.resolvePending(requestId, response);
|
||||||
|
|||||||
@@ -100,7 +100,11 @@ export class OpenCodeV2Session implements AgentSession {
|
|||||||
),
|
),
|
||||||
clearPermissions: async () => {
|
clearPermissions: async () => {
|
||||||
for (const request of this.permissions.list())
|
for (const request of this.permissions.list())
|
||||||
await this.permissions.respondToPermission(request.id, { behavior: "deny" });
|
await this.permissions.respondToPermission(request.id, {
|
||||||
|
behavior: "deny",
|
||||||
|
message:
|
||||||
|
"The user answered with a message instead of approving. Their message follows.",
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.usage = new SessionUsage({
|
this.usage = new SessionUsage({
|
||||||
|
|||||||
@@ -1590,7 +1590,7 @@ function createToolCatalog(
|
|||||||
{
|
{
|
||||||
title: "Create agent",
|
title: "Create agent",
|
||||||
description:
|
description:
|
||||||
"Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. Do not guess; call list_providers and list_models first if uncertain.",
|
"Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. For a different provider, set settings.modeId from inspect_provider; omission is accepted for targets with no modes and for unattended execution supported by the target. Do not guess; call list_providers and list_models first if uncertain.",
|
||||||
inputSchema: createAgentInputSchema,
|
inputSchema: createAgentInputSchema,
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
import type { ArchiveResult, ActiveWorkspaceRef } from "../workspace-archive-service.js";
|
import type { ArchiveResult, ActiveWorkspaceRef } from "../workspace-archive-service.js";
|
||||||
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
|
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
|
||||||
import { createWorktree, type WorktreeConfig } from "../../utils/worktree.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 { ForgeService } from "../../../services/forge-service.js";
|
||||||
import type { StoredAgentRecord } from "../agent/agent-storage.js";
|
import type { StoredAgentRecord } from "../agent/agent-storage.js";
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@ function createPullRequest(
|
|||||||
state: "open",
|
state: "open",
|
||||||
baseRefName: "main",
|
baseRefName: "main",
|
||||||
headRefName: "feature",
|
headRefName: "feature",
|
||||||
|
headSha: "same-head",
|
||||||
isMerged: true,
|
isMerged: true,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -46,6 +49,8 @@ function createSnapshot(overrides?: {
|
|||||||
repoRoot: "/tmp/repo",
|
repoRoot: "/tmp/repo",
|
||||||
mainRepoRoot: "/tmp/repo",
|
mainRepoRoot: "/tmp/repo",
|
||||||
currentBranch: "feature",
|
currentBranch: "feature",
|
||||||
|
upstreamKnown: true,
|
||||||
|
headSha: "same-head",
|
||||||
remoteUrl: "https://github.com/acme/repo.git",
|
remoteUrl: "https://github.com/acme/repo.git",
|
||||||
isPaseoOwnedWorktree: true,
|
isPaseoOwnedWorktree: true,
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
@@ -346,6 +351,89 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("archiveIfSafe", () => {
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
@@ -381,6 +469,26 @@ describe("archiveIfSafe", () => {
|
|||||||
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
|
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 () => {
|
test("archives when the PR is merged and the upstream branch was deleted", async () => {
|
||||||
const harness = createHarness({
|
const harness = createHarness({
|
||||||
snapshot: createSnapshot({ git: { aheadOfOrigin: null, behindOfOrigin: null } }),
|
snapshot: createSnapshot({ git: { aheadOfOrigin: null, behindOfOrigin: null } }),
|
||||||
|
|||||||
@@ -47,6 +47,19 @@ const defaultDependencies: ArchiveIfSafeDependencies = {
|
|||||||
killTerminalsForWorkspace,
|
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: {
|
export async function archiveIfSafe(input: {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
snapshot: WorkspaceGitRuntimeSnapshot;
|
snapshot: WorkspaceGitRuntimeSnapshot;
|
||||||
@@ -59,13 +72,7 @@ export async function archiveIfSafe(input: {
|
|||||||
const cwd = snapshot.cwd;
|
const cwd = snapshot.cwd;
|
||||||
const pullRequest = snapshot.forge.pullRequest;
|
const pullRequest = snapshot.forge.pullRequest;
|
||||||
|
|
||||||
if (!pullRequest?.isMerged) {
|
if (!pullRequest || !isEligibleForMergeArchive(snapshot)) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (snapshot.git.isDirty === true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof snapshot.git.aheadOfOrigin === "number" && snapshot.git.aheadOfOrigin > 0) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ interface PullRequestObservation {
|
|||||||
const cleanupPaths: string[] = [];
|
const cleanupPaths: string[] = [];
|
||||||
const cleanupServices: WorkspaceGitServiceImpl[] = [];
|
const cleanupServices: WorkspaceGitServiceImpl[] = [];
|
||||||
|
|
||||||
function run(cwd: string, args: string[]): void {
|
function run(cwd: string, args: string[]): string {
|
||||||
execFileSync("git", args, { cwd, stdio: "pipe" });
|
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function settleObservation(): Promise<void> {
|
async function settleObservation(): Promise<void> {
|
||||||
@@ -63,6 +63,7 @@ async function createWorkspaceFiles() {
|
|||||||
|
|
||||||
async function createWorkspaceJourney() {
|
async function createWorkspaceJourney() {
|
||||||
const { repoDir, worktree } = await createWorkspaceFiles();
|
const { repoDir, worktree } = await createWorkspaceFiles();
|
||||||
|
const remoteHeadSha = run(worktree.worktreePath, ["rev-parse", "HEAD"]).trim();
|
||||||
const workspaceId = "workspace-under-test";
|
const workspaceId = "workspace-under-test";
|
||||||
let active = true;
|
let active = true;
|
||||||
let currentPullRequest: PullRequestObservation | null = null;
|
let currentPullRequest: PullRequestObservation | null = null;
|
||||||
@@ -81,6 +82,7 @@ async function createWorkspaceJourney() {
|
|||||||
repoRoot: worktree.worktreePath,
|
repoRoot: worktree.worktreePath,
|
||||||
mainRepoRoot: repoDir,
|
mainRepoRoot: repoDir,
|
||||||
currentBranch: branch,
|
currentBranch: branch,
|
||||||
|
headSha: run(worktree.worktreePath, ["rev-parse", "HEAD"]).trim(),
|
||||||
remoteUrl: "https://github.com/acme/repo.git",
|
remoteUrl: "https://github.com/acme/repo.git",
|
||||||
isPaseoOwnedWorktree: true,
|
isPaseoOwnedWorktree: true,
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
@@ -102,6 +104,7 @@ async function createWorkspaceJourney() {
|
|||||||
state: currentPullRequest.state,
|
state: currentPullRequest.state,
|
||||||
baseRefName: "main",
|
baseRefName: "main",
|
||||||
headRefName: currentPullRequest.headRefName,
|
headRefName: currentPullRequest.headRefName,
|
||||||
|
headSha: remoteHeadSha,
|
||||||
isMerged: currentPullRequest.state === "merged",
|
isMerged: currentPullRequest.state === "merged",
|
||||||
}
|
}
|
||||||
: null,
|
: 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 { repoDir, paseoHome, worktree } = await createWorkspaceFiles();
|
||||||
const cwd = worktree.worktreePath;
|
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, ["remote", "add", "origin", "https://github.com/acme/repo.git"]);
|
||||||
run(repoDir, ["update-ref", "refs/remotes/origin/workspace", "HEAD"]);
|
run(repoDir, ["update-ref", "refs/remotes/origin/workspace", "HEAD"]);
|
||||||
run(cwd, ["branch", "--set-upstream-to=origin/workspace"]);
|
run(cwd, ["branch", "--set-upstream-to=origin/workspace"]);
|
||||||
@@ -176,38 +183,58 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
|
|||||||
const reads: Array<string | undefined> = [];
|
const reads: Array<string | undefined> = [];
|
||||||
const archives: string[] = [];
|
const archives: string[] = [];
|
||||||
let active = true;
|
let active = true;
|
||||||
const service = new WorkspaceGitServiceImpl({
|
function createService() {
|
||||||
logger,
|
const service = new WorkspaceGitServiceImpl({
|
||||||
paseoHome,
|
logger,
|
||||||
deps: {
|
paseoHome,
|
||||||
forgeOverrides: { github },
|
deps: {
|
||||||
getPullRequestStatus: async (_cwd, _forge, options) => {
|
forgeOverrides: { github },
|
||||||
reads.push(options?.reason);
|
getPullRequestStatus: async (_cwd, _forge, options) => {
|
||||||
return {
|
reads.push(options?.reason);
|
||||||
status: observation && {
|
return {
|
||||||
...observation,
|
status: observation && {
|
||||||
title: observation.headRefName,
|
...observation,
|
||||||
baseRefName: "main",
|
headSha: remoteHeadSha,
|
||||||
isMerged: observation.state === "merged",
|
title: observation.headRefName,
|
||||||
},
|
baseRefName: "main",
|
||||||
authState: "authenticated",
|
isMerged: observation.state === "merged",
|
||||||
featuresEnabled: true,
|
},
|
||||||
githubFeaturesEnabled: true,
|
authState: "authenticated",
|
||||||
};
|
featuresEnabled: true,
|
||||||
|
githubFeaturesEnabled: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
cleanupServices.push(service);
|
||||||
cleanupServices.push(service);
|
return service;
|
||||||
|
}
|
||||||
|
let service = createService();
|
||||||
const options = {
|
const options = {
|
||||||
logger,
|
logger,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
daemonConfigStore: { get: () => ({ autoArchiveAfterMerge: true }) },
|
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,
|
getAutoArchivedChangeRequestUrl: async () => null,
|
||||||
listActiveWorkspaces: async () =>
|
listActiveWorkspaces: async () =>
|
||||||
active ? [{ workspaceId: "workspace-under-test", cwd, kind: "worktree" }] : [],
|
active ? [{ workspaceId: "workspace-under-test", cwd, kind: "worktree" }] : [],
|
||||||
} as unknown as AutoArchiveOnMergeOptions;
|
} as unknown as AutoArchiveOnMergeOptions;
|
||||||
setupAutoArchiveOnMerge(options, {
|
const archiveDependencies: AutoArchiveOnMergeDependencies = {
|
||||||
resolvePath: path.resolve,
|
resolvePath: path.resolve,
|
||||||
archiveIfSafe: async (input) =>
|
archiveIfSafe: async (input) =>
|
||||||
archiveIfSafe({
|
archiveIfSafe({
|
||||||
@@ -228,12 +255,22 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
};
|
||||||
|
let subscription = setupAutoArchiveOnMerge(options, archiveDependencies);
|
||||||
return {
|
return {
|
||||||
archives,
|
archives,
|
||||||
reads,
|
reads,
|
||||||
metrics: () => service.getMetrics(),
|
metrics: () => service.getMetrics(),
|
||||||
isActive: () => active,
|
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"),
|
makeDirty: () => writeFileSync(path.join(cwd, "README.md"), "uncommitted work\n"),
|
||||||
commitUnpushed: () => {
|
commitUnpushed: () => {
|
||||||
writeFileSync(path.join(cwd, "README.md"), "unpushed work\n");
|
writeFileSync(path.join(cwd, "README.md"), "unpushed work\n");
|
||||||
@@ -270,6 +307,24 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("workspace auto-archive behavior", () => {
|
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 () => {
|
test("archives a clean owned workspace on an unobserved open-to-merged transition only once", async () => {
|
||||||
let releaseArchive!: () => void;
|
let releaseArchive!: () => void;
|
||||||
const archiveBarrier = new Promise<void>((resolve) => {
|
const archiveBarrier = new Promise<void>((resolve) => {
|
||||||
@@ -292,10 +347,11 @@ describe("workspace auto-archive behavior", () => {
|
|||||||
|
|
||||||
expect(workspace.metrics().workspaceListenerCount).toBe(0);
|
expect(workspace.metrics().workspaceListenerCount).toBe(0);
|
||||||
expect(workspace.archives).toEqual(["workspace-under-test"]);
|
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([
|
expect(workspace.reads).toEqual([
|
||||||
"workspace-journey",
|
"workspace-journey",
|
||||||
"workspace-journey",
|
"workspace-journey",
|
||||||
|
"auto-archive-on-merge",
|
||||||
"workspace-journey",
|
"workspace-journey",
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -306,11 +362,14 @@ describe("workspace auto-archive behavior", () => {
|
|||||||
|
|
||||||
test("keeps an unobserved workspace active when its first snapshot is already merged", async () => {
|
test("keeps an unobserved workspace active when its first snapshot is already merged", async () => {
|
||||||
const workspace = await createUnobservedWorkspace();
|
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.metrics().workspaceListenerCount).toBe(0);
|
||||||
expect(workspace.archives).toEqual([]);
|
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 () => {
|
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;
|
await finished;
|
||||||
|
|
||||||
expect(getSnapshot).toHaveBeenCalledTimes(1);
|
expect(getSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getSnapshot).toHaveBeenCalledWith("/repo/worktree/.", {
|
||||||
|
force: true,
|
||||||
|
afterInFlight: true,
|
||||||
|
reason: "auto-archive-on-merge",
|
||||||
|
});
|
||||||
expect(calls).toEqual([
|
expect(calls).toEqual([
|
||||||
{ workspaceId: "workspace-a", pullRequest: freshSnapshot.forge.pullRequest },
|
{ workspaceId: "workspace-a", pullRequest: freshSnapshot.forge.pullRequest },
|
||||||
{ workspaceId: "workspace-b", pullRequest: freshSnapshot.forge.pullRequest },
|
{ workspaceId: "workspace-b", pullRequest: freshSnapshot.forge.pullRequest },
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export function setupAutoArchiveOnMerge(
|
|||||||
let freshSnapshot: WorkspaceGitRuntimeSnapshot | null;
|
let freshSnapshot: WorkspaceGitRuntimeSnapshot | null;
|
||||||
try {
|
try {
|
||||||
freshSnapshot = await options.workspaceGitService.getSnapshot(snapshot.cwd, {
|
freshSnapshot = await options.workspaceGitService.getSnapshot(snapshot.cwd, {
|
||||||
|
force: true,
|
||||||
|
afterInFlight: true,
|
||||||
reason: "auto-archive-on-merge",
|
reason: "auto-archive-on-merge",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ test("ordinary routed APIs isolate scopes and enforce one-hop, disable and origi
|
|||||||
await paseo.dispose();
|
await paseo.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("routed authority is bounded by the caller, with isolated simultaneous sessions", async () => {
|
test("routed authority isolates simultaneous sessions and preserves ordinary permission RPC errors", async () => {
|
||||||
const b = await daemon();
|
const b = await daemon();
|
||||||
const a = await daemon();
|
const a = await daemon();
|
||||||
const owner = await api(a);
|
const owner = await api(a);
|
||||||
@@ -210,33 +210,16 @@ test("routed authority is bounded by the caller, with isolated simultaneous sess
|
|||||||
code: "permission_denied",
|
code: "permission_denied",
|
||||||
});
|
});
|
||||||
expect((await owner.paseo.hosts.list()).hosts).toHaveLength(1);
|
expect((await owner.paseo.hosts.list()).hosts).toHaveLength(1);
|
||||||
await remote.dispose();
|
const directClient = createHostClient(`127.0.0.1:${b.port}`, {
|
||||||
});
|
clientId: crypto.randomUUID(),
|
||||||
|
sessionPermissions: ["daemon.read", "workspace.read"],
|
||||||
test("SDK host clients preserve ordinary permission RPC errors", async () => {
|
reconnect: { enabled: false },
|
||||||
const b = await daemon();
|
});
|
||||||
const a = await daemon();
|
await directClient.connect();
|
||||||
const owner = await api(a);
|
const direct = createPaseoApi(directClient);
|
||||||
await owner.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
|
||||||
const serverId = (await online(owner.paseo)).hosts[0]!.serverId!;
|
|
||||||
const clients = [a, b].map((host) =>
|
|
||||||
createHostClient(`127.0.0.1:${host.port}`, {
|
|
||||||
clientId: crypto.randomUUID(),
|
|
||||||
sessionPermissions: ["daemon.read", "workspace.read"],
|
|
||||||
reconnect: { enabled: false },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
for (const client of clients) {
|
|
||||||
cleanup.push(() => client.close());
|
|
||||||
await client.connect();
|
|
||||||
}
|
|
||||||
const origin = createPaseoApi(clients[0]!);
|
|
||||||
const direct = createPaseoApi(clients[1]!);
|
|
||||||
const remote = await origin.hosts.client(serverId);
|
|
||||||
cleanup.push(async () => {
|
cleanup.push(async () => {
|
||||||
await remote.dispose();
|
|
||||||
await direct.dispose();
|
await direct.dispose();
|
||||||
await origin.dispose();
|
await directClient.close();
|
||||||
});
|
});
|
||||||
const input = { source: { kind: "directory" as const, path: b.staticDir } };
|
const input = { source: { kind: "directory" as const, path: b.staticDir } };
|
||||||
const directError = (await direct.workspaces.create(input).catch((error: unknown) => error)) as {
|
const directError = (await direct.workspaces.create(input).catch((error: unknown) => error)) as {
|
||||||
@@ -247,6 +230,7 @@ test("SDK host clients preserve ordinary permission RPC errors", async () => {
|
|||||||
};
|
};
|
||||||
expect(directError.code).toBe("access_denied");
|
expect(directError.code).toBe("access_denied");
|
||||||
expect(remoteError.code).toBe(directError.code);
|
expect(remoteError.code).toBe(directError.code);
|
||||||
|
await remote.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("distinct origin sessions cannot collide through a caller-selected route scope, including terminal binary output", async () => {
|
test("distinct origin sessions cannot collide through a caller-selected route scope, including terminal binary output", async () => {
|
||||||
@@ -784,6 +768,211 @@ test("old capabilities fail before execution and authentication errors recover t
|
|||||||
expect((await online(paseo)).hosts[0]!.serverId).toBe(b.daemon.getServerId());
|
expect((await online(paseo)).hosts[0]!.serverId).toBe(b.daemon.getServerId());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function updatableDestination() {
|
||||||
|
let features: { connectedHosts: boolean; hostMultiplexing: boolean } | undefined;
|
||||||
|
let holding = false;
|
||||||
|
const held: (() => void)[] = [];
|
||||||
|
let connections = 0;
|
||||||
|
const http = createServer();
|
||||||
|
const ws = new WebSocketServer({ server: http });
|
||||||
|
ws.on("connection", (socket) => {
|
||||||
|
connections++;
|
||||||
|
socket.on("message", (data) => {
|
||||||
|
const frame = JSON.parse(data.toString());
|
||||||
|
if (frame.type === "ping") socket.send(JSON.stringify({ type: "pong" }));
|
||||||
|
if (frame.type !== "hello") return;
|
||||||
|
const reply = () =>
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "status",
|
||||||
|
payload: {
|
||||||
|
status: "server_info",
|
||||||
|
serverId: "srv_updated",
|
||||||
|
hostname: "updated",
|
||||||
|
version: "0.12.0",
|
||||||
|
...(features ? { features } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (holding) held.push(reply);
|
||||||
|
else reply();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
http.listen(0, "127.0.0.1");
|
||||||
|
await once(http, "listening");
|
||||||
|
cleanup.push(async () => {
|
||||||
|
for (const socket of ws.clients) socket.terminate();
|
||||||
|
await new Promise<void>((resolve) => ws.close(() => resolve()));
|
||||||
|
await new Promise<void>((resolve) => http.close(() => resolve()));
|
||||||
|
});
|
||||||
|
const address = http.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("Missing destination port");
|
||||||
|
return {
|
||||||
|
port: address.port,
|
||||||
|
connections: () => connections,
|
||||||
|
hold: () => {
|
||||||
|
holding = true;
|
||||||
|
},
|
||||||
|
update: () => {
|
||||||
|
features = { connectedHosts: true, hostMultiplexing: true };
|
||||||
|
holding = false;
|
||||||
|
for (const reply of held.splice(0)) reply();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function capabilityRetry(paseo: ReturnType<typeof createPaseoApi>): Promise<number> {
|
||||||
|
let retryAt = "";
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const row = (await paseo.hosts.list()).hosts[0];
|
||||||
|
retryAt = row?.nextRetryAt ?? "";
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
{ timeout: 5000 },
|
||||||
|
)
|
||||||
|
.toMatchObject({
|
||||||
|
status: "offline",
|
||||||
|
nextRetryAt: expect.any(String),
|
||||||
|
error: { code: "unsupported_capability" },
|
||||||
|
});
|
||||||
|
return Date.parse(retryAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a destination updated past unsupported_capability keeps its guidance while retrying and comes online, while duplicate_host stays terminal", async () => {
|
||||||
|
const destination = await updatableDestination();
|
||||||
|
const a = await daemon();
|
||||||
|
const { paseo } = await api(a);
|
||||||
|
const host = await paseo.hosts.add({ connection: `127.0.0.1:${destination.port}` });
|
||||||
|
await capabilityRetry(paseo);
|
||||||
|
destination.hold();
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await paseo.hosts.list()).hosts[0], { timeout: 5000 })
|
||||||
|
.toMatchObject({ status: "connecting", error: { code: "unsupported_capability" } });
|
||||||
|
await expect(paseo.hosts.client("srv_updated")).rejects.toMatchObject({
|
||||||
|
code: "unsupported_capability",
|
||||||
|
});
|
||||||
|
destination.update();
|
||||||
|
expect((await online(paseo)).hosts[0]).toMatchObject({
|
||||||
|
registrationId: host.registrationId,
|
||||||
|
serverId: "srv_updated",
|
||||||
|
nextRetryAt: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
await paseo.hosts.add({ connection: `ws://127.0.0.1:${destination.port}/ws` });
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await paseo.hosts.list()).hosts[1], { timeout: 5000 })
|
||||||
|
.toMatchObject({ status: "error", nextRetryAt: null, error: { code: "duplicate_host" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disabling or removing a host cancels its pending capability retry", async () => {
|
||||||
|
const destination = await updatableDestination();
|
||||||
|
const a = await daemon();
|
||||||
|
const { paseo } = await api(a);
|
||||||
|
const host = await paseo.hosts.add({ connection: `127.0.0.1:${destination.port}` });
|
||||||
|
let retryAt = await capabilityRetry(paseo);
|
||||||
|
let connections = destination.connections();
|
||||||
|
await paseo.hosts.update(host.registrationId, { enabled: false });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, retryAt - Date.now() + 500));
|
||||||
|
expect(destination.connections()).toBe(connections);
|
||||||
|
await paseo.hosts.update(host.registrationId, { enabled: true });
|
||||||
|
retryAt = await capabilityRetry(paseo);
|
||||||
|
connections = destination.connections();
|
||||||
|
await paseo.hosts.remove(host.registrationId);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, retryAt - Date.now() + 500));
|
||||||
|
expect(destination.connections()).toBe(connections);
|
||||||
|
});
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDaemonChannel,
|
||||||
|
exportPublicKey,
|
||||||
|
generateKeyPair,
|
||||||
|
openDaemonSecureChannel,
|
||||||
|
type Transport,
|
||||||
|
} from "@getpaseo/relay/e2ee";
|
||||||
|
test("a capability retry over a relay keeps the protected endpoint security it negotiated", async () => {
|
||||||
|
const keys = generateKeyPair();
|
||||||
|
const legacyReceived: unknown[] = [];
|
||||||
|
let connections = 0;
|
||||||
|
let retryClosed = false;
|
||||||
|
const http = createServer();
|
||||||
|
const ws = new WebSocketServer({ server: http });
|
||||||
|
ws.on("connection", (socket) => {
|
||||||
|
connections++;
|
||||||
|
const wire: Transport = {
|
||||||
|
send: (data) => socket.send(data),
|
||||||
|
close: (code, reason) => socket.close(code, reason),
|
||||||
|
onmessage: null,
|
||||||
|
onclose: null,
|
||||||
|
onerror: null,
|
||||||
|
};
|
||||||
|
socket.on("message", (data, isBinary) => {
|
||||||
|
const bytes = Buffer.from(data as Buffer);
|
||||||
|
wire.onmessage?.({
|
||||||
|
data: isBinary
|
||||||
|
? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
|
||||||
|
: bytes.toString(),
|
||||||
|
isBinary,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
socket.on("close", (code, reason) => wire.onclose?.(code, reason.toString()));
|
||||||
|
if (connections > 1) {
|
||||||
|
socket.on("close", () => {
|
||||||
|
retryClosed = true;
|
||||||
|
});
|
||||||
|
void createDaemonChannel(wire, keys, {
|
||||||
|
onmessage: (data) => legacyReceived.push(data),
|
||||||
|
}).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const channel = openDaemonSecureChannel(wire, {
|
||||||
|
daemonKeyPair: keys,
|
||||||
|
events: {
|
||||||
|
onmessage: (data) => {
|
||||||
|
if (typeof data !== "string" || JSON.parse(data).type !== "hello") return;
|
||||||
|
void channel.then((secure) =>
|
||||||
|
secure.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "status",
|
||||||
|
payload: {
|
||||||
|
status: "server_info",
|
||||||
|
serverId: "srv_relayed",
|
||||||
|
hostname: "relayed",
|
||||||
|
version: "0.12.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
void channel.catch(() => {});
|
||||||
|
});
|
||||||
|
http.listen(0, "127.0.0.1");
|
||||||
|
await once(http, "listening");
|
||||||
|
cleanup.push(async () => {
|
||||||
|
for (const socket of ws.clients) socket.terminate();
|
||||||
|
await new Promise<void>((resolve) => ws.close(() => resolve()));
|
||||||
|
await new Promise<void>((resolve) => http.close(() => resolve()));
|
||||||
|
});
|
||||||
|
const address = http.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("Missing relay port");
|
||||||
|
const a = await daemon();
|
||||||
|
const { paseo } = await api(a);
|
||||||
|
await paseo.hosts.add({
|
||||||
|
connection: `relay://127.0.0.1:${address.port}/srv_relayed?key=${encodeURIComponent(exportPublicKey(keys.publicKey))}`,
|
||||||
|
});
|
||||||
|
await capabilityRetry(paseo);
|
||||||
|
await expect.poll(() => retryClosed, { timeout: 15000 }).toBe(true);
|
||||||
|
expect(legacyReceived).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test("an unreachable saved target becomes online after its daemon starts without being added again", async () => {
|
test("an unreachable saved target becomes online after its daemon starts without being added again", async () => {
|
||||||
const reservation = createServer();
|
const reservation = createServer();
|
||||||
reservation.listen(0, "127.0.0.1");
|
reservation.listen(0, "127.0.0.1");
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import { z } from "zod";
|
|||||||
import {
|
import {
|
||||||
createHostClient,
|
createHostClient,
|
||||||
createHostChannelTransport,
|
createHostChannelTransport,
|
||||||
|
createHostTransportFactory,
|
||||||
parseHostTarget,
|
parseHostTarget,
|
||||||
HostTransportBudget,
|
HostTransportBudget,
|
||||||
} from "@getpaseo/client/node";
|
} from "@getpaseo/client/node";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import type { DaemonTransportFactory } from "@getpaseo/client/internal/daemon-client-transport-types";
|
||||||
import {
|
import {
|
||||||
HostSummarySchema,
|
HostSummarySchema,
|
||||||
HostAddSchema,
|
HostAddSchema,
|
||||||
@@ -235,23 +237,30 @@ export class ConnectedHosts {
|
|||||||
if (route.registrationId === id)
|
if (route.registrationId === id)
|
||||||
route.close(code, "Host access was disabled, replaced or removed");
|
route.close(code, "Host access was disabled, replaced or removed");
|
||||||
}
|
}
|
||||||
private start(row: Saved): void {
|
private start(
|
||||||
|
row: Saved,
|
||||||
|
resume?: { failures: number; transports: DaemonTransportFactory },
|
||||||
|
): void {
|
||||||
if (!row.enabled || this.closed) return;
|
if (!row.enabled || this.closed) return;
|
||||||
let stopped = false;
|
const transports =
|
||||||
const client = createHostClient(
|
resume?.transports ??
|
||||||
row.connection,
|
createHostTransportFactory(row.connection, this.budget, {
|
||||||
{
|
|
||||||
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
|
|
||||||
connectTimeoutMs: 10000,
|
|
||||||
webSocketAgent: webSocketProxyAgent,
|
webSocketAgent: webSocketProxyAgent,
|
||||||
},
|
});
|
||||||
this.budget,
|
let stopped = false;
|
||||||
);
|
let attempts = resume?.failures ?? 0;
|
||||||
|
let retry: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const client = createHostClient(row.connection, {
|
||||||
|
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
|
||||||
|
connectTimeoutMs: 10000,
|
||||||
|
transportFactory: transports,
|
||||||
|
});
|
||||||
const live: Live = {
|
const live: Live = {
|
||||||
client,
|
client,
|
||||||
accepted: false,
|
accepted: false,
|
||||||
stop: () => {
|
stop: () => {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
|
clearTimeout(retry);
|
||||||
void client.close();
|
void client.close();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -285,7 +294,7 @@ export class ConnectedHosts {
|
|||||||
if (info.features?.connectedHosts !== true || info.features?.hostMultiplexing !== true)
|
if (info.features?.connectedHosts !== true || info.features?.hostMultiplexing !== true)
|
||||||
throw new HostError(
|
throw new HostError(
|
||||||
"unsupported_capability",
|
"unsupported_capability",
|
||||||
"Update the destination daemon to use connected hosts.",
|
"Update the destination daemon to use connected hosts; retrying automatically.",
|
||||||
);
|
);
|
||||||
// Pin and persist under the same writer as management to prevent discovery races.
|
// Pin and persist under the same writer as management to prevent discovery races.
|
||||||
await this.mutate(async () => {
|
await this.mutate(async () => {
|
||||||
@@ -304,16 +313,22 @@ export class ConnectedHosts {
|
|||||||
if (stopped || client.getLastServerInfoMessage() !== info || !client.isConnected) return;
|
if (stopped || client.getLastServerInfoMessage() !== info || !client.isConnected) return;
|
||||||
Object.assign(row, next);
|
Object.assign(row, next);
|
||||||
live.accepted = true;
|
live.accepted = true;
|
||||||
|
attempts = 0;
|
||||||
this.changed();
|
this.changed();
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
row.status = "error";
|
|
||||||
row.error = connectionFailure(error, client.authFailureReason !== null) ?? {
|
row.error = connectionFailure(error, client.authFailureReason !== null) ?? {
|
||||||
code: "host_offline",
|
code: "host_offline",
|
||||||
message: "Host connection failed",
|
message: "Host connection failed",
|
||||||
};
|
};
|
||||||
row.nextRetryAt = null;
|
if (terminalFailures.has(row.error.code)) {
|
||||||
|
row.status = "error";
|
||||||
|
row.nextRetryAt = null;
|
||||||
|
} else {
|
||||||
|
stopped = true;
|
||||||
|
retry = this.scheduleAcceptanceRetry(row, attempts, transports);
|
||||||
|
}
|
||||||
this.changed();
|
this.changed();
|
||||||
await client.close();
|
await client.close();
|
||||||
}
|
}
|
||||||
@@ -329,18 +344,17 @@ export class ConnectedHosts {
|
|||||||
if (route.registrationId === row.registrationId) route.close();
|
if (route.registrationId === row.registrationId) route.close();
|
||||||
row.status = state.status === "connecting" ? "connecting" : "offline";
|
row.status = state.status === "connecting" ? "connecting" : "offline";
|
||||||
row.nextRetryAt = state.status === "disconnected" ? (state.nextRetryAt ?? null) : null;
|
row.nextRetryAt = state.status === "disconnected" ? (state.nextRetryAt ?? null) : null;
|
||||||
row.error =
|
if (state.status === "disconnected")
|
||||||
state.status === "disconnected"
|
row.error = connectionFailure(
|
||||||
? (connectionFailure(
|
Object.assign(new Error("Check the SSH configuration and daemon credentials"), {
|
||||||
Object.assign(new Error("Check the SSH configuration and daemon credentials"), {
|
code: state.failureCode,
|
||||||
code: state.failureCode,
|
}),
|
||||||
}),
|
client.authFailureReason !== null,
|
||||||
client.authFailureReason !== null,
|
) ?? {
|
||||||
) ?? {
|
code: "host_offline",
|
||||||
code: "host_offline",
|
message: "Cannot reach host. Check the connection; retrying automatically.",
|
||||||
message: "Cannot reach host. Check the connection; retrying automatically.",
|
};
|
||||||
})
|
else if (row.error?.code !== "unsupported_capability") row.error = null;
|
||||||
: null;
|
|
||||||
if (row.error?.code === "authentication_failed") {
|
if (row.error?.code === "authentication_failed") {
|
||||||
row.status = "error";
|
row.status = "error";
|
||||||
row.nextRetryAt = null;
|
row.nextRetryAt = null;
|
||||||
@@ -351,6 +365,18 @@ export class ConnectedHosts {
|
|||||||
});
|
});
|
||||||
void client.connect().catch(() => {});
|
void client.connect().catch(() => {});
|
||||||
}
|
}
|
||||||
|
private scheduleAcceptanceRetry(
|
||||||
|
row: Saved,
|
||||||
|
failures: number,
|
||||||
|
transports: DaemonTransportFactory,
|
||||||
|
): ReturnType<typeof setTimeout> {
|
||||||
|
const delay = acceptanceRetryDelayMs(failures);
|
||||||
|
row.status = "offline";
|
||||||
|
row.nextRetryAt = new Date(Date.now() + delay).toISOString();
|
||||||
|
const retry = setTimeout(() => this.start(row, { failures: failures + 1, transports }), delay);
|
||||||
|
retry.unref();
|
||||||
|
return retry;
|
||||||
|
}
|
||||||
async openRoute(input: {
|
async openRoute(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
scopeId: string;
|
scopeId: string;
|
||||||
@@ -531,6 +557,20 @@ export class ConnectedHosts {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ACCEPTANCE_RETRY_BASE_MS = 1500;
|
||||||
|
const ACCEPTANCE_RETRY_MAX_MS = 30_000;
|
||||||
|
|
||||||
|
function acceptanceRetryDelayMs(failures: number): number {
|
||||||
|
return Math.min(ACCEPTANCE_RETRY_BASE_MS * 2 ** failures, ACCEPTANCE_RETRY_MAX_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminalFailures = new Set([
|
||||||
|
"server_id_mismatch",
|
||||||
|
"self_host",
|
||||||
|
"duplicate_host",
|
||||||
|
"authentication_failed",
|
||||||
|
]);
|
||||||
|
|
||||||
function connectionFailure(error: unknown, authenticationFailed: boolean): HostSummary["error"] {
|
function connectionFailure(error: unknown, authenticationFailed: boolean): HostSummary["error"] {
|
||||||
if (error instanceof HostError) return { code: error.code, message: error.message };
|
if (error instanceof HostError) return { code: error.code, message: error.message };
|
||||||
if (error instanceof Error && "code" in error && error.code === "authentication_failed")
|
if (error instanceof Error && "code" in error && error.code === "authentication_failed")
|
||||||
|
|||||||
@@ -88,13 +88,9 @@ test.each(["uncommitted", "base", "commit"] as const)(
|
|||||||
60_000,
|
60_000,
|
||||||
);
|
);
|
||||||
|
|
||||||
test.each(
|
test.each(["single", "batch", "rename"] as const)(
|
||||||
(["uncommitted", "base"] as const).flatMap((mode) =>
|
"%s diff includes hunks from a repository subdirectory before and after commit",
|
||||||
(["single", "batch", "rename"] as const).map((shape) => ({ mode, shape })),
|
async (shape) => {
|
||||||
),
|
|
||||||
)(
|
|
||||||
"$mode $shape diff includes hunks from a repository subdirectory",
|
|
||||||
async ({ mode, shape }) => {
|
|
||||||
const ctx = await createDaemonTestContext();
|
const ctx = await createDaemonTestContext();
|
||||||
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-"));
|
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-"));
|
||||||
|
|
||||||
@@ -112,9 +108,7 @@ test.each(
|
|||||||
}
|
}
|
||||||
git(cwd, "add", ".");
|
git(cwd, "add", ".");
|
||||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
|
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
|
||||||
if (mode === "base") {
|
git(cwd, "checkout", "-b", "feature");
|
||||||
git(cwd, "checkout", "-b", "feature");
|
|
||||||
}
|
|
||||||
const filename = shape === "rename" ? "renamed.txt" : "normal.txt";
|
const filename = shape === "rename" ? "renamed.txt" : "normal.txt";
|
||||||
if (shape === "rename") {
|
if (shape === "rename") {
|
||||||
git(cwd, "mv", "sub/normal.txt", `sub/${filename}`);
|
git(cwd, "mv", "sub/normal.txt", `sub/${filename}`);
|
||||||
@@ -123,36 +117,38 @@ test.each(
|
|||||||
if (shape === "batch") {
|
if (shape === "batch") {
|
||||||
writeFileSync(join(cwd, "sibling.txt"), "new sibling\n");
|
writeFileSync(join(cwd, "sibling.txt"), "new sibling\n");
|
||||||
}
|
}
|
||||||
if (mode === "base") {
|
async function expectSubdirectoryDiff(mode: "uncommitted" | "base") {
|
||||||
git(cwd, "add", ".");
|
const compare = { mode, baseRef: "main" };
|
||||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
|
const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare);
|
||||||
|
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare);
|
||||||
|
const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare);
|
||||||
|
const liveDiff = await subscription.ready;
|
||||||
|
await subscription.release();
|
||||||
|
|
||||||
|
expect(rootDiff.error).toBeNull();
|
||||||
|
expect(subdirectoryDiff.error).toBeNull();
|
||||||
|
expect(liveDiff.error).toBeNull();
|
||||||
|
expect(liveDiff.files).toEqual(rootDiff.files);
|
||||||
|
expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1);
|
||||||
|
const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`);
|
||||||
|
expect(file).toMatchObject({
|
||||||
|
additions: 1,
|
||||||
|
deletions: 1,
|
||||||
|
...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}),
|
||||||
|
});
|
||||||
|
expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ type: "remove", content: "before" }),
|
||||||
|
expect.objectContaining({ type: "add", content: "after" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
|
||||||
}
|
}
|
||||||
|
|
||||||
const compare = { mode, baseRef: "main" };
|
await expectSubdirectoryDiff("uncommitted");
|
||||||
const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare);
|
git(cwd, "add", ".");
|
||||||
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare);
|
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
|
||||||
const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare);
|
await expectSubdirectoryDiff("base");
|
||||||
const liveDiff = await subscription.ready;
|
|
||||||
await subscription.release();
|
|
||||||
|
|
||||||
expect(rootDiff.error).toBeNull();
|
|
||||||
expect(subdirectoryDiff.error).toBeNull();
|
|
||||||
expect(liveDiff.error).toBeNull();
|
|
||||||
expect(liveDiff.files).toEqual(rootDiff.files);
|
|
||||||
expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1);
|
|
||||||
const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`);
|
|
||||||
expect(file).toMatchObject({
|
|
||||||
additions: 1,
|
|
||||||
deletions: 1,
|
|
||||||
...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}),
|
|
||||||
});
|
|
||||||
expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({ type: "remove", content: "before" }),
|
|
||||||
expect.objectContaining({ type: "add", content: "after" }),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
|
|
||||||
} finally {
|
} finally {
|
||||||
await ctx.cleanup();
|
await ctx.cleanup();
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { createDaemonTestContext } from "../test-utils/index.js";
|
||||||
|
|
||||||
|
test("an attended MCP caller can discover and supply a cross-provider child mode", async () => {
|
||||||
|
const artifacts = path.join(process.cwd(), ".artifacts");
|
||||||
|
await mkdir(artifacts, { recursive: true });
|
||||||
|
const root = await mkdtemp(path.join(artifacts, "create-agent-mode-"));
|
||||||
|
const ctx = await createDaemonTestContext({ paseoHomeRoot: root, mcpEnabled: true });
|
||||||
|
const mcp = new Client({ name: "mode-contract-test", version: "1" });
|
||||||
|
try {
|
||||||
|
const parent = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
cwd: root,
|
||||||
|
modeId: "default",
|
||||||
|
title: "Attended parent",
|
||||||
|
});
|
||||||
|
await mcp.connect(
|
||||||
|
new StreamableHTTPClientTransport(
|
||||||
|
new URL(`http://127.0.0.1:${ctx.daemon.port}/mcp/agents?callerAgentId=${parent.id}`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { tools } = await mcp.listTools();
|
||||||
|
const create = tools.find((tool) => tool.name === "create_agent");
|
||||||
|
expect.soft(create?.description).toContain("settings.modeId");
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
provider: "codex/gpt-5.4-mini",
|
||||||
|
title: "Cross-provider child",
|
||||||
|
initialPrompt: "Reply with OK",
|
||||||
|
};
|
||||||
|
const refused = await mcp.callTool({ name: "create_agent", arguments: args });
|
||||||
|
expect(refused.isError).toBe(true);
|
||||||
|
expect.soft(refused.content).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "text",
|
||||||
|
text: expect.stringContaining("Pass an explicit mode in settings.modeId"),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect((await ctx.client.fetchAgents({ subscribe: {} })).entries).toHaveLength(1);
|
||||||
|
|
||||||
|
const inspected = await mcp.callTool({
|
||||||
|
name: "inspect_provider",
|
||||||
|
arguments: { provider: "codex" },
|
||||||
|
});
|
||||||
|
expect(inspected.isError).not.toBe(true);
|
||||||
|
expect(inspected.structuredContent).toMatchObject({
|
||||||
|
modes: expect.arrayContaining([expect.objectContaining({ id: "auto" })]),
|
||||||
|
});
|
||||||
|
const created = await mcp.callTool({
|
||||||
|
name: "create_agent",
|
||||||
|
arguments: { ...args, settings: { modeId: "auto" } },
|
||||||
|
});
|
||||||
|
expect(created.isError).not.toBe(true);
|
||||||
|
expect(created.structuredContent).toMatchObject({ type: "codex", currentModeId: "auto" });
|
||||||
|
const childId = (created.structuredContent as { agentId: string }).agentId;
|
||||||
|
expect((await ctx.client.fetchAgent(childId))?.agent.currentModeId).toBe("auto");
|
||||||
|
expect((await ctx.client.waitForFinish(childId, 5000)).status).toBe("idle");
|
||||||
|
} finally {
|
||||||
|
await mcp.close();
|
||||||
|
await ctx.cleanup();
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
@@ -102,53 +102,52 @@ describe("daemon E2E", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test.each([
|
test("downloads ASCII and Unicode filenames through one workspace", async () => {
|
||||||
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
|
const cwd = tmpCwd();
|
||||||
{
|
try {
|
||||||
fileName: "café.txt",
|
const agent = await ctx.client.createAgent({
|
||||||
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
|
provider: "codex",
|
||||||
},
|
model: CODEX_TEST_MODEL,
|
||||||
{
|
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||||
fileName: "中文报告 (最终版).txt",
|
cwd,
|
||||||
disposition:
|
title: "Download Token Test Agent",
|
||||||
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
|
});
|
||||||
},
|
|
||||||
{
|
expect(agent.id).toMatch(
|
||||||
fileName: "報告書.md",
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||||
disposition:
|
);
|
||||||
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
|
|
||||||
},
|
for (const { fileName, disposition } of [
|
||||||
{
|
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
|
||||||
fileName: "보고서.txt",
|
{
|
||||||
disposition:
|
fileName: "café.txt",
|
||||||
"attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
|
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fileName: "report-📄.txt",
|
fileName: "中文报告 (最终版).txt",
|
||||||
disposition:
|
disposition:
|
||||||
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
|
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
|
||||||
},
|
},
|
||||||
])(
|
{
|
||||||
"issues token over WS and downloads $fileName via HTTP",
|
fileName: "報告書.md",
|
||||||
async ({ fileName, disposition }) => {
|
disposition:
|
||||||
const cwd = tmpCwd();
|
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
|
||||||
try {
|
},
|
||||||
|
{
|
||||||
|
fileName: "보고서.txt",
|
||||||
|
disposition:
|
||||||
|
"attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: "report-📄.txt",
|
||||||
|
disposition:
|
||||||
|
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
|
||||||
|
},
|
||||||
|
]) {
|
||||||
const filePath = path.join(cwd, fileName);
|
const filePath = path.join(cwd, fileName);
|
||||||
const fileContents = "download test payload";
|
const fileContents = `download test payload: ${fileName}`;
|
||||||
writeFileSync(filePath, fileContents, "utf-8");
|
writeFileSync(filePath, fileContents, "utf-8");
|
||||||
|
|
||||||
const agent = await ctx.client.createAgent({
|
|
||||||
provider: "codex",
|
|
||||||
model: CODEX_TEST_MODEL,
|
|
||||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
|
||||||
cwd,
|
|
||||||
title: "Download Token Test Agent",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(agent.id).toMatch(
|
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
|
||||||
);
|
|
||||||
|
|
||||||
const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName);
|
const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName);
|
||||||
|
|
||||||
expect(tokenResponse.error).toBeNull();
|
expect(tokenResponse.error).toBeNull();
|
||||||
@@ -167,12 +166,11 @@ describe("daemon E2E", () => {
|
|||||||
|
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
expect(body).toBe(fileContents);
|
expect(body).toBe(fileContents);
|
||||||
} finally {
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
}
|
}
|
||||||
},
|
} finally {
|
||||||
60000,
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
);
|
}
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
test("rejects invalid token", async () => {
|
test("rejects invalid token", async () => {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { FormInfo } from "@opencode/client";
|
||||||
|
import { expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
|
import { OpenCodeV2AgentClient } from "../agent/providers/opencode/v2/agent.js";
|
||||||
|
import { V2Harness } from "../agent/providers/opencode/test-utils/v2-harness.js";
|
||||||
|
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||||
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
|
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
|
|
||||||
|
async function createQuestionHarness() {
|
||||||
|
const artifacts = path.resolve(".artifacts");
|
||||||
|
await mkdir(artifacts, { recursive: true });
|
||||||
|
const root = await mkdtemp(path.join(artifacts, "peer-permissions-"));
|
||||||
|
const runtime = new V2Harness();
|
||||||
|
runtime.autoComplete = false;
|
||||||
|
runtime.api.model.list = async () => ({ location: runtime.info.location, data: [] });
|
||||||
|
runtime.api.provider.list = async () => ({
|
||||||
|
location: runtime.info.location,
|
||||||
|
data: [{ id: "test", name: "Test", activation: "enabled", package: "test" }],
|
||||||
|
});
|
||||||
|
let form: FormInfo | null = null;
|
||||||
|
const answers: Array<Record<string, unknown>> = [];
|
||||||
|
runtime.api.session.form.list = async () => (form ? [form] : []);
|
||||||
|
runtime.api.session.form.cancel = async () => {
|
||||||
|
form = null;
|
||||||
|
runtime.finishExecution("interrupted");
|
||||||
|
};
|
||||||
|
runtime.api.session.form.reply = async ({ answer }) => {
|
||||||
|
answers.push(answer);
|
||||||
|
form = null;
|
||||||
|
runtime.finishExecution();
|
||||||
|
};
|
||||||
|
// A steer is admitted to the existing execution, rather than starting another one.
|
||||||
|
runtime.prompt = async (input) => {
|
||||||
|
runtime.prompts.push(input.text);
|
||||||
|
if (runtime.prompts.length === 1) runtime.startExecution();
|
||||||
|
};
|
||||||
|
const provider = new OpenCodeV2AgentClient({
|
||||||
|
logger: createTestLogger(),
|
||||||
|
runtime: runtime.runtime,
|
||||||
|
});
|
||||||
|
const daemon = await createTestPaseoDaemon({
|
||||||
|
agentClients: { ...createTestAgentClients(), opencode: provider },
|
||||||
|
paseoHomeRoot: path.join(root, "daemon"),
|
||||||
|
staticDir: path.join(root, "static"),
|
||||||
|
cleanup: false,
|
||||||
|
});
|
||||||
|
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
await client.fetchAgents({ subscribe: {} });
|
||||||
|
const recipient = await client.createAgent({
|
||||||
|
provider: "opencode",
|
||||||
|
cwd: root,
|
||||||
|
title: "Waiting for a human answer",
|
||||||
|
});
|
||||||
|
const sender = await client.createAgent({ provider: "claude", cwd: root });
|
||||||
|
await client.sendMessage(recipient.id, "Ask which color to use");
|
||||||
|
await expect.poll(() => runtime.prompts).toHaveLength(1);
|
||||||
|
form = {
|
||||||
|
id: "color-question",
|
||||||
|
sessionID: "session",
|
||||||
|
title: "Color",
|
||||||
|
fields: [{ key: "color", title: "Color", type: "string" }],
|
||||||
|
};
|
||||||
|
runtime.push({ id: "question-created", created: 2, type: "form.created", data: { form } });
|
||||||
|
await client.waitForAgentUpsert(recipient.id, (agent) =>
|
||||||
|
(agent.pendingPermissions ?? []).some((request) => request.id === "color-question"),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
recipient,
|
||||||
|
sender,
|
||||||
|
answers,
|
||||||
|
async close() {
|
||||||
|
await client.close();
|
||||||
|
await daemon.close();
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await client.close();
|
||||||
|
await daemon.close();
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("an agent-sourced steer leaves the question pending and answerable", async () => {
|
||||||
|
const { client, recipient, sender, answers, close } = await createQuestionHarness();
|
||||||
|
try {
|
||||||
|
await client.sendAgentMessage(recipient.id, "Peer status check", {
|
||||||
|
sourceAgentId: sender.id,
|
||||||
|
});
|
||||||
|
// The public wait includes admitted message delivery before reporting permission status.
|
||||||
|
const waiting = await client.waitForFinish(recipient.id);
|
||||||
|
expect(waiting.status).toBe("permission");
|
||||||
|
expect(waiting.final?.pendingPermissions).toMatchObject([
|
||||||
|
{ id: "color-question", kind: "question" },
|
||||||
|
]);
|
||||||
|
await client.respondToPermission(recipient.id, "color-question", {
|
||||||
|
behavior: "allow",
|
||||||
|
updatedInput: { answers: { Color: "Blue" } },
|
||||||
|
});
|
||||||
|
await client.waitForAgentUpsert(
|
||||||
|
recipient.id,
|
||||||
|
(agent) => (agent.pendingPermissions ?? []).length === 0 && agent.status === "idle",
|
||||||
|
);
|
||||||
|
expect(answers).toEqual([{ color: "Blue" }]);
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a human steer still dismisses the question", async () => {
|
||||||
|
const { client, recipient, answers, close } = await createQuestionHarness();
|
||||||
|
try {
|
||||||
|
await client.sendAgentMessage(recipient.id, "Use red instead", { activeTurnBehavior: "steer" });
|
||||||
|
const finished = await client.waitForFinish(recipient.id);
|
||||||
|
expect(finished.status).toBe("idle");
|
||||||
|
expect(finished.final?.pendingPermissions ?? []).toEqual([]);
|
||||||
|
expect(answers).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -20,6 +20,7 @@ import { DaemonConfigStore } from "../daemon-config-store.js";
|
|||||||
import { PluginService } from "./index.js";
|
import { PluginService } from "./index.js";
|
||||||
import { BuiltinPluginLoader } from "./builtin/index.js";
|
import { BuiltinPluginLoader } from "./builtin/index.js";
|
||||||
import { ManagedPluginSources } from "./managed-source.js";
|
import { ManagedPluginSources } from "./managed-source.js";
|
||||||
|
import type { PluginProviderMetadata } from "./plugin-process-protocol.js";
|
||||||
import {
|
import {
|
||||||
startNpmRegistry,
|
startNpmRegistry,
|
||||||
npmPluginPackages,
|
npmPluginPackages,
|
||||||
@@ -179,6 +180,55 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) {
|
|||||||
return { runtime, started, releaseStart, starts };
|
return { runtime, started, releaseStart, starts };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createGatedRuntime(providers: Record<string, readonly PluginProviderMetadata[]> = {}) {
|
||||||
|
const gates = new Map<string, PromiseWithResolvers<void>>();
|
||||||
|
const gate = (pluginId: string) => {
|
||||||
|
const existing = gates.get(pluginId);
|
||||||
|
if (existing) return existing;
|
||||||
|
const created = Promise.withResolvers<void>();
|
||||||
|
gates.set(pluginId, created);
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
const begun: string[] = [];
|
||||||
|
const running = new Set<string>();
|
||||||
|
const runtime: TestPluginRuntime = {
|
||||||
|
...emptyUsageRuntime,
|
||||||
|
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
|
||||||
|
invoke: async () => undefined,
|
||||||
|
getLogs: () => [],
|
||||||
|
clearLogs: () => undefined,
|
||||||
|
getProviderRegistrations: (pluginId) =>
|
||||||
|
running.has(pluginId) ? (providers[pluginId] ?? []) : [],
|
||||||
|
startPlugin: async (pluginId) => {
|
||||||
|
begun.push(pluginId);
|
||||||
|
await gate(pluginId).promise;
|
||||||
|
running.add(pluginId);
|
||||||
|
},
|
||||||
|
stopPluginById: async (pluginId) => running.delete(pluginId),
|
||||||
|
stopAll: async () => {
|
||||||
|
running.clear();
|
||||||
|
},
|
||||||
|
subscribe: () => () => undefined,
|
||||||
|
bindPaseoSessionHost: () => undefined,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
runtime,
|
||||||
|
begun,
|
||||||
|
finish: (pluginId: string) => gate(pluginId).resolve(),
|
||||||
|
fail: (pluginId: string, error: Error) => gate(pluginId).reject(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextNotification(service: PluginService, pluginId: string): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const unsubscribe = service.subscribe((notified) => {
|
||||||
|
if (notified !== pluginId) return;
|
||||||
|
unsubscribe();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("PluginService", () => {
|
describe("PluginService", () => {
|
||||||
it.each([
|
it.each([
|
||||||
{ shadow: false, runtime: "subprocess" },
|
{ shadow: false, runtime: "subprocess" },
|
||||||
@@ -372,15 +422,127 @@ describe("PluginService", () => {
|
|||||||
"startup-first": { source: "directory", path: first },
|
"startup-first": { source: "directory", path: first },
|
||||||
"startup-second": { source: "directory", path: second },
|
"startup-second": { source: "directory", path: second },
|
||||||
});
|
});
|
||||||
const snapshots: string[][] = [];
|
const notifications: { pluginId: string; running: boolean }[] = [];
|
||||||
service.subscribe(() => snapshots.push(catalogIds(service)));
|
service.subscribe((pluginId) =>
|
||||||
|
notifications.push({ pluginId, running: catalogIds(service).includes(pluginId) }),
|
||||||
|
);
|
||||||
|
|
||||||
await service.start();
|
await service.start();
|
||||||
|
|
||||||
expect(snapshots).toEqual([["startup-first"], ["startup-first", "startup-second"]]);
|
expect(notifications).toEqual([
|
||||||
|
{ pluginId: "startup-first", running: true },
|
||||||
|
{ pluginId: "startup-second", running: true },
|
||||||
|
]);
|
||||||
|
expect(catalogIds(service)).toEqual(["startup-first", "startup-second"]);
|
||||||
await service.stopAllPlugins();
|
await service.stopAllPlugins();
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
|
it("starts configured plugins concurrently and publishes them in config order", async () => {
|
||||||
|
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||||
|
roots.push(home);
|
||||||
|
const gated = createGatedRuntime();
|
||||||
|
const service = createService(
|
||||||
|
home,
|
||||||
|
{
|
||||||
|
alpha: { source: "directory", path: "/plugins/alpha" },
|
||||||
|
beta: { source: "directory", path: "/plugins/beta" },
|
||||||
|
gamma: { source: "directory", path: "/plugins/gamma" },
|
||||||
|
},
|
||||||
|
{ runtime: gated.runtime },
|
||||||
|
);
|
||||||
|
const notified: string[] = [];
|
||||||
|
service.subscribe((pluginId) => notified.push(pluginId));
|
||||||
|
let started = false;
|
||||||
|
const starting = (async () => {
|
||||||
|
await service.start();
|
||||||
|
started = true;
|
||||||
|
})();
|
||||||
|
|
||||||
|
await expect.poll(() => gated.begun).toEqual(["alpha", "beta", "gamma"]);
|
||||||
|
|
||||||
|
gated.finish("gamma");
|
||||||
|
const alphaPublished = nextNotification(service, "alpha");
|
||||||
|
gated.finish("alpha");
|
||||||
|
await alphaPublished;
|
||||||
|
expect(notified).toEqual(["alpha"]);
|
||||||
|
expect(started).toBe(false);
|
||||||
|
|
||||||
|
gated.finish("beta");
|
||||||
|
await starting;
|
||||||
|
expect(notified).toEqual(["alpha", "beta", "gamma"]);
|
||||||
|
expect(catalogIds(service)).toEqual(["alpha", "beta", "gamma"]);
|
||||||
|
await service.stopAllPlugins();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts the other configured plugins when one fails while they are still starting", async () => {
|
||||||
|
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||||
|
roots.push(home);
|
||||||
|
const gated = createGatedRuntime();
|
||||||
|
const service = createService(
|
||||||
|
home,
|
||||||
|
{
|
||||||
|
broken: { source: "directory", path: "/plugins/broken" },
|
||||||
|
healthy: { source: "directory", path: "/plugins/healthy" },
|
||||||
|
},
|
||||||
|
{ runtime: gated.runtime },
|
||||||
|
);
|
||||||
|
const notified: string[] = [];
|
||||||
|
service.subscribe((pluginId) => notified.push(pluginId));
|
||||||
|
const starting = service.start();
|
||||||
|
|
||||||
|
await expect.poll(() => gated.begun).toEqual(["broken", "healthy"]);
|
||||||
|
const brokenPublished = nextNotification(service, "broken");
|
||||||
|
gated.fail("broken", new Error("startup exploded"));
|
||||||
|
await brokenPublished;
|
||||||
|
gated.finish("healthy");
|
||||||
|
await starting;
|
||||||
|
|
||||||
|
expect(notified).toEqual(["broken", "healthy"]);
|
||||||
|
expect(catalogIds(service)).toEqual(["healthy"]);
|
||||||
|
expect(await service.listPlugins()).toMatchObject([
|
||||||
|
{ id: "broken", status: "failed", error: "startup exploded" },
|
||||||
|
{ id: "healthy", status: "running" },
|
||||||
|
]);
|
||||||
|
await service.stopAllPlugins();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a provider ID with the plugin listed first when both start at once", async () => {
|
||||||
|
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||||
|
roots.push(home);
|
||||||
|
const gated = createGatedRuntime({
|
||||||
|
first: [{ id: "shared-agent", label: "First agent" }],
|
||||||
|
second: [{ id: "shared-agent", label: "Second agent" }],
|
||||||
|
});
|
||||||
|
const service = createService(
|
||||||
|
home,
|
||||||
|
{
|
||||||
|
first: { source: "directory", path: "/plugins/first" },
|
||||||
|
second: { source: "directory", path: "/plugins/second" },
|
||||||
|
},
|
||||||
|
{ runtime: gated.runtime },
|
||||||
|
);
|
||||||
|
const starting = service.start();
|
||||||
|
|
||||||
|
await expect.poll(() => gated.begun).toEqual(["first", "second"]);
|
||||||
|
gated.finish("second");
|
||||||
|
gated.finish("first");
|
||||||
|
await starting;
|
||||||
|
|
||||||
|
expect(service.getProviderRegistrations()).toMatchObject([
|
||||||
|
{ id: "shared-agent", label: "First agent" },
|
||||||
|
]);
|
||||||
|
expect(catalogIds(service)).toEqual(["first"]);
|
||||||
|
expect(await service.listPlugins()).toMatchObject([
|
||||||
|
{ id: "first", status: "running" },
|
||||||
|
{
|
||||||
|
id: "second",
|
||||||
|
status: "failed",
|
||||||
|
error: 'Plugin second cannot register provider ID "shared-agent" twice',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await service.stopAllPlugins();
|
||||||
|
});
|
||||||
|
|
||||||
it("uses an explicit config key, exposes reload failure, and retries from disk", async () => {
|
it("uses an explicit config key, exposes reload failure, and retries from disk", async () => {
|
||||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||||
roots.push(home);
|
roots.push(home);
|
||||||
|
|||||||
@@ -230,11 +230,18 @@ export class PluginService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (config.pluginsEnabled === true) {
|
if (config.pluginsEnabled === true) {
|
||||||
for (const [pluginId, source] of Object.entries(config.plugins ?? {})) {
|
let earlierPublished: Promise<unknown> = Promise.resolve();
|
||||||
if (source.enabled === false) continue;
|
await Promise.all(
|
||||||
await this.startConfigured(pluginId);
|
Object.entries(config.plugins ?? {})
|
||||||
this.notify(pluginId);
|
.filter(([, source]) => source.enabled !== false)
|
||||||
}
|
.map(async ([pluginId]) => {
|
||||||
|
const publishTurn = earlierPublished;
|
||||||
|
const starting = this.startConfigured(pluginId, publishTurn);
|
||||||
|
earlierPublished = Promise.all([publishTurn, starting]);
|
||||||
|
await starting;
|
||||||
|
this.notify(pluginId);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this.configStore.onFieldChange("pluginsEnabled", (value) => {
|
this.configStore.onFieldChange("pluginsEnabled", (value) => {
|
||||||
this.handleGlobalSwitch(value === true);
|
this.handleGlobalSwitch(value === true);
|
||||||
@@ -560,12 +567,12 @@ export class PluginService {
|
|||||||
}).catch((error) => this.logger.error({ err: error }, "Failed to enable plugins"));
|
}).catch((error) => this.logger.error({ err: error }, "Failed to enable plugins"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startConfigured(pluginId: string): Promise<void> {
|
private async startConfigured(pluginId: string, publishTurn?: Promise<unknown>): Promise<void> {
|
||||||
const source = this.configStore.get().plugins?.[pluginId];
|
const source = this.configStore.get().plugins?.[pluginId];
|
||||||
if (!source || source.enabled === false || !this.canPublish(pluginId)) return;
|
if (!source || source.enabled === false || !this.canPublish(pluginId)) return;
|
||||||
this.errors.delete(pluginId);
|
this.errors.delete(pluginId);
|
||||||
try {
|
try {
|
||||||
await this.startPlugin(pluginId, source.path);
|
await this.startPlugin(pluginId, source.path, publishTurn);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.canPublish(pluginId)) this.recordFailure(pluginId, error);
|
if (this.canPublish(pluginId)) this.recordFailure(pluginId, error);
|
||||||
}
|
}
|
||||||
@@ -594,8 +601,13 @@ export class PluginService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startPlugin(pluginId: string, sourcePath: string): Promise<void> {
|
private async startPlugin(
|
||||||
|
pluginId: string,
|
||||||
|
sourcePath: string,
|
||||||
|
publishTurn?: Promise<unknown>,
|
||||||
|
): Promise<void> {
|
||||||
await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId));
|
await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId));
|
||||||
|
await publishTurn;
|
||||||
try {
|
try {
|
||||||
await this.publishProviderRegistrations(pluginId, sourcePath);
|
await this.publishProviderRegistrations(pluginId, sourcePath);
|
||||||
this.publishUsageSources(pluginId);
|
this.publishUsageSources(pluginId);
|
||||||
|
|||||||
@@ -8318,7 +8318,7 @@ export class Session {
|
|||||||
},
|
},
|
||||||
activeTurnBehavior: msg.activeTurnBehavior ?? "steer",
|
activeTurnBehavior: msg.activeTurnBehavior ?? "steer",
|
||||||
sessionMode: msg.sessionMode,
|
sessionMode: msg.sessionMode,
|
||||||
clearPendingPermissions: true,
|
clearPendingPermissions: false,
|
||||||
});
|
});
|
||||||
deliveryStatus = "queued";
|
deliveryStatus = "queued";
|
||||||
queued();
|
queued();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { flushGitCommandTrace } from "../utils/git-command-trace.js";
|
||||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
@@ -730,3 +731,103 @@ test("Git event identity retains a symlink's literal target before canonicalizin
|
|||||||
rmSync(root, { recursive: true, force: true });
|
rmSync(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.skipIf(process.platform !== "linux")(
|
||||||
|
"missing folders stop degraded Git polling and explicit refreshes, then recover",
|
||||||
|
async () => {
|
||||||
|
const artifacts = path.resolve(".artifacts");
|
||||||
|
mkdirSync(artifacts, { recursive: true });
|
||||||
|
const root = mkdtempSync(path.join(artifacts, "workspace-git-missing-"));
|
||||||
|
const cwd = path.join(root, "repo");
|
||||||
|
const initializeRepo = (branch: string) => {
|
||||||
|
mkdirSync(cwd);
|
||||||
|
execFileSync("git", ["init", "-b", branch], { cwd, stdio: "pipe" });
|
||||||
|
writeFileSync(path.join(cwd, "README.md"), "fixture\n");
|
||||||
|
execFileSync("git", ["add", "."], { cwd, stdio: "pipe" });
|
||||||
|
execFileSync(
|
||||||
|
"git",
|
||||||
|
[
|
||||||
|
"-c",
|
||||||
|
"user.name=Test",
|
||||||
|
"-c",
|
||||||
|
"user.email=test@example.invalid",
|
||||||
|
"-c",
|
||||||
|
"commit.gpgsign=false",
|
||||||
|
"commit",
|
||||||
|
"-m",
|
||||||
|
"fixture",
|
||||||
|
],
|
||||||
|
{ cwd, stdio: "pipe" },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
initializeRepo("main");
|
||||||
|
// Exceed the real Linux observer's directory limit to enter degraded polling.
|
||||||
|
// No watcher errors or Git responses are injected.
|
||||||
|
for (let index = 0; index < 5100; index++) {
|
||||||
|
mkdirSync(path.join(cwd, `generated-${index}`));
|
||||||
|
}
|
||||||
|
const trace = path.join(root, "git.jsonl");
|
||||||
|
writeFileSync(trace, "");
|
||||||
|
const previousTrace = process.env.PASEO_GIT_TRACE_FILE;
|
||||||
|
process.env.PASEO_GIT_TRACE_FILE = trace;
|
||||||
|
let degraded = false;
|
||||||
|
let suspended = false;
|
||||||
|
const logger = pino(
|
||||||
|
{ level: "warn" },
|
||||||
|
{
|
||||||
|
write(line) {
|
||||||
|
const message = JSON.parse(line).msg;
|
||||||
|
if (message === "Failed to start working tree watcher; using degraded polling")
|
||||||
|
degraded = true;
|
||||||
|
if (
|
||||||
|
message ===
|
||||||
|
"Workspace folder no longer exists; suspended git observation until it is back"
|
||||||
|
)
|
||||||
|
suspended = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const service = new WorkspaceGitServiceImpl({ logger, paseoHome: path.join(root, "home") });
|
||||||
|
const subscription = service.registerWorkspace({ cwd }, () => {});
|
||||||
|
const commandCount = async () => {
|
||||||
|
await flushGitCommandTrace();
|
||||||
|
return readFileSync(trace, "utf8").trim().split("\n").filter(Boolean).length;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await expect
|
||||||
|
.poll(() => degraded && service.getMetrics().workspaceRefreshInFlightCount === 0, {
|
||||||
|
timeout: 15_000,
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
await expect.poll(() => suspended, { timeout: 15_000 }).toBe(true);
|
||||||
|
await expect.poll(() => service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
||||||
|
const commandsWhileMissing = await commandCount();
|
||||||
|
await service.getSnapshot(cwd, {
|
||||||
|
force: true,
|
||||||
|
includeForge: false,
|
||||||
|
reason: "refresh-button",
|
||||||
|
});
|
||||||
|
await service.refresh(cwd);
|
||||||
|
expect(await commandCount()).toBe(commandsWhileMissing);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 16_000));
|
||||||
|
expect(await commandCount()).toBe(commandsWhileMissing);
|
||||||
|
|
||||||
|
initializeRepo("restored");
|
||||||
|
const snapshot = await service.getSnapshot(cwd, {
|
||||||
|
force: true,
|
||||||
|
includeForge: false,
|
||||||
|
reason: "refresh-button",
|
||||||
|
});
|
||||||
|
expect(snapshot.git).toMatchObject({ isGit: true, currentBranch: "restored" });
|
||||||
|
await expect.poll(() => service.getMetrics().repositoryWorkspaceLinkCount).toBe(1);
|
||||||
|
} finally {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
await service.dispose();
|
||||||
|
if (previousTrace === undefined) delete process.env.PASEO_GIT_TRACE_FILE;
|
||||||
|
else process.env.PASEO_GIT_TRACE_FILE = previousTrace;
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
60_000,
|
||||||
|
);
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ function createService(
|
|||||||
filterEvents: (events) => events,
|
filterEvents: (events) => events,
|
||||||
verify: vi.fn(async () => {}),
|
verify: vi.fn(async () => {}),
|
||||||
})),
|
})),
|
||||||
|
workspaceFolderExists: vi.fn(async () => true),
|
||||||
...overrides,
|
...overrides,
|
||||||
} as never,
|
} as never,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ function currentPullRequestJson(overrides: Record<string, unknown> = {}): string
|
|||||||
isDraft: false,
|
isDraft: false,
|
||||||
baseRefName: "main",
|
baseRefName: "main",
|
||||||
headRefName: "feature",
|
headRefName: "feature",
|
||||||
|
headRepositoryOwner: { login: "acme" },
|
||||||
|
headRepository: { name: "repo" },
|
||||||
mergedAt: null,
|
mergedAt: null,
|
||||||
statusCheckRollup: [],
|
statusCheckRollup: [],
|
||||||
reviewDecision: "REVIEW_REQUIRED",
|
reviewDecision: "REVIEW_REQUIRED",
|
||||||
@@ -802,6 +804,28 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
service.dispose();
|
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 () => {
|
test("a forced GitHub-inclusive call during an in-flight forced git refresh queues a GitHub refresh", async () => {
|
||||||
const forcedGitRefresh = createDeferred<CheckoutStatusGit>();
|
const forcedGitRefresh = createDeferred<CheckoutStatusGit>();
|
||||||
const getCheckoutStatus = vi
|
const getCheckoutStatus = vi
|
||||||
@@ -852,7 +876,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
expect(getPullRequestStatus).toHaveBeenCalledWith(
|
expect(getPullRequestStatus).toHaveBeenCalledWith(
|
||||||
REPO_CWD,
|
REPO_CWD,
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
{ force: true, reason: "merge-pr-validation" },
|
{ force: true, reason: "merge-pr-validation", previousPullRequest: null },
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -942,7 +966,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
expect(getPullRequestStatus).toHaveBeenCalledWith(
|
expect(getPullRequestStatus).toHaveBeenCalledWith(
|
||||||
REPO_CWD,
|
REPO_CWD,
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
{ force: false, reason: "initial" },
|
{ force: false, reason: "initial", previousPullRequest: null },
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1274,6 +1298,14 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
const service = createService({
|
const service = createService({
|
||||||
getCheckoutSnapshotFacts,
|
getCheckoutSnapshotFacts,
|
||||||
getCheckoutStatus,
|
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();
|
const listener = vi.fn();
|
||||||
|
|
||||||
@@ -1288,6 +1320,11 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
cwd: REPO_CWD,
|
cwd: REPO_CWD,
|
||||||
headRef: "feature",
|
headRef: "feature",
|
||||||
headSha: "1111111111111111111111111111111111111111",
|
headSha: "1111111111111111111111111111111111111111",
|
||||||
|
previousPullRequest: {
|
||||||
|
number: 2,
|
||||||
|
url: "https://forge-self-heal.test/acme/repo/pulls/2",
|
||||||
|
state: "open",
|
||||||
|
},
|
||||||
reason: "self-heal-forge-pr-status",
|
reason: "self-heal-forge-pr-status",
|
||||||
});
|
});
|
||||||
expect(listener).toHaveBeenCalledWith(
|
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 () => {
|
test("generic forge self-heal uses the fast poll window while checks are pending", async () => {
|
||||||
const forge = {
|
const forge = {
|
||||||
...createGitHubServiceStub(),
|
...createGitHubServiceStub(),
|
||||||
@@ -1397,7 +1604,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
nowMs = 3_000;
|
nowMs = 3_000;
|
||||||
await service.refresh(REPO_CWD);
|
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();
|
expect(forge.getCurrentPullRequestStatus).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(0);
|
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 () => {
|
test("subscription cancels generic forge PR status self-heal polling after unsubscribe", async () => {
|
||||||
const forge = {
|
const forge = {
|
||||||
...createGitHubServiceStub(),
|
...createGitHubServiceStub(),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path, { join } from "node:path";
|
import path, { join } from "node:path";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
@@ -10,11 +12,14 @@ import type {
|
|||||||
PullRequestStatusResult,
|
PullRequestStatusResult,
|
||||||
} from "../utils/checkout-git.js";
|
} from "../utils/checkout-git.js";
|
||||||
import {
|
import {
|
||||||
|
BACKGROUND_GIT_FETCH_INTERVAL_MS,
|
||||||
|
WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS,
|
||||||
WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY,
|
WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY,
|
||||||
WORKSPACE_GIT_REFRESH_CONCURRENCY,
|
WORKSPACE_GIT_REFRESH_CONCURRENCY,
|
||||||
WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS,
|
WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS,
|
||||||
WorkspaceGitServiceImpl,
|
WorkspaceGitServiceImpl,
|
||||||
type WorkspaceGitRuntimeSnapshot,
|
type WorkspaceGitRuntimeSnapshot,
|
||||||
|
workspaceFolderExists,
|
||||||
} from "./workspace-git-service.js";
|
} from "./workspace-git-service.js";
|
||||||
|
|
||||||
const REPO_CWD = path.resolve("/tmp/repo");
|
const REPO_CWD = path.resolve("/tmp/repo");
|
||||||
@@ -23,6 +28,7 @@ function createLogger() {
|
|||||||
const logger = {
|
const logger = {
|
||||||
child: () => logger,
|
child: () => logger,
|
||||||
debug: vi.fn(),
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
warn: vi.fn(),
|
warn: vi.fn(),
|
||||||
};
|
};
|
||||||
return logger;
|
return logger;
|
||||||
@@ -251,7 +257,7 @@ function createGitHubServiceStub(): ForgeService {
|
|||||||
interface CreateServiceTestOptions {
|
interface CreateServiceTestOptions {
|
||||||
subscribe?: ReturnType<typeof vi.fn>;
|
subscribe?: ReturnType<typeof vi.fn>;
|
||||||
getCheckoutStatus?: ReturnType<typeof vi.fn>;
|
getCheckoutStatus?: ReturnType<typeof vi.fn>;
|
||||||
getCheckoutSnapshotFacts?: ReturnType<typeof vi.fn>;
|
getCheckoutSnapshotFacts?: (cwd: string) => Promise<CheckoutSnapshotFacts>;
|
||||||
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
|
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
|
||||||
getCheckoutWorktreeState?: ReturnType<typeof vi.fn>;
|
getCheckoutWorktreeState?: ReturnType<typeof vi.fn>;
|
||||||
getPullRequestStatus?: ReturnType<typeof vi.fn>;
|
getPullRequestStatus?: ReturnType<typeof vi.fn>;
|
||||||
@@ -261,6 +267,7 @@ interface CreateServiceTestOptions {
|
|||||||
runGitFetch?: ReturnType<typeof vi.fn>;
|
runGitFetch?: ReturnType<typeof vi.fn>;
|
||||||
runGitCommand?: ReturnType<typeof vi.fn>;
|
runGitCommand?: ReturnType<typeof vi.fn>;
|
||||||
getWorkspaceGitSelfHealPhaseMs?: (cwd: string) => number;
|
getWorkspaceGitSelfHealPhaseMs?: (cwd: string) => number;
|
||||||
|
workspaceFolderExists?: (cwd: string) => Promise<boolean>;
|
||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +301,7 @@ function buildDefaultTestServiceDeps() {
|
|||||||
signal: null,
|
signal: null,
|
||||||
})),
|
})),
|
||||||
getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000),
|
getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000),
|
||||||
|
workspaceFolderExists: vi.fn(async () => true),
|
||||||
createWatcherLivenessCanary: vi.fn(() => ({
|
createWatcherLivenessCanary: vi.fn(() => ({
|
||||||
path: "",
|
path: "",
|
||||||
filterEvents: (events) => events,
|
filterEvents: (events) => events,
|
||||||
@@ -324,6 +332,145 @@ function createService(options?: CreateServiceTestOptions) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WatchCallback = (error: Error | null, events: Array<{ path: string; type: "update" }>) => void;
|
||||||
|
|
||||||
|
interface FolderBackedGitOptions {
|
||||||
|
facts?: (cwd: string) => CheckoutSnapshotFacts;
|
||||||
|
unwatchableDirectories?: string[];
|
||||||
|
heldWatch?: { directory: string; release: Promise<void> };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFolderBackedGit(options?: FolderBackedGitOptions) {
|
||||||
|
const watchCallbacks = new Map<string, WatchCallback>();
|
||||||
|
const subscribe = vi.fn(async (directory: string, callback: WatchCallback) => {
|
||||||
|
if (!existsSync(directory) || options?.unwatchableDirectories?.includes(directory)) {
|
||||||
|
throw new Error(`Cannot watch ${directory}`);
|
||||||
|
}
|
||||||
|
if (options?.heldWatch?.directory === directory) {
|
||||||
|
await options.heldWatch.release;
|
||||||
|
}
|
||||||
|
watchCallbacks.set(directory, callback);
|
||||||
|
return createAsyncSubscription();
|
||||||
|
});
|
||||||
|
const getCheckoutSnapshotFacts = vi.fn(
|
||||||
|
async (cwd: string): Promise<CheckoutSnapshotFacts> =>
|
||||||
|
existsSync(cwd) ? (options?.facts ?? createCheckoutSnapshotFacts)(cwd) : { isGit: false },
|
||||||
|
);
|
||||||
|
const getCheckoutStatus = vi.fn(async (cwd: string) =>
|
||||||
|
existsSync(cwd) ? createCheckoutStatus(cwd) : ({ isGit: false } as const),
|
||||||
|
);
|
||||||
|
const getCheckoutShortstat = vi.fn(async (_cwd: string) => ({ additions: 1, deletions: 0 }));
|
||||||
|
const getCheckoutWorktreeState = vi.fn(async (_cwd: string) => ({
|
||||||
|
isDirty: false,
|
||||||
|
diffStat: { additions: 1, deletions: 0 },
|
||||||
|
}));
|
||||||
|
const runGitCommand = vi.fn(async (args: string[], runOptions: { cwd: string }) => ({
|
||||||
|
stdout: args[0] === "rev-parse" ? `${runOptions.cwd}\n` : "",
|
||||||
|
stderr: "",
|
||||||
|
truncated: false,
|
||||||
|
exitCode: 0,
|
||||||
|
signal: null,
|
||||||
|
}));
|
||||||
|
const runGitFetch = vi.fn(async (_cwd: string) => ({ changes: [], error: null }));
|
||||||
|
const callsIn = (fake: ReturnType<typeof vi.fn>, cwd: string) =>
|
||||||
|
fake.mock.calls.filter(([calledCwd]) => calledCwd === cwd).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
deps: {
|
||||||
|
subscribe,
|
||||||
|
getCheckoutSnapshotFacts,
|
||||||
|
getCheckoutStatus,
|
||||||
|
getCheckoutShortstat,
|
||||||
|
getCheckoutWorktreeState,
|
||||||
|
runGitCommand,
|
||||||
|
runGitFetch,
|
||||||
|
workspaceFolderExists: async (cwd: string) => existsSync(cwd),
|
||||||
|
now: () => new Date(),
|
||||||
|
},
|
||||||
|
watchCallbackFor(directory: string): WatchCallback {
|
||||||
|
const callback = watchCallbacks.get(directory);
|
||||||
|
if (!callback) {
|
||||||
|
throw new Error(`No watcher was started for ${directory}`);
|
||||||
|
}
|
||||||
|
return callback;
|
||||||
|
},
|
||||||
|
watchAttemptsFor: (directory: string) => callsIn(subscribe, directory),
|
||||||
|
pollsOf: (cwd: string) => callsIn(getCheckoutShortstat, cwd),
|
||||||
|
fetchesIn: (cwd: string) => callsIn(runGitFetch, cwd),
|
||||||
|
gitCommandsIn(cwd: string): number {
|
||||||
|
const cwdCommands = [
|
||||||
|
getCheckoutSnapshotFacts,
|
||||||
|
getCheckoutStatus,
|
||||||
|
getCheckoutShortstat,
|
||||||
|
getCheckoutWorktreeState,
|
||||||
|
runGitFetch,
|
||||||
|
].reduce((count, fake) => count + callsIn(fake, cwd), 0);
|
||||||
|
const commandsRunInCwd = runGitCommand.mock.calls.filter(
|
||||||
|
([, runOptions]) => runOptions.cwd === cwd,
|
||||||
|
).length;
|
||||||
|
return cwdCommands + commandsRunInCwd;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkedWorktreeFacts(gitCommonDir: string) {
|
||||||
|
return (cwd: string): CheckoutSnapshotFacts => ({
|
||||||
|
...createCheckoutSnapshotFacts(cwd),
|
||||||
|
absoluteGitDir: join(gitCommonDir, "worktrees", path.basename(cwd)),
|
||||||
|
gitCommonDir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const SETTLE_ATTEMPTS = 1_000;
|
||||||
|
|
||||||
|
async function settle(assertion: () => void): Promise<void> {
|
||||||
|
let lastFailure: unknown = null;
|
||||||
|
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
|
||||||
|
try {
|
||||||
|
assertion();
|
||||||
|
return;
|
||||||
|
} catch (failure) {
|
||||||
|
lastFailure = failure;
|
||||||
|
}
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
}
|
||||||
|
throw lastFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRefreshesToSettle(service: WorkspaceGitServiceImpl): Promise<void> {
|
||||||
|
await settle(() => {
|
||||||
|
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForObservationToSettle(
|
||||||
|
service: WorkspaceGitServiceImpl,
|
||||||
|
repositoryWorkspaceLinkCount: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await settle(() => {
|
||||||
|
const metrics = service.getMetrics();
|
||||||
|
expect(metrics.repositoryWorkspaceLinkCount).toBe(repositoryWorkspaceLinkCount);
|
||||||
|
expect(metrics.workspaceObservationSetupInFlightCount).toBe(0);
|
||||||
|
expect(metrics.workspaceRefreshInFlightCount).toBe(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function advanceThroughDegradedPolls(service: WorkspaceGitServiceImpl): Promise<void> {
|
||||||
|
for (const delayMs of [5_000, 10_000, 20_000, 40_000, 60_000, 60_000]) {
|
||||||
|
await vi.advanceTimersByTimeAsync(delayMs);
|
||||||
|
await waitForRefreshesToSettle(service);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function advanceThroughWorkingTreeRecoveryAttempts(
|
||||||
|
service: WorkspaceGitServiceImpl,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const delayMs of [30_000, 60_000, 120_000]) {
|
||||||
|
await vi.advanceTimersByTimeAsync(delayMs);
|
||||||
|
await waitForRefreshesToSettle(service);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("WorkspaceGitServiceImpl", () => {
|
describe("WorkspaceGitServiceImpl", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -1553,3 +1700,460 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("WorkspaceGitServiceImpl with workspace folders that no longer exist", () => {
|
||||||
|
const temporaryFolders: string[] = [];
|
||||||
|
|
||||||
|
async function createTemporaryFolder(): Promise<string> {
|
||||||
|
const folder = await mkdtemp(join(os.tmpdir(), "paseo-workspace-git-"));
|
||||||
|
temporaryFolders.push(folder);
|
||||||
|
return folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
await Promise.all(
|
||||||
|
temporaryFolders.splice(0).map((folder) => rm(folder, { recursive: true, force: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("polling stops for a workspace whose folder no longer exists", async () => {
|
||||||
|
const workspace = await createTemporaryFolder();
|
||||||
|
const git = createFolderBackedGit();
|
||||||
|
const service = createService(git.deps);
|
||||||
|
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||||
|
await waitForObservationToSettle(service, 1);
|
||||||
|
|
||||||
|
await rm(workspace, { recursive: true, force: true });
|
||||||
|
const gitCommandsAtDeletion = git.gitCommandsIn(workspace);
|
||||||
|
await advanceThroughDegradedPolls(service);
|
||||||
|
|
||||||
|
expect(git.gitCommandsIn(workspace)).toBe(gitCommandsAtDeletion);
|
||||||
|
expect(service.getMetrics()).toMatchObject({
|
||||||
|
repositoryTargetCount: 0,
|
||||||
|
workingTreeWatchTargetCount: 0,
|
||||||
|
});
|
||||||
|
expect(vi.getTimerCount()).toBe(1);
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a deleted worktree leaves its shared repository polling and fetch to the surviving worktree", async () => {
|
||||||
|
const gitCommonDir = join(await createTemporaryFolder(), ".git");
|
||||||
|
const deletedWorktree = await createTemporaryFolder();
|
||||||
|
const survivingWorktree = await createTemporaryFolder();
|
||||||
|
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
|
||||||
|
let survivingWorktreeEdits = 0;
|
||||||
|
git.deps.getCheckoutShortstat.mockImplementation(async (cwd: string) => ({
|
||||||
|
additions: cwd === survivingWorktree ? ++survivingWorktreeEdits : 1,
|
||||||
|
deletions: 0,
|
||||||
|
}));
|
||||||
|
const service = createService(git.deps);
|
||||||
|
const deletedSubscription = service.registerWorkspace({ cwd: deletedWorktree }, vi.fn());
|
||||||
|
await waitForObservationToSettle(service, 1);
|
||||||
|
const survivingSubscription = service.registerWorkspace({ cwd: survivingWorktree }, vi.fn());
|
||||||
|
await waitForObservationToSettle(service, 2);
|
||||||
|
|
||||||
|
await rm(deletedWorktree, { recursive: true, force: true });
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
await waitForRefreshesToSettle(service);
|
||||||
|
const deletedWorktreeCommands = git.gitCommandsIn(deletedWorktree);
|
||||||
|
|
||||||
|
for (let tick = 0; tick < 3; tick += 1) {
|
||||||
|
const survivingWorktreePolls = git.pollsOf(survivingWorktree);
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
await settle(() => {
|
||||||
|
expect(git.pollsOf(survivingWorktree)).toBe(survivingWorktreePolls + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
|
||||||
|
|
||||||
|
const fetchesBefore = git.deps.runGitFetch.mock.calls.length;
|
||||||
|
await vi.advanceTimersByTimeAsync(2 * BACKGROUND_GIT_FETCH_INTERVAL_MS);
|
||||||
|
expect(git.deps.runGitFetch.mock.calls.slice(fetchesBefore).map(([cwd]) => cwd)).toEqual([
|
||||||
|
survivingWorktree,
|
||||||
|
survivingWorktree,
|
||||||
|
]);
|
||||||
|
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
|
||||||
|
|
||||||
|
deletedSubscription.unsubscribe();
|
||||||
|
survivingSubscription.unsubscribe();
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deleting every worktree of a repository stops its polling and background fetch", async () => {
|
||||||
|
const gitCommonDir = join(await createTemporaryFolder(), ".git");
|
||||||
|
const firstWorktree = await createTemporaryFolder();
|
||||||
|
const secondWorktree = await createTemporaryFolder();
|
||||||
|
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
|
||||||
|
const service = createService(git.deps);
|
||||||
|
const firstSubscription = service.registerWorkspace({ cwd: firstWorktree }, vi.fn());
|
||||||
|
await waitForObservationToSettle(service, 1);
|
||||||
|
const secondSubscription = service.registerWorkspace({ cwd: secondWorktree }, vi.fn());
|
||||||
|
await waitForObservationToSettle(service, 2);
|
||||||
|
|
||||||
|
await rm(firstWorktree, { recursive: true, force: true });
|
||||||
|
await rm(secondWorktree, { recursive: true, force: true });
|
||||||
|
const commandsAtDeletion = [
|
||||||
|
git.gitCommandsIn(firstWorktree),
|
||||||
|
git.gitCommandsIn(secondWorktree),
|
||||||
|
];
|
||||||
|
await advanceThroughDegradedPolls(service);
|
||||||
|
|
||||||
|
expect([git.gitCommandsIn(firstWorktree), git.gitCommandsIn(secondWorktree)]).toEqual(
|
||||||
|
commandsAtDeletion,
|
||||||
|
);
|
||||||
|
expect(service.getMetrics()).toMatchObject({
|
||||||
|
repositoryTargetCount: 0,
|
||||||
|
workingTreeWatchTargetCount: 0,
|
||||||
|
});
|
||||||
|
expect(vi.getTimerCount()).toBe(2);
|
||||||
|
|
||||||
|
firstSubscription.unsubscribe();
|
||||||
|
secondSubscription.unsubscribe();
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("working tree fallback polling and watcher recovery stop for a workspace whose folder no longer exists", async () => {
|
||||||
|
const workspace = await createTemporaryFolder();
|
||||||
|
await mkdir(join(workspace, ".git"));
|
||||||
|
const git = createFolderBackedGit();
|
||||||
|
const service = createService(git.deps);
|
||||||
|
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||||
|
await waitForObservationToSettle(service, 1);
|
||||||
|
|
||||||
|
await rm(workspace, { recursive: true, force: true });
|
||||||
|
git.watchCallbackFor(workspace)(new Error("watched directory was deleted"), []);
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await waitForRefreshesToSettle(service);
|
||||||
|
const commandsAfterWatcherError = git.gitCommandsIn(workspace);
|
||||||
|
const watchAttemptsAfterWatcherError = git.watchAttemptsFor(workspace);
|
||||||
|
await advanceThroughDegradedPolls(service);
|
||||||
|
|
||||||
|
expect(git.gitCommandsIn(workspace)).toBe(commandsAfterWatcherError);
|
||||||
|
expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError);
|
||||||
|
expect(vi.getTimerCount()).toBe(1);
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a workspace whose folder comes back resumes polling on its next subscription", async () => {
|
||||||
|
const workspace = await createTemporaryFolder();
|
||||||
|
const git = createFolderBackedGit();
|
||||||
|
const service = createService(git.deps);
|
||||||
|
const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||||
|
await waitForObservationToSettle(service, 1);
|
||||||
|
await rm(workspace, { recursive: true, force: true });
|
||||||
|
const commandsWhileMissing = git.gitCommandsIn(workspace);
|
||||||
|
await advanceThroughDegradedPolls(service);
|
||||||
|
expect(git.gitCommandsIn(workspace)).toBe(commandsWhileMissing);
|
||||||
|
|
||||||
|
await mkdir(workspace);
|
||||||
|
const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||||
|
await waitForObservationToSettle(service, 1);
|
||||||
|
const pollsAfterResume = git.pollsOf(workspace);
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
await waitForRefreshesToSettle(service);
|
||||||
|
|
||||||
|
expect(git.pollsOf(workspace)).toBe(pollsAfterResume + 1);
|
||||||
|
|
||||||
|
firstSubscription.unsubscribe();
|
||||||
|
secondSubscription.unsubscribe();
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a suspended workspace costs no git while its folder is missing, even when a refresh is requested", async () => {
|
||||||
|
const workspace = await createTemporaryFolder();
|
||||||
|
const git = createFolderBackedGit();
|
||||||
|
const service = createService(git.deps);
|
||||||
|
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||||
|
await waitForObservationToSettle(service, 1);
|
||||||
|
await rm(workspace, { recursive: true, force: true });
|
||||||
|
await advanceThroughDegradedPolls(service);
|
||||||
|
const commandsWhileMissing = git.gitCommandsIn(workspace);
|
||||||
|
|
||||||
|
for (let probe = 0; probe < 10; probe += 1) {
|
||||||
|
await vi.advanceTimersByTimeAsync(WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS);
|
||||||
|
await waitForRefreshesToSettle(service);
|
||||||
|
}
|
||||||
|
service.scheduleRefreshForCwd(workspace);
|
||||||
|
service.onWorkspaceStateMayHaveChanged(workspace);
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
await waitForRefreshesToSettle(service);
|
||||||
|
|
||||||
|
expect(git.gitCommandsIn(workspace)).toBe(commandsWhileMissing);
|
||||||
|
expect(vi.getTimerCount()).toBe(1);
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a suspended worktree resumes polling and its repository fetch once its folder is back, without a new subscription", async () => {
|
||||||
|
const gitCommonDir = join(await createTemporaryFolder(), ".git");
|
||||||
|
const worktree = await createTemporaryFolder();
|
||||||
|
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
|
||||||
|
const service = createService(git.deps);
|
||||||
|
const subscription = service.registerWorkspace({ cwd: worktree }, vi.fn());
|
||||||
|
await waitForObservationToSettle(service, 1);
|
||||||
|
await rm(worktree, { recursive: true, force: true });
|
||||||
|
await advanceThroughDegradedPolls(service);
|
||||||
|
await waitForObservationToSettle(service, 0);
|
||||||
|
|
||||||
|
await mkdir(worktree);
|
||||||
|
const fetchesBeforeRestore = git.fetchesIn(worktree);
|
||||||
|
await vi.advanceTimersByTimeAsync(WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS);
|
||||||
|
await waitForObservationToSettle(service, 1);
|
||||||
|
expect(git.fetchesIn(worktree)).toBe(fetchesBeforeRestore + 1);
|
||||||
|
|
||||||
|
const pollsAfterRestore = git.pollsOf(worktree);
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
await waitForRefreshesToSettle(service);
|
||||||
|
expect(git.pollsOf(worktree)).toBe(pollsAfterRestore + 1);
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a forced snapshot resumes a suspended workspace whose folder is back", async () => {
|
||||||
|
const workspace = await createTemporaryFolder();
|
||||||
|
const git = createFolderBackedGit();
|
||||||
|
const service = createService(git.deps);
|
||||||
|
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||||
|
await waitForObservationToSettle(service, 1);
|
||||||
|
await rm(workspace, { recursive: true, force: true });
|
||||||
|
await advanceThroughDegradedPolls(service);
|
||||||
|
await waitForObservationToSettle(service, 0);
|
||||||
|
|
||||||
|
await mkdir(workspace);
|
||||||
|
const snapshot = await service.getSnapshot(workspace, {
|
||||||
|
force: true,
|
||||||
|
reason: "refresh-button",
|
||||||
|
});
|
||||||
|
await waitForObservationToSettle(service, 1);
|
||||||
|
|
||||||
|
expect(snapshot.git.isGit).toBe(true);
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a resumed workspace rebuilds observation from fresh facts, not the non-git facts cached while its folder was gone", async () => {
|
||||||
|
const workspace = await createTemporaryFolder();
|
||||||
|
const git = createFolderBackedGit();
|
||||||
|
const service = createService(git.deps);
|
||||||
|
const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||||
|
await waitForObservationToSettle(service, 1);
|
||||||
|
await rm(workspace, { recursive: true, force: true });
|
||||||
|
service.onWorkspaceStateMayHaveChanged(workspace);
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await waitForRefreshesToSettle(service);
|
||||||
|
expect((await service.getSnapshot(workspace)).git.isGit).toBe(false);
|
||||||
|
await advanceThroughDegradedPolls(service);
|
||||||
|
await waitForObservationToSettle(service, 0);
|
||||||
|
|
||||||
|
await mkdir(workspace);
|
||||||
|
const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||||
|
await waitForObservationToSettle(service, 1);
|
||||||
|
|
||||||
|
expect((await service.getSnapshot(workspace)).git.isGit).toBe(true);
|
||||||
|
|
||||||
|
firstSubscription.unsubscribe();
|
||||||
|
secondSubscription.unsubscribe();
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resuming waits for an observation setup that was still running when the folder vanished", async () => {
|
||||||
|
const workspace = await createTemporaryFolder();
|
||||||
|
const gitDir = join(workspace, ".git");
|
||||||
|
await mkdir(gitDir);
|
||||||
|
const metadataWatchRelease = createDeferred<void>();
|
||||||
|
const git = createFolderBackedGit({
|
||||||
|
unwatchableDirectories: [workspace],
|
||||||
|
heldWatch: { directory: gitDir, release: metadataWatchRelease.promise },
|
||||||
|
});
|
||||||
|
const service = createService(git.deps);
|
||||||
|
const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||||
|
await settle(() => {
|
||||||
|
expect(git.watchAttemptsFor(gitDir)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await rm(workspace, { recursive: true, force: true });
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
await settle(() => {
|
||||||
|
expect(service.getMetrics()).toMatchObject({
|
||||||
|
repositoryTargetCount: 0,
|
||||||
|
workingTreeWatchTargetCount: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await mkdir(workspace);
|
||||||
|
const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||||
|
metadataWatchRelease.resolve();
|
||||||
|
await waitForObservationToSettle(service, 1);
|
||||||
|
|
||||||
|
expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1);
|
||||||
|
|
||||||
|
firstSubscription.unsubscribe();
|
||||||
|
secondSubscription.unsubscribe();
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an open working tree watch neither notifies nor recovers while its folder is missing, and picks up once it is back", async () => {
|
||||||
|
const workspace = await createTemporaryFolder();
|
||||||
|
await mkdir(join(workspace, ".git"));
|
||||||
|
const git = createFolderBackedGit();
|
||||||
|
const service = createService(git.deps);
|
||||||
|
const workspaceSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||||
|
await waitForObservationToSettle(service, 1);
|
||||||
|
const diffListener = vi.fn();
|
||||||
|
const diffWatch = await service.requestWorkingTreeWatch(workspace, diffListener);
|
||||||
|
|
||||||
|
await rm(workspace, { recursive: true, force: true });
|
||||||
|
git.watchCallbackFor(workspace)(new Error("watched directory was deleted"), []);
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await waitForRefreshesToSettle(service);
|
||||||
|
const commandsAfterWatcherError = git.gitCommandsIn(workspace);
|
||||||
|
const notificationsAfterWatcherError = diffListener.mock.calls.length;
|
||||||
|
const watchAttemptsAfterWatcherError = git.watchAttemptsFor(workspace);
|
||||||
|
await advanceThroughWorkingTreeRecoveryAttempts(service);
|
||||||
|
|
||||||
|
expect(git.gitCommandsIn(workspace)).toBe(commandsAfterWatcherError);
|
||||||
|
expect(diffListener.mock.calls.length).toBe(notificationsAfterWatcherError);
|
||||||
|
expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError);
|
||||||
|
expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1);
|
||||||
|
|
||||||
|
await mkdir(join(workspace, ".git"), { recursive: true });
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
await waitForRefreshesToSettle(service);
|
||||||
|
expect(diffListener.mock.calls.length).toBe(notificationsAfterWatcherError + 1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(240_000);
|
||||||
|
await waitForRefreshesToSettle(service);
|
||||||
|
expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError + 1);
|
||||||
|
const notificationsAfterResubscribe = diffListener.mock.calls.length;
|
||||||
|
await vi.advanceTimersByTimeAsync(120_000);
|
||||||
|
await waitForRefreshesToSettle(service);
|
||||||
|
expect(diffListener.mock.calls.length).toBe(notificationsAfterResubscribe);
|
||||||
|
|
||||||
|
diffWatch.unsubscribe();
|
||||||
|
workspaceSubscription.unsubscribe();
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a refresh crossing suspension preserves the cached snapshot and drops queued work", async () => {
|
||||||
|
const workspace = await createTemporaryFolder();
|
||||||
|
const git = createFolderBackedGit();
|
||||||
|
const started = createDeferred<void>();
|
||||||
|
const release = createDeferred<void>();
|
||||||
|
let holdNextRead = false;
|
||||||
|
let factsReads = 0;
|
||||||
|
const service = createService({
|
||||||
|
...git.deps,
|
||||||
|
getCheckoutSnapshotFacts: async (cwd: string) => {
|
||||||
|
factsReads++;
|
||||||
|
if (holdNextRead) {
|
||||||
|
holdNextRead = false;
|
||||||
|
started.resolve();
|
||||||
|
await release.promise;
|
||||||
|
}
|
||||||
|
return git.deps.getCheckoutSnapshotFacts(cwd);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const updates: WorkspaceGitRuntimeSnapshot[] = [];
|
||||||
|
const subscription = service.registerWorkspace({ cwd: workspace }, (snapshot) =>
|
||||||
|
updates.push(snapshot),
|
||||||
|
);
|
||||||
|
await waitForObservationToSettle(service, 1);
|
||||||
|
const cached = await service.getSnapshot(workspace);
|
||||||
|
holdNextRead = true;
|
||||||
|
const active = service.getSnapshot(workspace, {
|
||||||
|
force: true,
|
||||||
|
includeForge: false,
|
||||||
|
reason: "overlapping-refresh",
|
||||||
|
});
|
||||||
|
await started.promise;
|
||||||
|
const queued = service.getSnapshot(workspace, {
|
||||||
|
force: true,
|
||||||
|
includeForge: true,
|
||||||
|
reason: "queued-refresh",
|
||||||
|
});
|
||||||
|
await rm(workspace, { recursive: true, force: true });
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
await settle(() => expect(service.getMetrics().repositoryWorkspaceLinkCount).toBe(0));
|
||||||
|
const readsAtSuspension = factsReads;
|
||||||
|
const updatesAtSuspension = updates.length;
|
||||||
|
release.resolve();
|
||||||
|
expect(await active).toEqual(cached);
|
||||||
|
expect(await queued).toEqual(cached);
|
||||||
|
expect(await service.getSnapshot(workspace)).toEqual(cached);
|
||||||
|
expect(updates).toHaveLength(updatesAtSuspension);
|
||||||
|
expect(factsReads).toBe(readsAtSuspension);
|
||||||
|
subscription.unsubscribe();
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restoration settles a suspended refresh before reloading facts", async () => {
|
||||||
|
const workspace = await createTemporaryFolder();
|
||||||
|
const git = createFolderBackedGit();
|
||||||
|
const staleFacts = createDeferred<CheckoutSnapshotFacts>();
|
||||||
|
const started = createDeferred<void>();
|
||||||
|
let holdNextRead = false;
|
||||||
|
const service = createService({
|
||||||
|
...git.deps,
|
||||||
|
getCheckoutSnapshotFacts: async (cwd: string) => {
|
||||||
|
if (holdNextRead) {
|
||||||
|
holdNextRead = false;
|
||||||
|
started.resolve();
|
||||||
|
return staleFacts.promise;
|
||||||
|
}
|
||||||
|
return git.deps.getCheckoutSnapshotFacts(cwd);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const updates: WorkspaceGitRuntimeSnapshot[] = [];
|
||||||
|
const subscription = service.registerWorkspace({ cwd: workspace }, (snapshot) =>
|
||||||
|
updates.push(snapshot),
|
||||||
|
);
|
||||||
|
await waitForObservationToSettle(service, 1);
|
||||||
|
const cached = await service.getSnapshot(workspace);
|
||||||
|
holdNextRead = true;
|
||||||
|
const active = service.getSnapshot(workspace, {
|
||||||
|
force: true,
|
||||||
|
includeForge: false,
|
||||||
|
reason: "overlapping-refresh",
|
||||||
|
});
|
||||||
|
await started.promise;
|
||||||
|
await rm(workspace, { recursive: true, force: true });
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
await settle(() => expect(service.getMetrics().repositoryWorkspaceLinkCount).toBe(0));
|
||||||
|
const updatesAtSuspension = updates.length;
|
||||||
|
await mkdir(workspace);
|
||||||
|
const restored = service.getSnapshot(workspace, {
|
||||||
|
force: true,
|
||||||
|
includeForge: false,
|
||||||
|
reason: "folder-returned",
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
staleFacts.resolve({ isGit: false });
|
||||||
|
expect(await active).toEqual(cached);
|
||||||
|
expect((await restored).git.isGit).toBe(true);
|
||||||
|
expect(updates.slice(updatesAtSuspension).every((snapshot) => snapshot.git.isGit)).toBe(true);
|
||||||
|
await waitForObservationToSettle(service, 1);
|
||||||
|
subscription.unsubscribe();
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workspaceFolderExists reports a missing folder and a path under a file as missing", async () => {
|
||||||
|
const folder = await createTemporaryFolder();
|
||||||
|
const file = join(folder, "file.txt");
|
||||||
|
await writeFile(file, "");
|
||||||
|
|
||||||
|
expect(await workspaceFolderExists(folder)).toBe(true);
|
||||||
|
expect(await workspaceFolderExists(join(folder, "missing"))).toBe(false);
|
||||||
|
expect(await workspaceFolderExists(join(file, "nested"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile, stat } from "node:fs/promises";
|
||||||
import { basename, dirname, join, resolve } from "node:path";
|
import { basename, dirname, join, resolve } from "node:path";
|
||||||
import { LRUCache } from "lru-cache";
|
import { LRUCache } from "lru-cache";
|
||||||
import { CheckoutDiffCache } from "./checkout-diff-cache.js";
|
import { CheckoutDiffCache } from "./checkout-diff-cache.js";
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
getPullRequestStatus,
|
getPullRequestStatus,
|
||||||
forgeAuthStateFromError,
|
forgeAuthStateFromError,
|
||||||
hasOriginRemote,
|
hasOriginRemote,
|
||||||
|
isPullRequestTargetOnForge,
|
||||||
listBranchSuggestions,
|
listBranchSuggestions,
|
||||||
resolveRepositoryDefaultBranch,
|
resolveRepositoryDefaultBranch,
|
||||||
resolveBranchCheckout,
|
resolveBranchCheckout,
|
||||||
@@ -74,7 +75,7 @@ import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
|
|||||||
import { createWatcherLivenessCanary } from "./watcher-liveness-canary.js";
|
import { createWatcherLivenessCanary } from "./watcher-liveness-canary.js";
|
||||||
|
|
||||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
|
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
|
||||||
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
export const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
||||||
const FETCH_METADATA_ECHO_TTL_MS = 5_000;
|
const FETCH_METADATA_ECHO_TTL_MS = 5_000;
|
||||||
export const WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS = 60_000;
|
export const WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS = 60_000;
|
||||||
const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000;
|
const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000;
|
||||||
@@ -151,6 +152,8 @@ export const getWorkspaceGitSelfHealPhaseMs = getWorkspaceGitObservationReensure
|
|||||||
export interface WorkspaceGitRuntimeSnapshot {
|
export interface WorkspaceGitRuntimeSnapshot {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
git: {
|
git: {
|
||||||
|
headSha?: string;
|
||||||
|
upstreamKnown?: boolean;
|
||||||
isGit: boolean;
|
isGit: boolean;
|
||||||
repoRoot: string | null;
|
repoRoot: string | null;
|
||||||
mainRepoRoot: string | null;
|
mainRepoRoot: string | null;
|
||||||
@@ -176,6 +179,7 @@ export interface WorkspaceGitRuntimeSnapshot {
|
|||||||
*/
|
*/
|
||||||
forge?: string;
|
forge?: string;
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
|
headSha?: string;
|
||||||
number?: number;
|
number?: number;
|
||||||
repoOwner?: string;
|
repoOwner?: string;
|
||||||
repoName?: string;
|
repoName?: string;
|
||||||
@@ -321,6 +325,7 @@ export type WorkspaceGitSnapshotOptions =
|
|||||||
force: true;
|
force: true;
|
||||||
includeForge?: boolean;
|
includeForge?: boolean;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
afterInFlight?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface WorkspaceGitRefreshRequest {
|
interface WorkspaceGitRefreshRequest {
|
||||||
@@ -384,6 +389,7 @@ interface WorkspaceGitServiceDependencies {
|
|||||||
runGitCommand: typeof runGitCommand;
|
runGitCommand: typeof runGitCommand;
|
||||||
getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitObservationReensurePhaseMs;
|
getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitObservationReensurePhaseMs;
|
||||||
createWatcherLivenessCanary: typeof createWatcherLivenessCanary;
|
createWatcherLivenessCanary: typeof createWatcherLivenessCanary;
|
||||||
|
workspaceFolderExists: (cwd: string) => Promise<boolean>;
|
||||||
now: () => Date;
|
now: () => Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,6 +441,8 @@ interface WorkspaceGitTarget {
|
|||||||
repoGitRoot: string | null;
|
repoGitRoot: string | null;
|
||||||
observationSetupPromise: Promise<void> | null;
|
observationSetupPromise: Promise<void> | null;
|
||||||
observationSetupComplete: boolean;
|
observationSetupComplete: boolean;
|
||||||
|
folderMissing: boolean;
|
||||||
|
folderRestorePromise: Promise<void> | null;
|
||||||
closed: boolean;
|
closed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,6 +555,10 @@ interface WorkspaceForgePrStatusPollTarget {
|
|||||||
headRef: string;
|
headRef: string;
|
||||||
headSha?: string;
|
headSha?: string;
|
||||||
headRepositoryOwner?: string;
|
headRepositoryOwner?: string;
|
||||||
|
headRepositoryPath?: string;
|
||||||
|
headRepositoryHost?: string;
|
||||||
|
headRepositoryEndpoint?: import("../services/forge-service.js").HeadRepositoryEndpoint;
|
||||||
|
changeRequestNumber?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDefaultWorkspaceGitServiceDeps(
|
function buildDefaultWorkspaceGitServiceDeps(
|
||||||
@@ -571,10 +583,21 @@ function buildDefaultWorkspaceGitServiceDeps(
|
|||||||
runGitCommand,
|
runGitCommand,
|
||||||
getWorkspaceGitSelfHealPhaseMs: getWorkspaceGitObservationReensurePhaseMs,
|
getWorkspaceGitSelfHealPhaseMs: getWorkspaceGitObservationReensurePhaseMs,
|
||||||
createWatcherLivenessCanary,
|
createWatcherLivenessCanary,
|
||||||
|
workspaceFolderExists,
|
||||||
now: () => new Date(),
|
now: () => new Date(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function workspaceFolderExists(cwd: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await stat(cwd);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
return code !== "ENOENT" && code !== "ENOTDIR";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveWorkspaceGitServiceDeps(
|
function resolveWorkspaceGitServiceDeps(
|
||||||
subscribe: SubscribeToFileChanges,
|
subscribe: SubscribeToFileChanges,
|
||||||
deps: Partial<WorkspaceGitServiceDependencies> | undefined,
|
deps: Partial<WorkspaceGitServiceDependencies> | undefined,
|
||||||
@@ -660,6 +683,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
const cwd = resolve(params.cwd);
|
const cwd = resolve(params.cwd);
|
||||||
const target = this.ensureWorkspaceTarget(cwd);
|
const target = this.ensureWorkspaceTarget(cwd);
|
||||||
target.listeners.add(listener);
|
target.listeners.add(listener);
|
||||||
|
if (target.folderMissing) {
|
||||||
|
void this.resumeWorkspaceIfFolderRestored(target);
|
||||||
|
}
|
||||||
if (target.listeners.size === 1) {
|
if (target.listeners.size === 1) {
|
||||||
this.startWorkspaceSubscriptionTimers(target);
|
this.startWorkspaceSubscriptionTimers(target);
|
||||||
}
|
}
|
||||||
@@ -751,6 +777,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
if (!request.force && target.latestSnapshot) {
|
if (!request.force && target.latestSnapshot) {
|
||||||
return target.latestSnapshot;
|
return target.latestSnapshot;
|
||||||
}
|
}
|
||||||
|
if (target.folderMissing) {
|
||||||
|
await this.resumeWorkspaceIfFolderRestored(target);
|
||||||
|
}
|
||||||
|
|
||||||
return this.requestWorkspaceSnapshot(target, request);
|
return this.requestWorkspaceSnapshot(target, request);
|
||||||
}
|
}
|
||||||
@@ -972,6 +1001,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
this.assertNotDisposed();
|
this.assertNotDisposed();
|
||||||
cwd = resolve(cwd);
|
cwd = resolve(cwd);
|
||||||
const target = this.ensureWorkspaceTarget(cwd);
|
const target = this.ensureWorkspaceTarget(cwd);
|
||||||
|
if (target.folderMissing) {
|
||||||
|
await this.resumeWorkspaceIfFolderRestored(target);
|
||||||
|
}
|
||||||
await this.refreshWorkspaceTarget(target, {
|
await this.refreshWorkspaceTarget(target, {
|
||||||
force: false,
|
force: false,
|
||||||
refreshStructure: true,
|
refreshStructure: true,
|
||||||
@@ -1005,9 +1037,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
this.assertNotDisposed();
|
this.assertNotDisposed();
|
||||||
cwd = resolve(cwd);
|
cwd = resolve(cwd);
|
||||||
const target = this.workspaceTargets.get(cwd);
|
const target = this.workspaceTargets.get(cwd);
|
||||||
if (target) {
|
if (!target) {
|
||||||
this.scheduleWorkspaceRefresh(target, { queueIfBusy: false });
|
return;
|
||||||
}
|
}
|
||||||
|
if (target.folderMissing) {
|
||||||
|
void this.resumeWorkspaceIfFolderRestored(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.scheduleWorkspaceRefresh(target, { queueIfBusy: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
onWorkspaceStateMayHaveChanged(cwd: string): void {
|
onWorkspaceStateMayHaveChanged(cwd: string): void {
|
||||||
@@ -1018,6 +1055,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.invalidateForge(normalizedCwd);
|
this.invalidateForge(normalizedCwd);
|
||||||
|
if (target.folderMissing) {
|
||||||
|
void this.resumeWorkspaceIfFolderRestored(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.scheduleWorkspaceRefresh(target, {
|
this.scheduleWorkspaceRefresh(target, {
|
||||||
force: true,
|
force: true,
|
||||||
includeForge: true,
|
includeForge: true,
|
||||||
@@ -1227,6 +1268,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
repoGitRoot: null,
|
repoGitRoot: null,
|
||||||
observationSetupPromise: null,
|
observationSetupPromise: null,
|
||||||
observationSetupComplete: false,
|
observationSetupComplete: false,
|
||||||
|
folderMissing: false,
|
||||||
|
folderRestorePromise: null,
|
||||||
closed: false,
|
closed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1358,6 +1401,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
private isActiveObservedWorkspaceTarget(target: WorkspaceGitTarget): boolean {
|
private isActiveObservedWorkspaceTarget(target: WorkspaceGitTarget): boolean {
|
||||||
return (
|
return (
|
||||||
!target.closed &&
|
!target.closed &&
|
||||||
|
!target.folderMissing &&
|
||||||
target.listeners.size > 0 &&
|
target.listeners.size > 0 &&
|
||||||
this.workspaceTargets.get(target.cwd) === target
|
this.workspaceTargets.get(target.cwd) === target
|
||||||
);
|
);
|
||||||
@@ -1612,11 +1656,110 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
workspaceTarget: WorkspaceGitTarget,
|
workspaceTarget: WorkspaceGitTarget,
|
||||||
request: WorkspaceGitRefreshRequest,
|
request: WorkspaceGitRefreshRequest,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
if (await this.suspendWorkspaceIfFolderMissing(workspaceTarget)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const fingerprintBefore = workspaceTarget.latestFingerprint;
|
const fingerprintBefore = workspaceTarget.latestFingerprint;
|
||||||
await this.refreshWorkspaceTarget(workspaceTarget, request);
|
await this.refreshWorkspaceTarget(workspaceTarget, request);
|
||||||
return workspaceTarget.latestFingerprint !== fingerprintBefore;
|
return workspaceTarget.latestFingerprint !== fingerprintBefore;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async suspendWorkspaceIfFolderMissing(target: WorkspaceGitTarget): Promise<boolean> {
|
||||||
|
if (target.folderMissing) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (await this.deps.workspaceFolderExists(target.cwd)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.suspendWorkspaceWithMissingFolder(target);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspendWorkspaceWithMissingFolder(target: WorkspaceGitTarget): void {
|
||||||
|
if (target.folderMissing || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.folderMissing = true;
|
||||||
|
if (target.refreshState.status === "in-flight") {
|
||||||
|
target.refreshState.queued = null;
|
||||||
|
}
|
||||||
|
this.detachWorkspaceFromRepoTarget(target);
|
||||||
|
target.repoGitRoot = null;
|
||||||
|
this.stopWorkspaceObservation(target);
|
||||||
|
target.observationSetupComplete = false;
|
||||||
|
this.scheduleMissingFolderProbe(target);
|
||||||
|
this.logger.warn(
|
||||||
|
{ cwd: target.cwd },
|
||||||
|
"Workspace folder no longer exists; suspended git observation until it is back",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSuspendedWorkspaceTarget(target: WorkspaceGitTarget): boolean {
|
||||||
|
return (
|
||||||
|
target.folderMissing && !target.closed && this.workspaceTargets.get(target.cwd) === target
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleMissingFolderProbe(target: WorkspaceGitTarget): void {
|
||||||
|
if (
|
||||||
|
!this.isSuspendedWorkspaceTarget(target) ||
|
||||||
|
target.observationReensureTimer ||
|
||||||
|
target.folderRestorePromise
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.observationReensureTimer = setTimeout(() => {
|
||||||
|
target.observationReensureTimer = null;
|
||||||
|
void this.resumeWorkspaceIfFolderRestored(target);
|
||||||
|
}, WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resumeWorkspaceIfFolderRestored(target: WorkspaceGitTarget): Promise<void> {
|
||||||
|
if (!target.folderMissing) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
target.folderRestorePromise ??= this.resumeWorkspaceWithRestoredFolder(target).finally(() => {
|
||||||
|
target.folderRestorePromise = null;
|
||||||
|
this.scheduleMissingFolderProbe(target);
|
||||||
|
});
|
||||||
|
return target.folderRestorePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resumeWorkspaceWithRestoredFolder(target: WorkspaceGitTarget): Promise<void> {
|
||||||
|
if (!(await this.deps.workspaceFolderExists(target.cwd))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await target.observationSetupPromise;
|
||||||
|
if (target.refreshState.status === "in-flight") {
|
||||||
|
await target.refreshState.promise.catch(() => undefined);
|
||||||
|
}
|
||||||
|
if (!this.isSuspendedWorkspaceTarget(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.latestFacts = null;
|
||||||
|
target.folderMissing = false;
|
||||||
|
await this.refreshWorkspaceTarget(target, {
|
||||||
|
force: false,
|
||||||
|
refreshStructure: true,
|
||||||
|
refreshWorktree: true,
|
||||||
|
includeForge: false,
|
||||||
|
reason: "workspace-folder-restored",
|
||||||
|
queueIfBusy: true,
|
||||||
|
movedRemoteRefs: new Set(),
|
||||||
|
});
|
||||||
|
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.observationSetupComplete = false;
|
||||||
|
if (target.observationReensureTimer) {
|
||||||
|
clearTimeout(target.observationReensureTimer);
|
||||||
|
target.observationReensureTimer = null;
|
||||||
|
}
|
||||||
|
this.logger.info({ cwd: target.cwd }, "Workspace folder is back; resumed git observation");
|
||||||
|
this.startWorkspaceSubscriptionTimers(target);
|
||||||
|
this.scheduleWorkspaceObservationSetup(target);
|
||||||
|
}
|
||||||
|
|
||||||
private startWorkingTreeWatchFallback(
|
private startWorkingTreeWatchFallback(
|
||||||
target: WorkingTreeWatchTarget,
|
target: WorkingTreeWatchTarget,
|
||||||
reason: WorkingTreeWatchFallbackReason,
|
reason: WorkingTreeWatchFallbackReason,
|
||||||
@@ -1654,7 +1797,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
return changed;
|
return changed;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
this.notifyWorkingTreeConsumers(target);
|
if (await this.deps.workspaceFolderExists(target.watchPath)) {
|
||||||
|
this.notifyWorkingTreeConsumers(target);
|
||||||
|
}
|
||||||
if (!target.closed && (target.subscription === null || target.repoRoot === null)) {
|
if (!target.closed && (target.subscription === null || target.repoRoot === null)) {
|
||||||
target.fallbackPollQuietTickCount = changes.some(Boolean)
|
target.fallbackPollQuietTickCount = changes.some(Boolean)
|
||||||
? 0
|
? 0
|
||||||
@@ -1731,6 +1876,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
if (target.closed || target.subscription) {
|
if (target.closed || target.subscription) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!(await this.deps.workspaceFolderExists(target.watchPath))) {
|
||||||
|
this.scheduleWorkingTreeWatchRecovery(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await this.refreshWorkingTreeIgnoredDirectories(target);
|
await this.refreshWorkingTreeIgnoredDirectories(target);
|
||||||
if (target.closed || target.subscription) {
|
if (target.closed || target.subscription) {
|
||||||
return;
|
return;
|
||||||
@@ -2786,7 +2935,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateForgePrStatusPollForTarget(target: WorkspaceGitTarget): void {
|
private updateForgePrStatusPollForTarget(target: WorkspaceGitTarget): void {
|
||||||
if (target.listeners.size === 0) {
|
if (!this.isActiveObservedWorkspaceTarget(target)) {
|
||||||
this.stopForgePrStatusPollForTarget(target);
|
this.stopForgePrStatusPollForTarget(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2830,6 +2979,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
...(pollTarget.headRepositoryOwner
|
...(pollTarget.headRepositoryOwner
|
||||||
? { headRepositoryOwner: pollTarget.headRepositoryOwner }
|
? { headRepositoryOwner: pollTarget.headRepositoryOwner }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(pollTarget.headRepositoryPath
|
||||||
|
? { headRepositoryPath: pollTarget.headRepositoryPath }
|
||||||
|
: {}),
|
||||||
|
...(pollTarget.headRepositoryEndpoint
|
||||||
|
? { headRepositoryEndpoint: pollTarget.headRepositoryEndpoint }
|
||||||
|
: {}),
|
||||||
|
...(pollTarget.changeRequestNumber
|
||||||
|
? { changeRequestNumber: pollTarget.changeRequestNumber }
|
||||||
|
: {}),
|
||||||
onStatus: (status) => {
|
onStatus: (status) => {
|
||||||
if (!this.isActiveObservedWorkspaceTarget(target)) {
|
if (!this.isActiveObservedWorkspaceTarget(target)) {
|
||||||
return;
|
return;
|
||||||
@@ -2880,8 +3038,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
}): { unsubscribe: () => void } {
|
}): { unsubscribe: () => void } {
|
||||||
let closed = false;
|
let closed = false;
|
||||||
let timer: NodeJS.Timeout | null = null;
|
let timer: NodeJS.Timeout | null = null;
|
||||||
let latestStatus: WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"] =
|
|
||||||
target.latestForge?.pullRequest ?? null;
|
|
||||||
let consecutiveErrors = 0;
|
let consecutiveErrors = 0;
|
||||||
|
|
||||||
const schedule = (delayMs: number) => {
|
const schedule = (delayMs: number) => {
|
||||||
@@ -2898,6 +3054,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
if (closed || !this.isActiveObservedWorkspaceTarget(target)) {
|
if (closed || !this.isActiveObservedWorkspaceTarget(target)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const previousForge = target.latestForge;
|
||||||
|
const previousStatus = previousForge?.pullRequest;
|
||||||
try {
|
try {
|
||||||
const status = await service.getCurrentPullRequestStatus({
|
const status = await service.getCurrentPullRequestStatus({
|
||||||
cwd: target.cwd,
|
cwd: target.cwd,
|
||||||
@@ -2906,10 +3064,31 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
...(pollTarget.headRepositoryOwner
|
...(pollTarget.headRepositoryOwner
|
||||||
? { headRepositoryOwner: 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",
|
reason: "self-heal-forge-pr-status",
|
||||||
});
|
});
|
||||||
if (!closed && this.isActiveObservedWorkspaceTarget(target)) {
|
if (
|
||||||
latestStatus = status;
|
!closed &&
|
||||||
|
this.isActiveObservedWorkspaceTarget(target) &&
|
||||||
|
target.latestForge === previousForge
|
||||||
|
) {
|
||||||
consecutiveErrors = 0;
|
consecutiveErrors = 0;
|
||||||
this.rememberForgePrStatusSnapshot(target, buildForgeSnapshotFromStatus(status, forge));
|
this.rememberForgePrStatusSnapshot(target, buildForgeSnapshotFromStatus(status, forge));
|
||||||
}
|
}
|
||||||
@@ -2927,7 +3106,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
"Failed to run forge PR status self-heal refresh",
|
"Failed to run forge PR status self-heal refresh",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
schedule(computeGenericForgeNextInterval(latestStatus, consecutiveErrors));
|
schedule(
|
||||||
|
computeGenericForgeNextInterval(
|
||||||
|
target.latestForge?.pullRequest ?? null,
|
||||||
|
consecutiveErrors,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2935,7 +3119,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
// changes. Revalidate that new identity immediately instead of leaving the
|
// changes. Revalidate that new identity immediately instead of leaving the
|
||||||
// PR panel empty for the full stable polling interval.
|
// PR panel empty for the full stable polling interval.
|
||||||
schedule(
|
schedule(
|
||||||
pollImmediately ? 0 : computeGenericForgeNextInterval(latestStatus, consecutiveErrors),
|
pollImmediately
|
||||||
|
? 0
|
||||||
|
: computeGenericForgeNextInterval(
|
||||||
|
target.latestForge?.pullRequest ?? null,
|
||||||
|
consecutiveErrors,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
unsubscribe: () => {
|
unsubscribe: () => {
|
||||||
@@ -2961,10 +3150,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
? target.latestFacts.pullRequestLookupTarget
|
? target.latestFacts.pullRequestLookupTarget
|
||||||
: null;
|
: null;
|
||||||
if (target.latestFacts?.isGit && target.latestFacts.paseoWorktree.isPaseoOwnedWorktree) {
|
if (target.latestFacts?.isGit && target.latestFacts.paseoWorktree.isPaseoOwnedWorktree) {
|
||||||
return lookupTarget;
|
return lookupTarget && isPullRequestTargetOnForge(git.remoteUrl, lookupTarget)
|
||||||
|
? lookupTarget
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
if (lookupTarget) {
|
if (lookupTarget) {
|
||||||
return lookupTarget;
|
return isPullRequestTargetOnForge(git.remoteUrl, lookupTarget) ? lookupTarget : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { headRef: git.currentBranch };
|
return { headRef: git.currentBranch };
|
||||||
@@ -3049,6 +3240,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
target: WorkspaceGitTarget,
|
target: WorkspaceGitTarget,
|
||||||
request: WorkspaceGitRefreshRequest,
|
request: WorkspaceGitRefreshRequest,
|
||||||
): Promise<WorkspaceGitRuntimeSnapshot> {
|
): Promise<WorkspaceGitRuntimeSnapshot> {
|
||||||
|
if (target.folderMissing) {
|
||||||
|
return Promise.resolve(target.latestSnapshot ?? buildNotGitSnapshot(target.cwd));
|
||||||
|
}
|
||||||
if (target.refreshState.status === "in-flight") {
|
if (target.refreshState.status === "in-flight") {
|
||||||
const active = target.refreshState.request;
|
const active = target.refreshState.request;
|
||||||
const addsWork =
|
const addsWork =
|
||||||
@@ -3099,7 +3293,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
refreshWorktree: true,
|
refreshWorktree: true,
|
||||||
includeForge: options?.includeForge ?? true,
|
includeForge: options?.includeForge ?? true,
|
||||||
reason: options?.reason ?? defaultReason,
|
reason: options?.reason ?? defaultReason,
|
||||||
queueIfBusy: false,
|
queueIfBusy: options?.force === true && options.afterInFlight === true,
|
||||||
movedRemoteRefs: new Set(),
|
movedRemoteRefs: new Set(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3191,11 +3385,19 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
try {
|
try {
|
||||||
const runRefreshGitCommand = createRunGitCommand(`workspace-refresh:${request.reason}`);
|
const runRefreshGitCommand = createRunGitCommand(`workspace-refresh:${request.reason}`);
|
||||||
const admittedSnapshot = await this.workspaceRefreshLimit(() => {
|
const admittedSnapshot = await this.workspaceRefreshLimit(() => {
|
||||||
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
if (
|
||||||
|
target.closed ||
|
||||||
|
target.folderMissing ||
|
||||||
|
this.workspaceTargets.get(target.cwd) !== target
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return this.refreshSnapshot(target, request, runRefreshGitCommand);
|
return this.refreshSnapshot(target, request, runRefreshGitCommand);
|
||||||
});
|
});
|
||||||
|
if (target.folderMissing) {
|
||||||
|
failure = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (!admittedSnapshot) {
|
if (!admittedSnapshot) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -3209,6 +3411,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
failure = { error };
|
failure = { error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (target.folderMissing) {
|
||||||
|
failure = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (this.disposed || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
if (this.disposed || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -3304,12 +3510,17 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
runGitCommand: runRefreshGitCommand,
|
runGitCommand: runRefreshGitCommand,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
target.latestFacts = { ...facts, upstreamStatus: derived.upstreamStatus };
|
target.latestFacts = {
|
||||||
|
...facts,
|
||||||
|
upstreamStatus: derived.upstreamStatus,
|
||||||
|
upstreamKnown: derived.upstreamStatus !== null,
|
||||||
|
};
|
||||||
target.latestGit = {
|
target.latestGit = {
|
||||||
...latestGit,
|
...latestGit,
|
||||||
aheadBehind: derived.aheadBehind,
|
aheadBehind: derived.aheadBehind,
|
||||||
diffStat: derived.diffStat,
|
diffStat: derived.diffStat,
|
||||||
upstreamRef: derived.upstreamStatus?.ref ?? null,
|
upstreamRef: derived.upstreamStatus?.ref ?? null,
|
||||||
|
upstreamKnown: derived.upstreamStatus !== null,
|
||||||
aheadOfOrigin: derived.upstreamStatus?.aheadBehind.ahead ?? null,
|
aheadOfOrigin: derived.upstreamStatus?.aheadBehind.ahead ?? null,
|
||||||
behindOfOrigin: derived.upstreamStatus?.aheadBehind.behind ?? null,
|
behindOfOrigin: derived.upstreamStatus?.aheadBehind.behind ?? null,
|
||||||
};
|
};
|
||||||
@@ -3354,7 +3565,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
target.lastShellOutAtMs = now.getTime();
|
target.lastShellOutAtMs = now.getTime();
|
||||||
|
|
||||||
const cwd = target.cwd;
|
const cwd = target.cwd;
|
||||||
const previousForgePrStatusPollKey = this.getForgePrStatusPollKey(target);
|
const previousForgePrAssociationKey = this.getForgePrAssociationKey(target);
|
||||||
const baseContext: CheckoutContext = {
|
const baseContext: CheckoutContext = {
|
||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
worktreesRoot: this.worktreesRoot,
|
worktreesRoot: this.worktreesRoot,
|
||||||
@@ -3384,6 +3595,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
|
|
||||||
target.latestGit = {
|
target.latestGit = {
|
||||||
isGit: true,
|
isGit: true,
|
||||||
|
headSha: facts.isGit ? facts.pullRequestLookupTarget?.headSha : undefined,
|
||||||
|
upstreamKnown: facts.isGit ? facts.upstreamKnown : false,
|
||||||
repoRoot: checkoutStatus.repoRoot,
|
repoRoot: checkoutStatus.repoRoot,
|
||||||
mainRepoRoot: checkoutStatus.mainRepoRoot,
|
mainRepoRoot: checkoutStatus.mainRepoRoot,
|
||||||
currentBranch: checkoutStatus.currentBranch,
|
currentBranch: checkoutStatus.currentBranch,
|
||||||
@@ -3403,7 +3616,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
const loadedAtMs = this.deps.now().getTime();
|
const loadedAtMs = this.deps.now().getTime();
|
||||||
target.latestGitLoadedAtMs = loadedAtMs;
|
target.latestGitLoadedAtMs = loadedAtMs;
|
||||||
|
|
||||||
if (previousForgePrStatusPollKey !== this.getForgePrStatusPollKey(target)) {
|
if (previousForgePrAssociationKey !== this.getForgePrAssociationKey(target)) {
|
||||||
target.latestForge = buildForgeUnavailableSnapshot();
|
target.latestForge = buildForgeUnavailableSnapshot();
|
||||||
target.latestForgeLoadedAtMs = target.latestGitLoadedAtMs;
|
target.latestForgeLoadedAtMs = target.latestGitLoadedAtMs;
|
||||||
}
|
}
|
||||||
@@ -3433,20 +3646,28 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
forgeService.invalidate({ cwd: target.cwd });
|
forgeService.invalidate({ cwd: target.cwd });
|
||||||
}
|
}
|
||||||
|
|
||||||
const forgeSnapshot = await loadForgeSnapshot({
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
cwd: target.cwd,
|
const previousForge = target.latestForge;
|
||||||
forgeService,
|
const forgeSnapshot = await loadForgeSnapshot({
|
||||||
now: this.deps.now(),
|
cwd: target.cwd,
|
||||||
deps: this.deps,
|
forgeService,
|
||||||
force: forceForge,
|
now: this.deps.now(),
|
||||||
reason: request.reason,
|
deps: this.deps,
|
||||||
facts,
|
force: forceForge,
|
||||||
runGitCommand: runRefreshGitCommand,
|
reason: request.reason,
|
||||||
});
|
facts,
|
||||||
// Carry the resolved forge (probe-aware) so the wire projection labels
|
runGitCommand: runRefreshGitCommand,
|
||||||
// self-managed GitLab hosts correctly instead of falling back to "github".
|
previousForge,
|
||||||
target.latestForge = { ...forgeSnapshot, forge: resolution.forge };
|
});
|
||||||
target.latestForgeLoadedAtMs = this.deps.now().getTime();
|
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 {
|
private combineSnapshot(target: WorkspaceGitTarget): WorkspaceGitRuntimeSnapshot {
|
||||||
@@ -3461,7 +3682,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getForgePrStatusPollKey(target: WorkspaceGitTarget): string | null {
|
private getForgePrAssociationKey(target: WorkspaceGitTarget): string | null {
|
||||||
const git = target.latestGit;
|
const git = target.latestGit;
|
||||||
if (!git?.currentBranch || !git.remoteUrl) {
|
if (!git?.currentBranch || !git.remoteUrl) {
|
||||||
return null;
|
return null;
|
||||||
@@ -3477,7 +3698,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildWorkspaceForgePrStatusPollKey({
|
return buildWorkspaceForgePrAssociationKey({
|
||||||
forge: resolution.forge,
|
forge: resolution.forge,
|
||||||
remoteUrl: git.remoteUrl,
|
remoteUrl: git.remoteUrl,
|
||||||
target: pollTarget,
|
target: pollTarget,
|
||||||
@@ -3504,7 +3725,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
snapshot: WorkspaceGitRuntimeSnapshot,
|
snapshot: WorkspaceGitRuntimeSnapshot,
|
||||||
options?: { forceEmit?: boolean },
|
options?: { forceEmit?: boolean },
|
||||||
): void {
|
): void {
|
||||||
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
if (target.closed || target.folderMissing || this.workspaceTargets.get(target.cwd) !== target) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
target.latestSnapshot = snapshot;
|
target.latestSnapshot = snapshot;
|
||||||
@@ -3660,21 +3881,25 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private removeWorkspaceTarget(target: WorkspaceGitTarget): void {
|
private removeWorkspaceTarget(target: WorkspaceGitTarget): void {
|
||||||
if (target.repoGitRoot) {
|
this.detachWorkspaceFromRepoTarget(target);
|
||||||
const repoTarget = this.repoTargets.get(target.repoGitRoot);
|
|
||||||
repoTarget?.workspaceKeys.delete(target.cwd);
|
|
||||||
if (repoTarget && repoTarget.workspaceKeys.size === 0) {
|
|
||||||
this.closeRepoTarget(repoTarget);
|
|
||||||
this.repoTargets.delete(target.repoGitRoot);
|
|
||||||
} else if (repoTarget?.cwd === target.cwd) {
|
|
||||||
repoTarget.cwd = repoTarget.workspaceKeys.values().next().value ?? repoTarget.cwd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.closeWorkspaceTarget(target);
|
this.closeWorkspaceTarget(target);
|
||||||
this.workspaceTargets.delete(target.cwd);
|
this.workspaceTargets.delete(target.cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private detachWorkspaceFromRepoTarget(target: WorkspaceGitTarget): void {
|
||||||
|
if (!target.repoGitRoot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const repoTarget = this.repoTargets.get(target.repoGitRoot);
|
||||||
|
repoTarget?.workspaceKeys.delete(target.cwd);
|
||||||
|
if (repoTarget && repoTarget.workspaceKeys.size === 0) {
|
||||||
|
this.closeRepoTarget(repoTarget);
|
||||||
|
this.repoTargets.delete(target.repoGitRoot);
|
||||||
|
} else if (repoTarget?.cwd === target.cwd) {
|
||||||
|
repoTarget.cwd = repoTarget.workspaceKeys.values().next().value ?? repoTarget.cwd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private removeWorkingTreeWatchListener(cwd: string, listener: () => void): void {
|
private removeWorkingTreeWatchListener(cwd: string, listener: () => void): void {
|
||||||
const target = this.workingTreeWatchTargets.get(cwd);
|
const target = this.workingTreeWatchTargets.get(cwd);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@@ -3719,6 +3944,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
|
|
||||||
private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
|
private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
|
||||||
target.closed = true;
|
target.closed = true;
|
||||||
|
this.stopWorkspaceObservation(target);
|
||||||
|
target.listeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopWorkspaceObservation(target: WorkspaceGitTarget): void {
|
||||||
if (target.workingTreeWatchTarget) {
|
if (target.workingTreeWatchTarget) {
|
||||||
this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd);
|
this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd);
|
||||||
target.workingTreeWatchTarget = null;
|
target.workingTreeWatchTarget = null;
|
||||||
@@ -3727,12 +3957,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
clearTimeout(target.debounceTimer);
|
clearTimeout(target.debounceTimer);
|
||||||
target.debounceTimer = null;
|
target.debounceTimer = null;
|
||||||
}
|
}
|
||||||
|
target.pendingDebounceRequest = null;
|
||||||
if (target.observationReensureTimer) {
|
if (target.observationReensureTimer) {
|
||||||
clearTimeout(target.observationReensureTimer);
|
clearTimeout(target.observationReensureTimer);
|
||||||
target.observationReensureTimer = null;
|
target.observationReensureTimer = null;
|
||||||
}
|
}
|
||||||
this.stopForgePrStatusPollForTarget(target);
|
this.stopForgePrStatusPollForTarget(target);
|
||||||
target.listeners.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void {
|
private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void {
|
||||||
@@ -3807,6 +4037,7 @@ async function loadForgeSnapshot(options: {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
facts?: CheckoutSnapshotFacts;
|
facts?: CheckoutSnapshotFacts;
|
||||||
runGitCommand: RunGitCommand;
|
runGitCommand: RunGitCommand;
|
||||||
|
previousForge: WorkspaceGitRuntimeSnapshot["forge"] | null;
|
||||||
}): Promise<WorkspaceGitRuntimeSnapshot["forge"]> {
|
}): Promise<WorkspaceGitRuntimeSnapshot["forge"]> {
|
||||||
const forgeService = options.forgeService;
|
const forgeService = options.forgeService;
|
||||||
if (!forgeService) {
|
if (!forgeService) {
|
||||||
@@ -3832,6 +4063,7 @@ async function loadForgeSnapshot(options: {
|
|||||||
{
|
{
|
||||||
force: options.force,
|
force: options.force,
|
||||||
reason: options.reason,
|
reason: options.reason,
|
||||||
|
previousPullRequest: options.previousForge?.pullRequest ?? null,
|
||||||
},
|
},
|
||||||
{ facts: options.facts, runGitCommand: options.runGitCommand },
|
{ facts: options.facts, runGitCommand: options.runGitCommand },
|
||||||
);
|
);
|
||||||
@@ -3839,7 +4071,7 @@ async function loadForgeSnapshot(options: {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// The auth probe succeeded, so a failure here is a command error, not an
|
// 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.
|
// 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),
|
message: error instanceof Error ? error.message : String(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3956,13 +4188,31 @@ function buildWorkspaceForgePrStatusPollKey({
|
|||||||
forge: string;
|
forge: string;
|
||||||
remoteUrl: string;
|
remoteUrl: string;
|
||||||
target: WorkspaceForgePrStatusPollTarget;
|
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 {
|
}): string {
|
||||||
return JSON.stringify([
|
return JSON.stringify([
|
||||||
forge,
|
forge,
|
||||||
remoteUrl,
|
remoteUrl,
|
||||||
target.headRef,
|
target.headRef,
|
||||||
target.headSha ?? null,
|
|
||||||
target.headRepositoryOwner ?? 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 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";
|
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 type ForgeSpecificStatusFacts = { forge: string } & Record<string, unknown>;
|
||||||
|
|
||||||
export interface CurrentPullRequestStatus {
|
export interface CurrentPullRequestStatus {
|
||||||
|
/** Commit recorded as the change request's head by the forge. */
|
||||||
|
headSha?: string;
|
||||||
number?: number;
|
number?: number;
|
||||||
repoOwner?: string;
|
repoOwner?: string;
|
||||||
repoName?: string;
|
repoName?: string;
|
||||||
@@ -472,6 +499,11 @@ export interface ForgeService {
|
|||||||
headRef: string;
|
headRef: string;
|
||||||
headSha?: string;
|
headSha?: string;
|
||||||
headRepositoryOwner?: string;
|
headRepositoryOwner?: string;
|
||||||
|
headRepositoryPath?: string;
|
||||||
|
headRepositoryHost?: string;
|
||||||
|
headRepositoryEndpoint?: HeadRepositoryEndpoint;
|
||||||
|
previousPullRequest?: { number?: number; url: string; state: string } | null;
|
||||||
|
changeRequestNumber?: number;
|
||||||
} & ForgeReadOptions,
|
} & ForgeReadOptions,
|
||||||
): Promise<CurrentPullRequestStatus | null>;
|
): Promise<CurrentPullRequestStatus | null>;
|
||||||
getPullRequestTimeline(options: GetPullRequestTimelineOptions): Promise<PullRequestTimeline>;
|
getPullRequestTimeline(options: GetPullRequestTimelineOptions): Promise<PullRequestTimeline>;
|
||||||
@@ -513,6 +545,9 @@ export interface ForgeService {
|
|||||||
headRef: string;
|
headRef: string;
|
||||||
headSha?: string;
|
headSha?: string;
|
||||||
headRepositoryOwner?: string;
|
headRepositoryOwner?: string;
|
||||||
|
headRepositoryPath?: string;
|
||||||
|
headRepositoryEndpoint?: HeadRepositoryEndpoint;
|
||||||
|
changeRequestNumber?: number;
|
||||||
onStatus?: (status: CurrentPullRequestStatus | null) => void;
|
onStatus?: (status: CurrentPullRequestStatus | null) => void;
|
||||||
onError?: (error: unknown) => void;
|
onError?: (error: unknown) => void;
|
||||||
}): { unsubscribe: () => void };
|
}): { unsubscribe: () => void };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import type { PullRequestCommandStatus, PullRequestTimelineItem } from "./forge-service.js";
|
import type { PullRequestCommandStatus, PullRequestTimelineItem } from "./forge-service.js";
|
||||||
|
import { ForgeDiscoveryIncompleteError } from "./forge-service.js";
|
||||||
import {
|
import {
|
||||||
type CreateGiteaServiceOptions,
|
type CreateGiteaServiceOptions,
|
||||||
createGiteaService,
|
createGiteaService,
|
||||||
@@ -19,9 +20,40 @@ function ok(stdout: string): TeaCommandResult {
|
|||||||
|
|
||||||
function makeService(responder: Responder, overrides: Partial<CreateGiteaServiceOptions> = {}) {
|
function makeService(responder: Responder, overrides: Partial<CreateGiteaServiceOptions> = {}) {
|
||||||
const calls: string[][] = [];
|
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) => {
|
const runner: TeaCommandRunner = async (args) => {
|
||||||
calls.push(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({
|
const service = createGiteaService({
|
||||||
runner,
|
runner,
|
||||||
@@ -94,7 +126,7 @@ function currentPullRequestApi(input: {
|
|||||||
head: {
|
head: {
|
||||||
ref: input.headRef,
|
ref: input.headRef,
|
||||||
sha: input.headSha,
|
sha: input.headSha,
|
||||||
repo: { id: 1, owner: { login: "example-user" } },
|
repo: { id: 1, full_name: "example-user/sample-repo", owner: { login: "example-user" } },
|
||||||
},
|
},
|
||||||
base: {
|
base: {
|
||||||
ref: "main",
|
ref: "main",
|
||||||
@@ -485,8 +517,9 @@ describe("createGiteaService", () => {
|
|||||||
expect(calls[0]).toContain("--fields");
|
expect(calls[0]).toContain("--fields");
|
||||||
expect(calls[0]).toContain("-o");
|
expect(calls[0]).toContain("-o");
|
||||||
expect(calls[0]).toContain("json");
|
expect(calls[0]).toContain("json");
|
||||||
expect(calls[1]).toEqual(["pr", "5", "-o", "json"]);
|
expect(calls[1]).toEqual(["api", "repos/example-user/sample-repo/pulls/5"]);
|
||||||
expect(calls[2]).toEqual([
|
expect(calls[2]).toEqual(["pr", "5", "-o", "json"]);
|
||||||
|
expect(calls[3]).toEqual([
|
||||||
"api",
|
"api",
|
||||||
"repos/example-user/sample-repo/commits/3333333333333333333333333333333333333333/status",
|
"repos/example-user/sample-repo/commits/3333333333333333333333333333333333333333/status",
|
||||||
]);
|
]);
|
||||||
@@ -1627,6 +1660,7 @@ describe("createGiteaService", () => {
|
|||||||
"--page",
|
"--page",
|
||||||
"1",
|
"1",
|
||||||
],
|
],
|
||||||
|
["api", "repos/example-user/sample-repo/pulls/5"],
|
||||||
["pr", "5", "-o", "json"],
|
["pr", "5", "-o", "json"],
|
||||||
["api", `repos/example-user/sample-repo/commits/${STATUS_PR_VIEW.headSha}/status`],
|
["api", `repos/example-user/sample-repo/commits/${STATUS_PR_VIEW.headSha}/status`],
|
||||||
]);
|
]);
|
||||||
@@ -2055,6 +2089,7 @@ describe("createGiteaService", () => {
|
|||||||
cwd: "/repo",
|
cwd: "/repo",
|
||||||
headRef: "feat/recently-closed",
|
headRef: "feat/recently-closed",
|
||||||
headSha,
|
headSha,
|
||||||
|
headRepositoryPath: "example-user/sample-repo",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(status).toMatchObject({ number: 8, headRefName: "feat/recently-closed" });
|
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({
|
const stale = currentPullRequestApi({
|
||||||
number: 8,
|
number: 8,
|
||||||
state: "closed",
|
state: "closed",
|
||||||
@@ -2086,9 +2145,548 @@ describe("createGiteaService", () => {
|
|||||||
headRef: "dev",
|
headRef: "dev",
|
||||||
headSha: "2222222222222222222222222222222222222222",
|
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();
|
).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 () => {
|
it("returns null when no PR matches the current branch", async () => {
|
||||||
const { service, calls } = makeService((args) => {
|
const { service, calls } = makeService((args) => {
|
||||||
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([]));
|
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 () => {
|
it("resolves an open cross-fork PR for the specified head owner", 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.
|
|
||||||
const forkHeadSha = "c4ce035f493f2c50f656e3e5bdba29b321a74042";
|
const forkHeadSha = "c4ce035f493f2c50f656e3e5bdba29b321a74042";
|
||||||
const forkPr = {
|
const forkPr = {
|
||||||
number: 6,
|
number: 6,
|
||||||
@@ -2129,7 +2723,7 @@ describe("createGiteaService", () => {
|
|||||||
head: {
|
head: {
|
||||||
ref: "feat/fork-change",
|
ref: "feat/fork-change",
|
||||||
sha: forkHeadSha,
|
sha: forkHeadSha,
|
||||||
repo: { id: 2, owner: { login: "contributor" } },
|
repo: { id: 2, full_name: "contributor/renamed", owner: { login: "contributor" } },
|
||||||
},
|
},
|
||||||
base: {
|
base: {
|
||||||
ref: "main",
|
ref: "main",
|
||||||
@@ -2157,6 +2751,8 @@ describe("createGiteaService", () => {
|
|||||||
cwd: "/repo",
|
cwd: "/repo",
|
||||||
headRef: "feat/fork-change",
|
headRef: "feat/fork-change",
|
||||||
headSha: forkHeadSha,
|
headSha: forkHeadSha,
|
||||||
|
headRepositoryOwner: "contributor",
|
||||||
|
headRepositoryPath: "contributor/renamed",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(status?.number).toBe(6);
|
expect(status?.number).toBe(6);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { LRUCache } from "lru-cache";
|
||||||
import { CHECK_TRAIT_ACTION_REQUIRED, CHECK_TRAIT_WARNING } from "@getpaseo/protocol/check-traits";
|
import { CHECK_TRAIT_ACTION_REQUIRED, CHECK_TRAIT_WARNING } from "@getpaseo/protocol/check-traits";
|
||||||
import { mapGiteaCommitState } from "@getpaseo/protocol/gitea-status";
|
import { mapGiteaCommitState } from "@getpaseo/protocol/gitea-status";
|
||||||
import pLimit from "p-limit";
|
import pLimit from "p-limit";
|
||||||
@@ -27,6 +28,8 @@ import {
|
|||||||
createUnavailableSearchResult,
|
createUnavailableSearchResult,
|
||||||
normalizeForgeSearchKinds,
|
normalizeForgeSearchKinds,
|
||||||
parseOptionalTime,
|
parseOptionalTime,
|
||||||
|
matchesHeadRepositoryEndpoint,
|
||||||
|
ForgeDiscoveryIncompleteError,
|
||||||
} from "./forge-service.js";
|
} from "./forge-service.js";
|
||||||
import type {
|
import type {
|
||||||
CheckDetails,
|
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_LOOKUP_PAGE_SIZE = 50;
|
||||||
const CURRENT_PR_OPEN_LOOKUP_MAX_PAGES = 4;
|
const CURRENT_PR_OPEN_LOOKUP_MAX_PAGES = 4;
|
||||||
|
const CURRENT_PR_IDENTITY_READ_LIMIT = 3;
|
||||||
const TIMELINE_PAGE_SIZE = 50;
|
const TIMELINE_PAGE_SIZE = 50;
|
||||||
const TIMELINE_MAX_PAGES_PER_BUCKET = 4;
|
const TIMELINE_MAX_PAGES_PER_BUCKET = 4;
|
||||||
|
|
||||||
@@ -160,6 +164,7 @@ const GiteaPrListItemSchema = z
|
|||||||
head: z.string().optional(),
|
head: z.string().optional(),
|
||||||
created: z.string().optional(),
|
created: z.string().optional(),
|
||||||
updated: z.string().optional(),
|
updated: z.string().optional(),
|
||||||
|
headSha: z.string().optional(),
|
||||||
labels: z.string().optional(),
|
labels: z.string().optional(),
|
||||||
ci: z.string().optional(),
|
ci: z.string().optional(),
|
||||||
})
|
})
|
||||||
@@ -223,7 +228,9 @@ const GiteaPullRequestViewSchema = z
|
|||||||
const GiteaPullRequestRepoSchema = z
|
const GiteaPullRequestRepoSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: z.number().optional(),
|
id: z.number().optional(),
|
||||||
|
full_name: z.string().optional(),
|
||||||
ssh_url: z.string().nullable().optional(),
|
ssh_url: z.string().nullable().optional(),
|
||||||
|
clone_url: z.string().nullable().optional(),
|
||||||
html_url: z.string().nullable().optional(),
|
html_url: z.string().nullable().optional(),
|
||||||
owner: z.object({ login: z.string().optional() }).passthrough().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) } : {}),
|
...(item.mergeable != null ? { mergeable: String(item.mergeable) } : {}),
|
||||||
base: item.base.ref,
|
base: item.base.ref,
|
||||||
head,
|
head,
|
||||||
|
headSha: item.head.sha,
|
||||||
updated: item.updated_at,
|
updated: item.updated_at,
|
||||||
labels: item.labels.map((label) => label.name).join(","),
|
labels: item.labels.map((label) => label.name).join(","),
|
||||||
};
|
};
|
||||||
@@ -1082,6 +1090,7 @@ function toCurrentPullRequestStatus(item: GiteaPrListItem): CurrentPullRequestSt
|
|||||||
state,
|
state,
|
||||||
baseRefName: item.base ?? "",
|
baseRefName: item.base ?? "",
|
||||||
headRefName: stripHeadOwner(item.head),
|
headRefName: stripHeadOwner(item.head),
|
||||||
|
...(item.headSha ? { headSha: item.headSha } : {}),
|
||||||
isMerged: state === "merged",
|
isMerged: state === "merged",
|
||||||
mergeable: mapGiteaMergeable(item),
|
mergeable: mapGiteaMergeable(item),
|
||||||
checks: [],
|
checks: [],
|
||||||
@@ -1394,6 +1403,11 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
|
|||||||
const resolveCurrentBranch = options.resolveCurrentBranch ?? defaultResolveCurrentBranch;
|
const resolveCurrentBranch = options.resolveCurrentBranch ?? defaultResolveCurrentBranch;
|
||||||
const now = options.now ?? Date.now;
|
const now = options.now ?? Date.now;
|
||||||
const currentPrHeadShaCache = new Map<string, { sha: string; expiresAt: number }>();
|
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> {
|
async function run(args: string[], runOptions: TeaCommandRunnerOptions): Promise<string> {
|
||||||
const teaPath = await resolveTea();
|
const teaPath = await resolveTea();
|
||||||
@@ -1578,6 +1592,12 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
headRef: string;
|
headRef: string;
|
||||||
headSha?: 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> {
|
}): Promise<CurrentPullRequestStatus | null> {
|
||||||
const match = await findCurrentPullRequestForHeadRef(input);
|
const match = await findCurrentPullRequestForHeadRef(input);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -1606,21 +1626,199 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
headRef: string;
|
headRef: string;
|
||||||
headSha?: 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> {
|
}): Promise<GiteaPrListItem | null> {
|
||||||
const repoIdentity = await resolveCurrentRepoIdentity(input.cwd);
|
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
|
const repoPath = repoIdentity
|
||||||
? `repos/${encodeURIComponent(repoIdentity.owner)}/${encodeURIComponent(repoIdentity.name)}`
|
? `repos/${encodeURIComponent(repoIdentity.owner)}/${encodeURIComponent(repoIdentity.name)}`
|
||||||
: "repos/{owner}/{repo}";
|
: "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",
|
"api",
|
||||||
`${repoPath}/pulls?state=all&sort=recentupdate&page=1&limit=${CURRENT_PR_LOOKUP_PAGE_SIZE}`,
|
`${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 },
|
{ cwd: input.cwd },
|
||||||
GiteaCurrentPullRequestApiSchema,
|
GiteaCurrentPullRequestApiSchema,
|
||||||
);
|
);
|
||||||
const candidates = recentItems.filter((item) => {
|
const candidates: GiteaCurrentPullRequestApi[] = [];
|
||||||
|
for (const item of items) {
|
||||||
if (
|
if (
|
||||||
matchesCurrentHeadRef(
|
(await matchesHead(currentPullRequestApiToListItem(item))) &&
|
||||||
currentPullRequestApiToListItem(item),
|
(await matchesRepository(item))
|
||||||
input.headRef,
|
)
|
||||||
expectedHeadOwner,
|
candidates.push(item);
|
||||||
)
|
}
|
||||||
) {
|
candidates.sort(
|
||||||
return true;
|
(a, b) =>
|
||||||
}
|
Number(b.state === "open") - Number(a.state === "open") ||
|
||||||
// A cross-fork PR carries the fork owner in its head, so the checkout-owner
|
parseOptionalTime(b.updated_at) - parseOptionalTime(a.updated_at) ||
|
||||||
// gate excludes it even though the PR lives in this base repo. The head sha
|
b.number - a.number,
|
||||||
// is an unambiguous identity, so accept a match on it regardless of owner —
|
);
|
||||||
// this mirrors the GitLab adapter, which resolves fork MRs without an
|
const match = candidates[0] ?? null;
|
||||||
// owner gate.
|
const last = items.at(-1);
|
||||||
return input.headSha !== undefined && item.head.sha === input.headSha;
|
if (
|
||||||
});
|
items.length >= CURRENT_PR_LOOKUP_PAGE_SIZE &&
|
||||||
const match =
|
(!match?.updated_at ||
|
||||||
candidates.find((item) => mapGiteaState(item.state) === "open") ??
|
!last?.updated_at ||
|
||||||
candidates.find(
|
parseOptionalTime(last.updated_at) >= parseOptionalTime(match.updated_at))
|
||||||
(item) =>
|
)
|
||||||
mapGiteaState(item.state) !== "open" &&
|
throw new ForgeDiscoveryIncompleteError(
|
||||||
input.headSha !== undefined &&
|
"Gitea pull request discovery exceeded its recent candidate window",
|
||||||
item.head.sha === input.headSha,
|
);
|
||||||
) ??
|
|
||||||
null;
|
|
||||||
return match ? currentPullRequestApiToListItem(match) : null;
|
return match ? currentPullRequestApiToListItem(match) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1662,9 +1859,16 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
headRef: string;
|
headRef: string;
|
||||||
headSha?: string;
|
headSha?: string;
|
||||||
|
force?: boolean;
|
||||||
},
|
},
|
||||||
expectedHeadOwner: string | null,
|
matchesHead: (item: GiteaPrListItem) => Promise<boolean>,
|
||||||
): Promise<GiteaPrListItem | null> {
|
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) {
|
for (let page = 1; page <= CURRENT_PR_OPEN_LOOKUP_MAX_PAGES; page += 1) {
|
||||||
const items = await listPullRequestItems({
|
const items = await listPullRequestItems({
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
@@ -1672,17 +1876,36 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
|
|||||||
limit: CURRENT_PR_LOOKUP_PAGE_SIZE,
|
limit: CURRENT_PR_LOOKUP_PAGE_SIZE,
|
||||||
page,
|
page,
|
||||||
});
|
});
|
||||||
const match = items.find((item) =>
|
for (const item of items) {
|
||||||
matchesCurrentHeadRef(item, input.headRef, expectedHeadOwner),
|
if (!(await matchesHead(item))) continue;
|
||||||
);
|
const identityKey = JSON.stringify([
|
||||||
if (match) {
|
input.cwd,
|
||||||
return match;
|
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) {
|
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(
|
async function loadCurrentPullRequestChecks(
|
||||||
@@ -1718,7 +1941,12 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
|
|||||||
combined,
|
combined,
|
||||||
actionTasks,
|
actionTasks,
|
||||||
);
|
);
|
||||||
return applyGiteaChecks(status, combined, actionTasks, actionRequiredRunIds);
|
return applyGiteaChecks(
|
||||||
|
status.headSha ? status : { ...status, headSha: pr.headSha },
|
||||||
|
combined,
|
||||||
|
actionTasks,
|
||||||
|
actionRequiredRunIds,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
@@ -2003,6 +2231,12 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
|
|||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
headRef: input.headRef,
|
headRef: input.headRef,
|
||||||
headSha: input.headSha,
|
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 {
|
invalidate(input: { cwd: string }): void {
|
||||||
|
pullRequestIdentityCache.clear();
|
||||||
|
canonicalRepositoryPaths.clear();
|
||||||
const prefix = `${input.cwd}:`;
|
const prefix = `${input.cwd}:`;
|
||||||
for (const key of currentPrHeadShaCache.keys()) {
|
for (const key of currentPrHeadShaCache.keys()) {
|
||||||
if (key.startsWith(prefix)) {
|
if (key.startsWith(prefix)) {
|
||||||
|
|||||||
@@ -18,14 +18,16 @@ import {
|
|||||||
} from "./github-service.js";
|
} from "./github-service.js";
|
||||||
import { isPlatform } from "../test-utils/platform.js";
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
import { CheckoutPrStatusResponseSchema } from "@getpaseo/protocol/messages";
|
import { CheckoutPrStatusResponseSchema } from "@getpaseo/protocol/messages";
|
||||||
|
import { ForgeDiscoveryIncompleteError } from "./forge-service.js";
|
||||||
|
|
||||||
const EXPECTED_GITHUB_FAST_POLL_MS = 20_000;
|
const EXPECTED_GITHUB_FAST_POLL_MS = 20_000;
|
||||||
const EXPECTED_GITHUB_SLOW_POLL_MS = 120_000;
|
const EXPECTED_GITHUB_SLOW_POLL_MS = 120_000;
|
||||||
const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000;
|
const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000;
|
||||||
const CURRENT_PR_STATUS_BASE_FIELDS =
|
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 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 {
|
interface RunnerCall {
|
||||||
args: string[];
|
args: string[];
|
||||||
@@ -202,6 +204,8 @@ function readFakeGitHubCliLog(logPath: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function currentPullRequestJson(overrides: Record<string, unknown> = {}): 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({
|
return JSON.stringify({
|
||||||
number: 42,
|
number: 42,
|
||||||
url: "https://github.com/parentOwner/parentRepo/pull/42",
|
url: "https://github.com/parentOwner/parentRepo/pull/42",
|
||||||
@@ -211,6 +215,8 @@ function currentPullRequestJson(overrides: Record<string, unknown> = {}): string
|
|||||||
baseRefName: "main",
|
baseRefName: "main",
|
||||||
headRefName: "feature/fork",
|
headRefName: "feature/fork",
|
||||||
headRefOid: "1111111111111111111111111111111111111111",
|
headRefOid: "1111111111111111111111111111111111111111",
|
||||||
|
headRepositoryOwner: { login: owner },
|
||||||
|
headRepository: { name },
|
||||||
mergedAt: null,
|
mergedAt: null,
|
||||||
statusCheckRollup: [],
|
statusCheckRollup: [],
|
||||||
reviewDecision: "REVIEW_REQUIRED",
|
reviewDecision: "REVIEW_REQUIRED",
|
||||||
@@ -244,6 +250,8 @@ function currentPullRequestGithubFactsJson(overrides: Record<string, unknown> =
|
|||||||
}
|
}
|
||||||
|
|
||||||
function batchPollPrNodeJson(overrides: Record<string, unknown> = {}): 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 {
|
return {
|
||||||
number: 42,
|
number: 42,
|
||||||
url: "https://github.com/acme/widgets/pull/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",
|
mergedAt: "2026-08-01T00:00:00Z",
|
||||||
mergeable: "UNKNOWN",
|
mergeable: "UNKNOWN",
|
||||||
reviewDecision: null,
|
reviewDecision: null,
|
||||||
headRepositoryOwner: { login: "acme" },
|
headRepositoryOwner: { login: owner },
|
||||||
|
headRepository: { name },
|
||||||
mergeStateStatus: "CLEAN",
|
mergeStateStatus: "CLEAN",
|
||||||
autoMergeRequest: null,
|
autoMergeRequest: null,
|
||||||
viewerCanEnableAutoMerge: false,
|
viewerCanEnableAutoMerge: false,
|
||||||
@@ -888,6 +897,7 @@ describe("ForgeService", () => {
|
|||||||
cwd: "/repo",
|
cwd: "/repo",
|
||||||
headRef: "open-button-targets-active-file",
|
headRef: "open-button-targets-active-file",
|
||||||
headRepositoryOwner: "fork-owner",
|
headRepositoryOwner: "fork-owner",
|
||||||
|
headRepositoryPath: "fork-owner/parentRepo",
|
||||||
});
|
});
|
||||||
await vi.advanceTimersByTimeAsync(0);
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
@@ -907,6 +917,50 @@ describe("ForgeService", () => {
|
|||||||
service.dispose?.();
|
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 () => {
|
it("polls PR status at slow cadence after stable checks", async () => {
|
||||||
let now = 0;
|
let now = 0;
|
||||||
const runner = createRunner([
|
const runner = createRunner([
|
||||||
@@ -1711,10 +1765,10 @@ describe("ForgeService", () => {
|
|||||||
batchPollPrNodeJson({
|
batchPollPrNodeJson({
|
||||||
number: 100 + index,
|
number: 100 + index,
|
||||||
url: `https://github.com/acme/widgets/pull/${100 + index}`,
|
url: `https://github.com/acme/widgets/pull/${100 + index}`,
|
||||||
state: "OPEN",
|
state: index === 0 ? "MERGED" : "OPEN",
|
||||||
mergedAt: null,
|
mergedAt: index === 0 ? "2026-08-01T00:00:00Z" : null,
|
||||||
headRefName: "feat-a",
|
headRefName: "feat-a",
|
||||||
headRepositoryOwner: { login: "stranger" },
|
headRepositoryOwner: { login: index === 0 ? "acme" : "stranger" },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const runner = createScriptedRunner([
|
const runner = createScriptedRunner([
|
||||||
@@ -1740,8 +1794,7 @@ describe("ForgeService", () => {
|
|||||||
await vi.advanceTimersByTimeAsync(0);
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
// Ten same-named fork PRs filled the page, so absence of a self-owned PR
|
// A full page can contain a terminal match while an older open PR is omitted.
|
||||||
// is inconclusive and the target resolves through the legacy path.
|
|
||||||
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
|
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
|
||||||
expect(statuses).toEqual([expect.objectContaining({ number: 42, state: "open" })]);
|
expect(statuses).toEqual([expect.objectContaining({ number: 42, state: "open" })]);
|
||||||
|
|
||||||
@@ -1790,6 +1843,114 @@ describe("ForgeService", () => {
|
|||||||
service.dispose?.();
|
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 () => {
|
it("recovers healthy aliases from a partially failed batch response", async () => {
|
||||||
let now = 0;
|
let now = 0;
|
||||||
const partialBody = batchPollStatusJson({
|
const partialBody = batchPollStatusJson({
|
||||||
@@ -2718,6 +2879,8 @@ describe("ForgeService", () => {
|
|||||||
isDraft: true,
|
isDraft: true,
|
||||||
baseRefName: "main",
|
baseRefName: "main",
|
||||||
headRefName: "feature/pr-pane",
|
headRefName: "feature/pr-pane",
|
||||||
|
headRepositoryOwner: { login: "acme" },
|
||||||
|
headRepository: { name: "repo" },
|
||||||
mergedAt: null,
|
mergedAt: null,
|
||||||
reviewDecision: "REVIEW_REQUIRED",
|
reviewDecision: "REVIEW_REQUIRED",
|
||||||
statusCheckRollup: [
|
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 headSha = "2222222222222222222222222222222222222222";
|
||||||
const runner = createRunner([
|
const merged = currentPullRequestJson({
|
||||||
currentPullRequestJson({
|
state: "MERGED",
|
||||||
state: "MERGED",
|
mergedAt: "2026-07-17T12:00:00Z",
|
||||||
mergedAt: "2026-07-17T12:00:00Z",
|
headRefOid: headSha,
|
||||||
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({
|
const service = createGitHubService({
|
||||||
runner: runner.runner,
|
runner: runner.runner,
|
||||||
@@ -3243,11 +3410,12 @@ describe("ForgeService", () => {
|
|||||||
expect(status?.state).toBe("merged");
|
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({
|
const stale = currentPullRequestJson({
|
||||||
state: "MERGED",
|
state: "MERGED",
|
||||||
mergedAt: "2026-07-17T12:00:00Z",
|
mergedAt: "2026-07-17T12:00:00Z",
|
||||||
headRefOid: "1111111111111111111111111111111111111111",
|
headRefOid: "1111111111111111111111111111111111111111",
|
||||||
|
headRepositoryOwner: { login: "parentOwner" },
|
||||||
});
|
});
|
||||||
const runner = createScriptedRunner([
|
const runner = createScriptedRunner([
|
||||||
stale,
|
stale,
|
||||||
@@ -3265,12 +3433,183 @@ describe("ForgeService", () => {
|
|||||||
headRef: "feature/fork",
|
headRef: "feature/fork",
|
||||||
headSha: "2222222222222222222222222222222222222222",
|
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(
|
expect(runner.calls.some((call) => call.args[0] === "pr" && call.args[1] === "list")).toBe(
|
||||||
true,
|
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 () => {
|
it("prefers an open PR over an exact-SHA merged PR for the same head", async () => {
|
||||||
const checkoutSha = "2222222222222222222222222222222222222222";
|
const checkoutSha = "2222222222222222222222222222222222222222";
|
||||||
const owner = { login: "forkOwner" };
|
const owner = { login: "forkOwner" };
|
||||||
@@ -3308,6 +3647,7 @@ describe("ForgeService", () => {
|
|||||||
headRef: "feature/fork",
|
headRef: "feature/fork",
|
||||||
headSha: checkoutSha,
|
headSha: checkoutSha,
|
||||||
headRepositoryOwner: "forkOwner",
|
headRepositoryOwner: "forkOwner",
|
||||||
|
headRepositoryPath: "forkOwner/parentRepo",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(status).toMatchObject({ number: 43, state: "open" });
|
expect(status).toMatchObject({ number: 43, state: "open" });
|
||||||
@@ -3326,7 +3666,18 @@ describe("ForgeService", () => {
|
|||||||
name: "parentRepo",
|
name: "parentRepo",
|
||||||
parent: { owner: { login: "parentOwner" }, 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({
|
JSON.stringify({
|
||||||
number: 41,
|
number: 41,
|
||||||
url: "https://github.com/parentOwner/parentRepo/pull/41",
|
url: "https://github.com/parentOwner/parentRepo/pull/41",
|
||||||
@@ -3339,6 +3690,7 @@ describe("ForgeService", () => {
|
|||||||
statusCheckRollup: [],
|
statusCheckRollup: [],
|
||||||
reviewDecision: "REVIEW_REQUIRED",
|
reviewDecision: "REVIEW_REQUIRED",
|
||||||
headRepositoryOwner: { login: "otherFork" },
|
headRepositoryOwner: { login: "otherFork" },
|
||||||
|
headRepository: { name: "parentRepo" },
|
||||||
}),
|
}),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
number: 42,
|
number: 42,
|
||||||
@@ -3352,6 +3704,7 @@ describe("ForgeService", () => {
|
|||||||
statusCheckRollup: [],
|
statusCheckRollup: [],
|
||||||
reviewDecision: "REVIEW_REQUIRED",
|
reviewDecision: "REVIEW_REQUIRED",
|
||||||
headRepositoryOwner: { login: "forkOwner" },
|
headRepositoryOwner: { login: "forkOwner" },
|
||||||
|
headRepository: { name: "parentRepo" },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const service = createGitHubService({
|
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 checkoutSha = "3333333333333333333333333333333333333333";
|
||||||
const viewed: number[] = [];
|
const viewed: number[] = [];
|
||||||
const runner: GitHubCommandRunner = async (args) => {
|
const runner: GitHubCommandRunner = async (args) => {
|
||||||
@@ -3399,10 +3752,19 @@ describe("ForgeService", () => {
|
|||||||
// Newest first, and the checkout is sitting on the oldest attempt.
|
// Newest first, and the checkout is sitting on the oldest attempt.
|
||||||
return {
|
return {
|
||||||
stdout: JSON.stringify([
|
stdout: JSON.stringify([
|
||||||
{ number: 50, head: { sha: "5".repeat(40) } },
|
{
|
||||||
{ number: 49, head: { sha: "4".repeat(40) } },
|
number: 50,
|
||||||
{ number: 48, head: { sha: "8".repeat(40) } },
|
head: { sha: "5".repeat(40), repo: { full_name: "forkOwner/parentRepo" } },
|
||||||
{ number: 47, head: { sha: checkoutSha } },
|
},
|
||||||
|
{
|
||||||
|
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: "",
|
stderr: "",
|
||||||
};
|
};
|
||||||
@@ -3436,14 +3798,163 @@ describe("ForgeService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(status).toMatchObject({
|
expect(status).toMatchObject({
|
||||||
number: 47,
|
number: 50,
|
||||||
repoOwner: "parentOwner",
|
repoOwner: "parentOwner",
|
||||||
repoName: "parentRepo",
|
repoName: "parentRepo",
|
||||||
});
|
});
|
||||||
expect(viewed).toContain(47);
|
expect(viewed).not.toContain(47);
|
||||||
expect(viewed).toHaveLength(3);
|
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 () => {
|
it("propagates a failed fork PR lookup instead of reporting no pull request", async () => {
|
||||||
const rateLimitError = new GitHubCommandError({
|
const rateLimitError = new GitHubCommandError({
|
||||||
args: ["api", "repos/parentOwner/parentRepo/pulls"],
|
args: ["api", "repos/parentOwner/parentRepo/pulls"],
|
||||||
@@ -3493,6 +4004,7 @@ describe("ForgeService", () => {
|
|||||||
statusCheckRollup: [],
|
statusCheckRollup: [],
|
||||||
reviewDecision: "REVIEW_REQUIRED",
|
reviewDecision: "REVIEW_REQUIRED",
|
||||||
headRepositoryOwner: { login: headRepositoryOwner },
|
headRepositoryOwner: { login: headRepositoryOwner },
|
||||||
|
headRepository: { name: "parentRepo" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const crowd = Array.from({ length: 10 }, (_, index) =>
|
const crowd = Array.from({ length: 10 }, (_, index) =>
|
||||||
@@ -3524,7 +4036,17 @@ describe("ForgeService", () => {
|
|||||||
}
|
}
|
||||||
if (args[0] === "api" && args[1]?.startsWith("repos/")) {
|
if (args[0] === "api" && args[1]?.startsWith("repos/")) {
|
||||||
expect(args[1]).toContain("head=forkOwner%3Amain");
|
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: "" };
|
return { stdout: "{}", stderr: "" };
|
||||||
};
|
};
|
||||||
@@ -3560,7 +4082,7 @@ describe("ForgeService", () => {
|
|||||||
name: "parentRepo",
|
name: "parentRepo",
|
||||||
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
|
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
|
||||||
}),
|
}),
|
||||||
JSON.stringify([{ number: 42 }]),
|
JSON.stringify([{ number: 42, head: { repo: { full_name: "forkOwner/parentRepo" } } }]),
|
||||||
{
|
{
|
||||||
error: statusCheckRollupPermissionError([
|
error: statusCheckRollupPermissionError([
|
||||||
"pr",
|
"pr",
|
||||||
@@ -3583,6 +4105,7 @@ describe("ForgeService", () => {
|
|||||||
mergedAt: null,
|
mergedAt: null,
|
||||||
reviewDecision: "REVIEW_REQUIRED",
|
reviewDecision: "REVIEW_REQUIRED",
|
||||||
headRepositoryOwner: { login: "forkOwner" },
|
headRepositoryOwner: { login: "forkOwner" },
|
||||||
|
headRepository: { name: "parentRepo" },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const service = createGitHubService({
|
const service = createGitHubService({
|
||||||
@@ -3679,6 +4202,8 @@ describe("ForgeService", () => {
|
|||||||
"all",
|
"all",
|
||||||
"--head",
|
"--head",
|
||||||
"main",
|
"main",
|
||||||
|
"--search",
|
||||||
|
"sort:updated-desc",
|
||||||
"--limit",
|
"--limit",
|
||||||
"10",
|
"10",
|
||||||
"--json",
|
"--json",
|
||||||
@@ -3716,6 +4241,7 @@ describe("ForgeService", () => {
|
|||||||
statusCheckRollup: [],
|
statusCheckRollup: [],
|
||||||
reviewDecision: "REVIEW_REQUIRED",
|
reviewDecision: "REVIEW_REQUIRED",
|
||||||
headRepositoryOwner: { login: "chethanuk" },
|
headRepositoryOwner: { login: "chethanuk" },
|
||||||
|
headRepository: { name: "repo" },
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
@@ -3729,6 +4255,7 @@ describe("ForgeService", () => {
|
|||||||
cwd: "/repo",
|
cwd: "/repo",
|
||||||
headRef: "main",
|
headRef: "main",
|
||||||
headRepositoryOwner: "chethanuk",
|
headRepositoryOwner: "chethanuk",
|
||||||
|
headRepositoryPath: "chethanuk/repo",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(status).toMatchObject({
|
expect(status).toMatchObject({
|
||||||
@@ -3746,6 +4273,8 @@ describe("ForgeService", () => {
|
|||||||
"all",
|
"all",
|
||||||
"--head",
|
"--head",
|
||||||
"main",
|
"main",
|
||||||
|
"--search",
|
||||||
|
"sort:updated-desc",
|
||||||
"--limit",
|
"--limit",
|
||||||
"10",
|
"10",
|
||||||
"--json",
|
"--json",
|
||||||
@@ -3762,7 +4291,7 @@ describe("ForgeService", () => {
|
|||||||
name: "repo",
|
name: "repo",
|
||||||
parent: { owner: { login: "parentOwner" }, name: "repo" },
|
parent: { owner: { login: "parentOwner" }, name: "repo" },
|
||||||
}),
|
}),
|
||||||
JSON.stringify([{ number: 88 }]),
|
JSON.stringify([{ number: 88, head: { repo: { full_name: "forkOwner/repo" } } }]),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
number: 88,
|
number: 88,
|
||||||
url: "https://github.com/parentOwner/repo/pull/88",
|
url: "https://github.com/parentOwner/repo/pull/88",
|
||||||
@@ -3775,6 +4304,7 @@ describe("ForgeService", () => {
|
|||||||
statusCheckRollup: [],
|
statusCheckRollup: [],
|
||||||
reviewDecision: null,
|
reviewDecision: null,
|
||||||
headRepositoryOwner: { login: "forkOwner" },
|
headRepositoryOwner: { login: "forkOwner" },
|
||||||
|
headRepository: { name: "repo" },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const service = createGitHubService({
|
const service = createGitHubService({
|
||||||
@@ -4420,6 +4950,49 @@ describe("ForgeService", () => {
|
|||||||
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
|
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 () => {
|
it("bypasses the warm PR status cache for forced reads", async () => {
|
||||||
const runner = createRunner([
|
const runner = createRunner([
|
||||||
currentPullRequestJson({ number: 41, title: "First" }),
|
currentPullRequestJson({ number: 41, title: "First" }),
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
createUnavailableSearchResult,
|
createUnavailableSearchResult,
|
||||||
normalizeForgeSearchKinds,
|
normalizeForgeSearchKinds,
|
||||||
parseOptionalTime,
|
parseOptionalTime,
|
||||||
|
matchesHeadRepositoryEndpoint,
|
||||||
|
ForgeDiscoveryIncompleteError,
|
||||||
} from "./forge-service.js";
|
} from "./forge-service.js";
|
||||||
import type {
|
import type {
|
||||||
CheckAnnotation,
|
CheckAnnotation,
|
||||||
@@ -346,6 +348,8 @@ const CurrentPullRequestStatusSchema = z.object({
|
|||||||
baseRefName: z.string().catch(""),
|
baseRefName: z.string().catch(""),
|
||||||
headRefName: z.string().catch(""),
|
headRefName: z.string().catch(""),
|
||||||
headRefOid: z.string().optional(),
|
headRefOid: z.string().optional(),
|
||||||
|
headRepository: z.object({ name: z.string() }).nullable().optional(),
|
||||||
|
updatedAt: z.string().optional(),
|
||||||
mergedAt: z.string().nullable().optional(),
|
mergedAt: z.string().nullable().optional(),
|
||||||
statusCheckRollup: z.unknown().optional(),
|
statusCheckRollup: z.unknown().optional(),
|
||||||
reviewDecision: z.unknown().optional(),
|
reviewDecision: z.unknown().optional(),
|
||||||
@@ -469,8 +473,13 @@ const GitHubRepoViewSchema = z.object({
|
|||||||
const ForkPullRequestRefsSchema = z.array(
|
const ForkPullRequestRefsSchema = z.array(
|
||||||
z.object({
|
z.object({
|
||||||
number: z.number(),
|
number: z.number(),
|
||||||
|
state: z.string().optional(),
|
||||||
|
updated_at: z.string().optional(),
|
||||||
head: z
|
head: z
|
||||||
.object({ sha: z.string().catch("") })
|
.object({
|
||||||
|
sha: z.string().catch(""),
|
||||||
|
repo: z.object({ full_name: z.string() }).nullable().optional(),
|
||||||
|
})
|
||||||
.nullable()
|
.nullable()
|
||||||
.optional(),
|
.optional(),
|
||||||
}),
|
}),
|
||||||
@@ -524,7 +533,7 @@ query PullRequestCheckoutTarget($owner: String!, $name: String!, $number: Int!)
|
|||||||
}`;
|
}`;
|
||||||
|
|
||||||
const CURRENT_PR_STATUS_BASE_FIELDS =
|
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 CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
|
||||||
|
|
||||||
const PULL_REQUEST_STATUS_FACTS_QUERY = `
|
const PULL_REQUEST_STATUS_FACTS_QUERY = `
|
||||||
@@ -640,6 +649,8 @@ const BatchPollPrNodeSchema = z.object({
|
|||||||
baseRefName: z.string().catch(""),
|
baseRefName: z.string().catch(""),
|
||||||
headRefName: z.string().catch(""),
|
headRefName: z.string().catch(""),
|
||||||
headRefOid: z.string().catch(""),
|
headRefOid: z.string().catch(""),
|
||||||
|
headRepository: z.object({ name: z.string() }).nullable().optional(),
|
||||||
|
updatedAt: z.string().optional(),
|
||||||
mergedAt: z.string().nullable().optional(),
|
mergedAt: z.string().nullable().optional(),
|
||||||
mergeable: PullRequestMergeableSchema.optional().default("UNKNOWN"),
|
mergeable: PullRequestMergeableSchema.optional().default("UNKNOWN"),
|
||||||
reviewDecision: z.unknown().optional(),
|
reviewDecision: z.unknown().optional(),
|
||||||
@@ -656,6 +667,7 @@ const BatchPollPrNodeSchema = z.object({
|
|||||||
|
|
||||||
const BatchPollRepositorySchema = z.object({
|
const BatchPollRepositorySchema = z.object({
|
||||||
owner: z.object({ login: z.string().optional() }).nullable().optional().catch(null),
|
owner: z.object({ login: z.string().optional() }).nullable().optional().catch(null),
|
||||||
|
name: z.string().optional(),
|
||||||
isFork: z.boolean().optional().catch(false),
|
isFork: z.boolean().optional().catch(false),
|
||||||
parent: z
|
parent: z
|
||||||
.object({ owner: z.object({ login: z.string() }), name: z.string() })
|
.object({ owner: z.object({ login: z.string() }), name: z.string() })
|
||||||
@@ -744,12 +756,14 @@ fragment PaseoPollPullRequest on PullRequest {
|
|||||||
baseRefName
|
baseRefName
|
||||||
headRefName
|
headRefName
|
||||||
headRefOid
|
headRefOid
|
||||||
|
updatedAt
|
||||||
mergedAt
|
mergedAt
|
||||||
mergeable
|
mergeable
|
||||||
reviewDecision
|
reviewDecision
|
||||||
headRepositoryOwner {
|
headRepositoryOwner {
|
||||||
login
|
login
|
||||||
}
|
}
|
||||||
|
headRepository { name }
|
||||||
mergeStateStatus
|
mergeStateStatus
|
||||||
autoMergeRequest {
|
autoMergeRequest {
|
||||||
enabledAt
|
enabledAt
|
||||||
@@ -785,6 +799,7 @@ function buildBatchPullRequestStatusQuery(
|
|||||||
owner {
|
owner {
|
||||||
login
|
login
|
||||||
}
|
}
|
||||||
|
name
|
||||||
isFork
|
isFork
|
||||||
parent {
|
parent {
|
||||||
owner {
|
owner {
|
||||||
@@ -799,7 +814,7 @@ function buildBatchPullRequestStatusQuery(
|
|||||||
viewerDefaultMergeMethod
|
viewerDefaultMergeMethod
|
||||||
pullRequests(headRefName: ${graphqlString(
|
pullRequests(headRefName: ${graphqlString(
|
||||||
entry.headRef,
|
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 {
|
nodes {
|
||||||
...PaseoPollPullRequest
|
...PaseoPollPullRequest
|
||||||
}
|
}
|
||||||
@@ -1071,6 +1086,9 @@ interface GitHubPollTarget {
|
|||||||
headRef: string;
|
headRef: string;
|
||||||
headSha?: string;
|
headSha?: string;
|
||||||
headRepositoryOwner?: string;
|
headRepositoryOwner?: string;
|
||||||
|
headRepositoryPath?: string;
|
||||||
|
headRepositoryEndpoint?: import("./forge-service.js").HeadRepositoryEndpoint;
|
||||||
|
changeRequestNumber?: number;
|
||||||
retainCount: number;
|
retainCount: number;
|
||||||
/** Absolute due time on the shared poll grid; null while a poll is in flight. */
|
/** Absolute due time on the shared poll grid; null while a poll is in flight. */
|
||||||
nextDueAt: number | null;
|
nextDueAt: number | null;
|
||||||
@@ -1093,6 +1111,7 @@ interface GitHubBatchPollEntry {
|
|||||||
owner: string;
|
owner: string;
|
||||||
name: string;
|
name: string;
|
||||||
headRepositoryOwner?: string;
|
headRepositoryOwner?: string;
|
||||||
|
canonicalHeadRepositoryPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingBatchPullRequestChecks {
|
interface PendingBatchPullRequestChecks {
|
||||||
@@ -1116,8 +1135,9 @@ interface GitHubBatchDistribution {
|
|||||||
|
|
||||||
interface ResolvedPullRequestCandidate {
|
interface ResolvedPullRequestCandidate {
|
||||||
status: CurrentPullRequestStatus;
|
status: CurrentPullRequestStatus;
|
||||||
headSha?: string;
|
updatedAt?: string;
|
||||||
headRepositoryOwner?: string;
|
headRepositoryOwner?: string;
|
||||||
|
headRepositoryPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGitHubService(options: CreateGitHubServiceOptions = {}): GitHubService {
|
export function createGitHubService(options: CreateGitHubServiceOptions = {}): GitHubService {
|
||||||
@@ -1163,7 +1183,12 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
const frozenPullRequestChecksCache = new Map<string, unknown[]>();
|
const frozenPullRequestChecksCache = new Map<string, unknown[]>();
|
||||||
const pollRepositoryRedirects = new Map<
|
const pollRepositoryRedirects = new Map<
|
||||||
string,
|
string,
|
||||||
{ owner: string; name: string; headRepositoryOwner: string }
|
{
|
||||||
|
owner: string;
|
||||||
|
name: string;
|
||||||
|
headRepositoryOwner: string;
|
||||||
|
canonicalHeadRepositoryPath: string;
|
||||||
|
}
|
||||||
>();
|
>();
|
||||||
const pollPausedUntilByHost = new Map<string, number>();
|
const pollPausedUntilByHost = new Map<string, number>();
|
||||||
const rateLimitResetLoads = new Map<string, Promise<number | null>>();
|
const rateLimitResetLoads = new Map<string, Promise<number | null>>();
|
||||||
@@ -1357,6 +1382,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
headRef: string;
|
headRef: string;
|
||||||
headSha?: string;
|
headSha?: string;
|
||||||
headRepositoryOwner?: string;
|
headRepositoryOwner?: string;
|
||||||
|
headRepositoryPath?: string;
|
||||||
|
headRepositoryEndpoint?: import("./forge-service.js").HeadRepositoryEndpoint;
|
||||||
|
changeRequestNumber?: number;
|
||||||
}): string {
|
}): string {
|
||||||
return buildCacheKey({
|
return buildCacheKey({
|
||||||
cwd: target.cwd,
|
cwd: target.cwd,
|
||||||
@@ -1365,6 +1393,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
headRef: target.headRef,
|
headRef: target.headRef,
|
||||||
headSha: target.headSha,
|
headSha: target.headSha,
|
||||||
headRepositoryOwner: target.headRepositoryOwner,
|
headRepositoryOwner: target.headRepositoryOwner,
|
||||||
|
headRepositoryPath: target.headRepositoryPath,
|
||||||
|
headRepositoryEndpoint: target.headRepositoryEndpoint,
|
||||||
|
changeRequestNumber: target.changeRequestNumber,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1374,6 +1405,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
headRef: string;
|
headRef: string;
|
||||||
headSha?: string;
|
headSha?: string;
|
||||||
headRepositoryOwner?: string;
|
headRepositoryOwner?: string;
|
||||||
|
headRepositoryPath?: string;
|
||||||
|
headRepositoryEndpoint?: import("./forge-service.js").HeadRepositoryEndpoint;
|
||||||
|
changeRequestNumber?: number;
|
||||||
status: CurrentPullRequestStatus | null;
|
status: CurrentPullRequestStatus | null;
|
||||||
notify: boolean;
|
notify: boolean;
|
||||||
}): void {
|
}): void {
|
||||||
@@ -1702,6 +1736,13 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
redirected: [],
|
redirected: [],
|
||||||
};
|
};
|
||||||
for (const [index, entry] of input.entries.entries()) {
|
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 aliasValue = input.aliases[batchAlias(index)];
|
||||||
const repository = aliasValue ? BatchPollRepositorySchema.safeParse(aliasValue) : null;
|
const repository = aliasValue ? BatchPollRepositorySchema.safeParse(aliasValue) : null;
|
||||||
if (!repository?.success) {
|
if (!repository?.success) {
|
||||||
@@ -1722,20 +1763,18 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
owner: repository.data.parent.owner.login,
|
owner: repository.data.parent.owner.login,
|
||||||
name: repository.data.parent.name,
|
name: repository.data.parent.name,
|
||||||
headRepositoryOwner: forkOwner,
|
headRepositoryOwner: forkOwner,
|
||||||
|
canonicalHeadRepositoryPath: `${forkOwner}/${repository.data.name ?? entry.originName}`,
|
||||||
});
|
});
|
||||||
distribution.redirected.push(entry);
|
distribution.redirected.push(entry);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (repository.data.pullRequests.nodes.length >= BATCH_PR_CANDIDATE_LIMIT) {
|
||||||
|
distribution.legacyFallback.push(entry.target);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const node = selectBatchPollNode(entry, repository.data);
|
const node = selectBatchPollNode(entry, repository.data);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
// Legacy fallback covers the two ways "no match" can be wrong: a fork
|
if (shouldRetryUnmatchedBatch(entry, repository.data)) {
|
||||||
// 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
|
|
||||||
) {
|
|
||||||
distribution.legacyFallback.push(entry.target);
|
distribution.legacyFallback.push(entry.target);
|
||||||
} else {
|
} else {
|
||||||
finalizeBatchPollTarget({
|
finalizeBatchPollTarget({
|
||||||
@@ -1748,10 +1787,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const frozenKey =
|
const frozenKey = frozenPullRequestKey(entry, node);
|
||||||
node.state !== "OPEN" && node.headRefOid.length > 0
|
|
||||||
? `${entry.owner}/${entry.name}#${node.number}@${node.headRefOid}`
|
|
||||||
: null;
|
|
||||||
const cachedRollup = frozenKey ? frozenPullRequestChecksCache.get(frozenKey) : undefined;
|
const cachedRollup = frozenKey ? frozenPullRequestChecksCache.get(frozenKey) : undefined;
|
||||||
if (frozenKey && cachedRollup) {
|
if (frozenKey && cachedRollup) {
|
||||||
rememberFrozenPullRequestChecks(frozenKey, cachedRollup);
|
rememberFrozenPullRequestChecks(frozenKey, cachedRollup);
|
||||||
@@ -1774,6 +1810,24 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
return distribution;
|
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 {
|
function addressGitHubBatchEntry(entry: GitHubBatchPollEntry): GitHubBatchPollEntry {
|
||||||
const redirect = pollRepositoryRedirects.get(batchRepositoryRedirectKey(entry));
|
const redirect = pollRepositoryRedirects.get(batchRepositoryRedirectKey(entry));
|
||||||
return redirect
|
return redirect
|
||||||
@@ -1782,6 +1836,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
owner: redirect.owner,
|
owner: redirect.owner,
|
||||||
name: redirect.name,
|
name: redirect.name,
|
||||||
headRepositoryOwner: redirect.headRepositoryOwner,
|
headRepositoryOwner: redirect.headRepositoryOwner,
|
||||||
|
canonicalHeadRepositoryPath: redirect.canonicalHeadRepositoryPath,
|
||||||
}
|
}
|
||||||
: entry;
|
: entry;
|
||||||
}
|
}
|
||||||
@@ -1812,7 +1867,11 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
// name. Without a fork-owner hint the workspace branch lives in this
|
// name. Without a fork-owner hint the workspace branch lives in this
|
||||||
// repository, so only self-owned heads are real candidates (mirrors
|
// repository, so only self-owned heads are real candidates (mirrors
|
||||||
// `gh pr view` resolving the current branch's own PR).
|
// `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;
|
continue;
|
||||||
}
|
}
|
||||||
nodeByCandidate.set(candidate, node);
|
nodeByCandidate.set(candidate, node);
|
||||||
@@ -1821,8 +1880,14 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
const match = pickPullRequestCandidate({
|
const match = pickPullRequestCandidate({
|
||||||
candidates,
|
candidates,
|
||||||
headRef: entry.target.headRef,
|
headRef: entry.target.headRef,
|
||||||
headSha: entry.target.headSha,
|
|
||||||
headRepositoryOwner,
|
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;
|
return match ? (nodeByCandidate.get(match) ?? null) : null;
|
||||||
}
|
}
|
||||||
@@ -1958,6 +2023,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
headRef: entry.target.headRef,
|
headRef: entry.target.headRef,
|
||||||
headSha: entry.target.headSha,
|
headSha: entry.target.headSha,
|
||||||
headRepositoryOwner: entry.target.headRepositoryOwner,
|
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
|
// 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,
|
headRef: entry.target.headRef,
|
||||||
headSha: entry.target.headSha,
|
headSha: entry.target.headSha,
|
||||||
headRepositoryOwner: entry.target.headRepositoryOwner,
|
headRepositoryOwner: entry.target.headRepositoryOwner,
|
||||||
|
headRepositoryPath: entry.target.headRepositoryPath,
|
||||||
|
headRepositoryEndpoint: entry.target.headRepositoryEndpoint,
|
||||||
|
changeRequestNumber: entry.target.changeRequestNumber,
|
||||||
status: existing.value as CurrentPullRequestStatus | null,
|
status: existing.value as CurrentPullRequestStatus | null,
|
||||||
notify: true,
|
notify: true,
|
||||||
});
|
});
|
||||||
@@ -1983,6 +2054,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
headRef: entry.target.headRef,
|
headRef: entry.target.headRef,
|
||||||
headSha: entry.target.headSha,
|
headSha: entry.target.headSha,
|
||||||
headRepositoryOwner: entry.target.headRepositoryOwner,
|
headRepositoryOwner: entry.target.headRepositoryOwner,
|
||||||
|
headRepositoryPath: entry.target.headRepositoryPath,
|
||||||
|
headRepositoryEndpoint: entry.target.headRepositoryEndpoint,
|
||||||
|
changeRequestNumber: entry.target.changeRequestNumber,
|
||||||
status,
|
status,
|
||||||
notify: true,
|
notify: true,
|
||||||
});
|
});
|
||||||
@@ -1995,6 +2069,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
headRef: target.headRef,
|
headRef: target.headRef,
|
||||||
headSha: target.headSha,
|
headSha: target.headSha,
|
||||||
headRepositoryOwner: target.headRepositoryOwner,
|
headRepositoryOwner: target.headRepositoryOwner,
|
||||||
|
headRepositoryPath: target.headRepositoryPath,
|
||||||
|
headRepositoryEndpoint: target.headRepositoryEndpoint,
|
||||||
|
changeRequestNumber: target.changeRequestNumber,
|
||||||
reason: "self-heal-github",
|
reason: "self-heal-github",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2161,6 +2238,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
headRef: input.headRef,
|
headRef: input.headRef,
|
||||||
headSha: input.headSha,
|
headSha: input.headSha,
|
||||||
headRepositoryOwner: input.headRepositoryOwner,
|
headRepositoryOwner: input.headRepositoryOwner,
|
||||||
|
headRepositoryPath: input.headRepositoryPath,
|
||||||
|
headRepositoryEndpoint: input.headRepositoryEndpoint,
|
||||||
|
changeRequestNumber: input.changeRequestNumber,
|
||||||
},
|
},
|
||||||
readOptions: input,
|
readOptions: input,
|
||||||
load: async () => {
|
load: async () => {
|
||||||
@@ -2169,8 +2249,33 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
headRef: input.headRef,
|
headRef: input.headRef,
|
||||||
headSha: input.headSha,
|
headSha: input.headSha,
|
||||||
headRepositoryOwner: input.headRepositoryOwner,
|
headRepositoryOwner: input.headRepositoryOwner,
|
||||||
|
headRepositoryPath: input.headRepositoryPath,
|
||||||
|
originRepositoryPath:
|
||||||
|
input.headRepositoryPath && !input.headRepositoryOwner
|
||||||
|
? await resolveRepoSlugCached(input.cwd)
|
||||||
|
: null,
|
||||||
|
changeRequestNumber: input.changeRequestNumber,
|
||||||
run,
|
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 });
|
return addCurrentPullRequestGithubFacts({ cwd: input.cwd, status, run });
|
||||||
},
|
},
|
||||||
}).then((status) => {
|
}).then((status) => {
|
||||||
@@ -2179,6 +2284,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
headRef: input.headRef,
|
headRef: input.headRef,
|
||||||
headSha: input.headSha,
|
headSha: input.headSha,
|
||||||
headRepositoryOwner: input.headRepositoryOwner,
|
headRepositoryOwner: input.headRepositoryOwner,
|
||||||
|
headRepositoryPath: input.headRepositoryPath,
|
||||||
|
headRepositoryEndpoint: input.headRepositoryEndpoint,
|
||||||
|
changeRequestNumber: input.changeRequestNumber,
|
||||||
status,
|
status,
|
||||||
notify: input.reason === "self-heal-github",
|
notify: input.reason === "self-heal-github",
|
||||||
});
|
});
|
||||||
@@ -2571,6 +2679,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
headRef: input.headRef,
|
headRef: input.headRef,
|
||||||
headSha: input.headSha,
|
headSha: input.headSha,
|
||||||
headRepositoryOwner: input.headRepositoryOwner,
|
headRepositoryOwner: input.headRepositoryOwner,
|
||||||
|
headRepositoryPath: input.headRepositoryPath,
|
||||||
|
headRepositoryEndpoint: input.headRepositoryEndpoint,
|
||||||
|
changeRequestNumber: input.changeRequestNumber,
|
||||||
retainCount: 0,
|
retainCount: 0,
|
||||||
nextDueAt: null,
|
nextDueAt: null,
|
||||||
pollCycleStartedAt: null,
|
pollCycleStartedAt: null,
|
||||||
@@ -2952,60 +3063,160 @@ function isGitHubRateLimitError(error: unknown): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveCurrentPullRequestView(options: {
|
interface CurrentPullRequestLookup {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
headRef: string;
|
headRef: string;
|
||||||
headSha?: string;
|
headSha?: string;
|
||||||
headRepositoryOwner?: string;
|
headRepositoryOwner?: string;
|
||||||
|
headRepositoryPath?: string;
|
||||||
|
originRepositoryPath?: string | null;
|
||||||
|
changeRequestNumber?: number;
|
||||||
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
||||||
}): Promise<CurrentPullRequestStatus | null> {
|
}
|
||||||
const viewCandidate = await tryCurrentPullRequestView(options);
|
|
||||||
const viewMatch = viewCandidate
|
async function resolveCurrentPullRequestView(
|
||||||
? pickPullRequestCandidate({
|
options: CurrentPullRequestLookup,
|
||||||
candidates: [viewCandidate],
|
): Promise<CurrentPullRequestStatus | null> {
|
||||||
headRef: options.headRef,
|
const resolved = await resolveHintedHeadRepository(options);
|
||||||
headSha: options.headSha,
|
return resolved ? resolveCurrentPullRequestForRepository(resolved) : null;
|
||||||
headRepositoryOwner: options.headRepositoryOwner,
|
}
|
||||||
})
|
|
||||||
: null;
|
async function resolveHintedHeadRepository(
|
||||||
if (viewMatch) {
|
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;
|
return viewMatch.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
let headRepositoryOwner = options.headRepositoryOwner;
|
if (!options.headRepositoryOwner) return resolvePullRequestWithoutOwner(options, viewMatch);
|
||||||
|
return resolveListedPullRequest(options, viewMatch, options.headRepositoryOwner);
|
||||||
|
}
|
||||||
|
|
||||||
if (!headRepositoryOwner) {
|
async function resolvePullRequestWithoutOwner(
|
||||||
const repo = await getGitHubRepoView(options);
|
options: CurrentPullRequestLookup,
|
||||||
const forkOwner = repo?.owner?.login;
|
viewMatch: ResolvedPullRequestCandidate | null,
|
||||||
const parentOwner = repo?.parent?.owner?.login;
|
): Promise<CurrentPullRequestStatus | null> {
|
||||||
const parentName = repo?.parent?.name;
|
const repo = await getGitHubRepoView(options);
|
||||||
if (!forkOwner) {
|
const forkOwner = repo?.owner?.login;
|
||||||
return null;
|
const forkName = repo?.name;
|
||||||
}
|
const parentOwner = repo?.parent?.owner?.login;
|
||||||
if (parentOwner && parentName) {
|
const parentName = repo?.parent?.name;
|
||||||
return resolveForkPullRequestView({
|
if (!forkOwner || !forkName) return null;
|
||||||
...options,
|
const headRepositoryPath =
|
||||||
forkOwner,
|
options.headRepositoryPath &&
|
||||||
repo: `${parentOwner}/${parentName}`,
|
options.headRepositoryPath.toLowerCase() !== options.originRepositoryPath?.toLowerCase()
|
||||||
});
|
? options.headRepositoryPath
|
||||||
}
|
: `${forkOwner}/${forkName}`;
|
||||||
headRepositoryOwner = forkOwner;
|
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({
|
const candidates = await listCurrentPullRequestCandidates({
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
headRef: options.headRef,
|
headRef: options.headRef,
|
||||||
run: options.run,
|
run: options.run,
|
||||||
});
|
});
|
||||||
const match = pickPullRequestCandidate({
|
let match = pickPullRequestCandidate({
|
||||||
candidates,
|
candidates: viewMatch ? [viewMatch, ...candidates] : candidates,
|
||||||
headRef: options.headRef,
|
headRef: options.headRef,
|
||||||
headSha: options.headSha,
|
|
||||||
headRepositoryOwner,
|
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;
|
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
|
// 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
|
// 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
|
// the same branch name. `gh pr list --head` cannot ask that question: it
|
||||||
@@ -3019,6 +3230,7 @@ async function resolveForkPullRequestView(options: {
|
|||||||
headRef: string;
|
headRef: string;
|
||||||
headSha?: string;
|
headSha?: string;
|
||||||
forkOwner: string;
|
forkOwner: string;
|
||||||
|
headRepositoryPath?: string;
|
||||||
repo: string;
|
repo: string;
|
||||||
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
||||||
}): Promise<CurrentPullRequestStatus | null> {
|
}): Promise<CurrentPullRequestStatus | null> {
|
||||||
@@ -3038,22 +3250,19 @@ async function resolveForkPullRequestView(options: {
|
|||||||
(candidate): candidate is ResolvedPullRequestCandidate => candidate !== null,
|
(candidate): candidate is ResolvedPullRequestCandidate => candidate !== null,
|
||||||
),
|
),
|
||||||
headRef: options.headRef,
|
headRef: options.headRef,
|
||||||
headSha: options.headSha,
|
|
||||||
headRepositoryOwner: options.forkOwner,
|
headRepositoryOwner: options.forkOwner,
|
||||||
|
headRepositoryPath: options.headRepositoryPath,
|
||||||
});
|
});
|
||||||
return match?.status ?? null;
|
return match?.status ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The endpoint answers with the fork branch's pull requests, newest first, so
|
// The endpoint answers with the fork branch's pull requests, newest first.
|
||||||
// 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.
|
|
||||||
async function listForkPullRequestNumbers(options: {
|
async function listForkPullRequestNumbers(options: {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
headRef: string;
|
headRef: string;
|
||||||
headSha?: string;
|
headSha?: string;
|
||||||
forkOwner: string;
|
forkOwner: string;
|
||||||
|
headRepositoryPath?: string;
|
||||||
repo: string;
|
repo: string;
|
||||||
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
||||||
}): Promise<number[]> {
|
}): Promise<number[]> {
|
||||||
@@ -3061,6 +3270,8 @@ async function listForkPullRequestNumbers(options: {
|
|||||||
state: "all",
|
state: "all",
|
||||||
per_page: String(FORK_PR_PAGE_SIZE),
|
per_page: String(FORK_PR_PAGE_SIZE),
|
||||||
head: `${options.forkOwner}:${options.headRef}`,
|
head: `${options.forkOwner}:${options.headRef}`,
|
||||||
|
sort: "updated",
|
||||||
|
direction: "desc",
|
||||||
});
|
});
|
||||||
const args = ["api", `repos/${options.repo}/pulls?${query.toString()}`];
|
const args = ["api", `repos/${options.repo}/pulls?${query.toString()}`];
|
||||||
const stdout = await options.run(args, { cwd: options.cwd });
|
const stdout = await options.run(args, { cwd: options.cwd });
|
||||||
@@ -3069,12 +3280,22 @@ async function listForkPullRequestNumbers(options: {
|
|||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
emptyFallback: "[]",
|
emptyFallback: "[]",
|
||||||
});
|
});
|
||||||
function carriesHeadSha(ref: (typeof refs)[number]): boolean {
|
const matching = refs
|
||||||
return options.headSha !== undefined && ref.head?.sha === options.headSha;
|
.filter(
|
||||||
}
|
(ref) =>
|
||||||
return [...refs.filter(carriesHeadSha), ...refs.filter((ref) => !carriesHeadSha(ref))]
|
ref.head?.repo?.full_name?.toLowerCase() === options.headRepositoryPath?.toLowerCase(),
|
||||||
.slice(0, FORK_PR_VIEW_LIMIT)
|
)
|
||||||
.map((ref) => ref.number);
|
.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: {
|
async function addCurrentPullRequestGithubFacts(options: {
|
||||||
@@ -3163,7 +3384,18 @@ async function listCurrentPullRequestCandidates(options: {
|
|||||||
headRef: string;
|
headRef: string;
|
||||||
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
||||||
}): Promise<ResolvedPullRequestCandidate[]> {
|
}): 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 {
|
try {
|
||||||
const stdout = await runCurrentPullRequestStatusCommand({
|
const stdout = await runCurrentPullRequestStatusCommand({
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
@@ -3337,11 +3569,13 @@ function toBatchCurrentPullRequestItem(
|
|||||||
baseRefName: node.baseRefName,
|
baseRefName: node.baseRefName,
|
||||||
headRefName: node.headRefName,
|
headRefName: node.headRefName,
|
||||||
...(node.headRefOid ? { headRefOid: node.headRefOid } : {}),
|
...(node.headRefOid ? { headRefOid: node.headRefOid } : {}),
|
||||||
|
...(node.updatedAt ? { updatedAt: node.updatedAt } : {}),
|
||||||
mergedAt: node.mergedAt ?? null,
|
mergedAt: node.mergedAt ?? null,
|
||||||
...(statusCheckRollup ? { statusCheckRollup } : {}),
|
...(statusCheckRollup ? { statusCheckRollup } : {}),
|
||||||
reviewDecision: node.reviewDecision,
|
reviewDecision: node.reviewDecision,
|
||||||
mergeable: node.mergeable,
|
mergeable: node.mergeable,
|
||||||
headRepositoryOwner: node.headRepositoryOwner,
|
headRepositoryOwner: node.headRepositoryOwner,
|
||||||
|
headRepository: node.headRepository,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3354,11 +3588,15 @@ function toCurrentPullRequestCandidate(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const headRepositoryOwner = item.headRepositoryOwner?.login;
|
const headRepositoryOwner = item.headRepositoryOwner?.login;
|
||||||
const headSha = item.headRefOid;
|
const headRepositoryPath =
|
||||||
|
headRepositoryOwner && item.headRepository?.name
|
||||||
|
? `${headRepositoryOwner}/${item.headRepository.name}`
|
||||||
|
: undefined;
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
...(headSha ? { headSha } : {}),
|
updatedAt: item.updatedAt,
|
||||||
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
|
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
|
||||||
|
...(headRepositoryPath ? { headRepositoryPath } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3373,55 +3611,53 @@ function hasResolvedRepoIdentity(status: CurrentPullRequestStatus): boolean {
|
|||||||
function pickPullRequestCandidate(options: {
|
function pickPullRequestCandidate(options: {
|
||||||
candidates: ResolvedPullRequestCandidate[];
|
candidates: ResolvedPullRequestCandidate[];
|
||||||
headRef: string;
|
headRef: string;
|
||||||
headSha?: string;
|
|
||||||
headRepositoryOwner?: string;
|
headRepositoryOwner?: string;
|
||||||
|
headRepositoryPath?: string;
|
||||||
}): ResolvedPullRequestCandidate | null {
|
}): ResolvedPullRequestCandidate | null {
|
||||||
const matching = options.candidates.filter((candidate) => {
|
const matching = options.candidates.filter((candidate) => {
|
||||||
if (!isCandidateForHeadRef(candidate, options.headRef)) {
|
if (!isCandidateForHeadRef(candidate, options.headRef)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
candidate.status.state !== "open" &&
|
options.headRepositoryPath &&
|
||||||
(!options.headSha || candidate.headSha !== options.headSha)
|
candidate.headRepositoryPath?.toLowerCase() !== options.headRepositoryPath.toLowerCase()
|
||||||
) {
|
)
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
if (!options.headRepositoryOwner) {
|
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) =>
|
matching.sort(comparePullRequestCandidatePreference);
|
||||||
comparePullRequestCandidatePreference(left, right, options.headSha),
|
|
||||||
);
|
|
||||||
return matching[0] ?? null;
|
return matching[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function comparePullRequestCandidatePreference(
|
function comparePullRequestCandidatePreference(
|
||||||
left: ResolvedPullRequestCandidate,
|
left: ResolvedPullRequestCandidate,
|
||||||
right: ResolvedPullRequestCandidate,
|
right: ResolvedPullRequestCandidate,
|
||||||
headSha?: string,
|
|
||||||
): number {
|
): number {
|
||||||
const stateRank = getPullRequestStateRank(left.status) - getPullRequestStateRank(right.status);
|
const stateRank = getPullRequestStateRank(left.status) - getPullRequestStateRank(right.status);
|
||||||
if (stateRank !== 0) {
|
if (stateRank !== 0) {
|
||||||
return stateRank;
|
return stateRank;
|
||||||
}
|
}
|
||||||
const leftExact = headSha !== undefined && left.headSha === headSha;
|
return (
|
||||||
const rightExact = headSha !== undefined && right.headSha === headSha;
|
parseOptionalTime(right.updatedAt) - parseOptionalTime(left.updatedAt) ||
|
||||||
if (leftExact !== rightExact) {
|
(right.status.number ?? 0) - (left.status.number ?? 0)
|
||||||
return leftExact ? -1 : 1;
|
);
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPullRequestStateRank(status: CurrentPullRequestStatus): number {
|
function getPullRequestStateRank(status: CurrentPullRequestStatus): number {
|
||||||
if (status.state === "open" || status.isDraft) {
|
if (status.state === "open") {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (status.state === "merged") {
|
return 1;
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GitHubCloneProtocol = "https" | "ssh";
|
type GitHubCloneProtocol = "https" | "ssh";
|
||||||
@@ -3963,6 +4199,7 @@ function toCurrentPullRequestStatus(
|
|||||||
const checks = parseStatusCheckRollup(item.statusCheckRollup);
|
const checks = parseStatusCheckRollup(item.statusCheckRollup);
|
||||||
return {
|
return {
|
||||||
...(typeof item.number === "number" ? { number: item.number } : {}),
|
...(typeof item.number === "number" ? { number: item.number } : {}),
|
||||||
|
...(item.headRefOid ? { headSha: item.headRefOid } : {}),
|
||||||
...(repoIdentity ? { repoOwner: repoIdentity.owner, repoName: repoIdentity.name } : {}),
|
...(repoIdentity ? { repoOwner: repoIdentity.owner, repoName: repoIdentity.name } : {}),
|
||||||
url: item.url,
|
url: item.url,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import type { PullRequestCommandStatus } from "./forge-service.js";
|
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 { GITLAB_ACTIVE_PIPELINE_STATUS_SET } from "@getpaseo/protocol/gitlab-pipeline";
|
||||||
import type { GitLabStatusFacts } from "./gitlab-facts.js";
|
import type { GitLabStatusFacts } from "./gitlab-facts.js";
|
||||||
import {
|
import {
|
||||||
@@ -261,6 +262,114 @@ const DISCUSSIONS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
describe("createGitLabService", () => {
|
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 () => {
|
it("maps a glab merge request view to the neutral current PR status", async () => {
|
||||||
const { service, calls } = makeService((args) =>
|
const { service, calls } = makeService((args) =>
|
||||||
ok(JSON.stringify(args[1] === "list" ? [OPEN_MR] : OPEN_MR)),
|
ok(JSON.stringify(args[1] === "list" ? [OPEN_MR] : OPEN_MR)),
|
||||||
@@ -321,7 +430,7 @@ describe("createGitLabService", () => {
|
|||||||
expect(status).toBeNull();
|
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 branch = "dev";
|
||||||
const checkoutSha = "2222222222222222222222222222222222222222";
|
const checkoutSha = "2222222222222222222222222222222222222222";
|
||||||
const newestStale = {
|
const newestStale = {
|
||||||
@@ -342,7 +451,7 @@ describe("createGitLabService", () => {
|
|||||||
};
|
};
|
||||||
const { service, calls } = makeService((args) => {
|
const { service, calls } = makeService((args) => {
|
||||||
if (args[1] === "list") return ok(JSON.stringify([newestStale, exactOlder]));
|
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("{}");
|
if (args[0] === "api") return ok("{}");
|
||||||
throw new Error(`unexpected call: ${args.join(" ")}`);
|
throw new Error(`unexpected call: ${args.join(" ")}`);
|
||||||
});
|
});
|
||||||
@@ -353,18 +462,159 @@ describe("createGitLabService", () => {
|
|||||||
headSha: checkoutSha,
|
headSha: checkoutSha,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(status?.number).toBe(270);
|
expect(status?.number).toBe(271);
|
||||||
expect(calls[1]).toEqual(["mr", "view", "270", "-F", "json"]);
|
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 = {
|
const stale = {
|
||||||
...OPEN_MR,
|
...OPEN_MR,
|
||||||
state: "merged",
|
state: "merged",
|
||||||
source_branch: "dev",
|
source_branch: "dev",
|
||||||
sha: "1111111111111111111111111111111111111111",
|
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(
|
await expect(
|
||||||
service.getCurrentPullRequestStatus({
|
service.getCurrentPullRequestStatus({
|
||||||
@@ -372,8 +622,8 @@ describe("createGitLabService", () => {
|
|||||||
headRef: "dev",
|
headRef: "dev",
|
||||||
headSha: "2222222222222222222222222222222222222222",
|
headSha: "2222222222222222222222222222222222222222",
|
||||||
}),
|
}),
|
||||||
).resolves.toBeNull();
|
).resolves.toMatchObject({ number: stale.iid, state: "merged" });
|
||||||
expect(calls).toEqual([currentMrListArgs("dev")]);
|
expect(calls[0]).toEqual(currentMrListArgs("dev"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("looks up a numeric current branch through the source-branch list filter", async () => {
|
it("looks up a numeric current branch through the source-branch list filter", async () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { LRUCache } from "lru-cache";
|
||||||
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||||
import { findExecutable } from "../executable-resolution/executable-resolution.js";
|
import { findExecutable } from "../executable-resolution/executable-resolution.js";
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +18,8 @@ import {
|
|||||||
createUnavailableSearchResult,
|
createUnavailableSearchResult,
|
||||||
normalizeForgeSearchKinds,
|
normalizeForgeSearchKinds,
|
||||||
parseOptionalTime,
|
parseOptionalTime,
|
||||||
|
matchesHeadRepositoryEndpoint,
|
||||||
|
ForgeDiscoveryIncompleteError,
|
||||||
} from "./forge-service.js";
|
} from "./forge-service.js";
|
||||||
import type {
|
import type {
|
||||||
CheckDetails,
|
CheckDetails,
|
||||||
@@ -720,6 +723,7 @@ function toCurrentPullRequestStatus(
|
|||||||
const projectPath = extractProjectPath(mr.references?.full);
|
const projectPath = extractProjectPath(mr.references?.full);
|
||||||
return {
|
return {
|
||||||
number: mr.iid,
|
number: mr.iid,
|
||||||
|
...(mr.sha ? { headSha: mr.sha } : {}),
|
||||||
...(owner ? { repoOwner: owner } : {}),
|
...(owner ? { repoOwner: owner } : {}),
|
||||||
...(name ? { repoName: name } : {}),
|
...(name ? { repoName: name } : {}),
|
||||||
...(projectPath ? { projectPath } : {}),
|
...(projectPath ? { projectPath } : {}),
|
||||||
@@ -911,6 +915,15 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
|
|||||||
const runner = options.runner ?? runGlabCommand;
|
const runner = options.runner ?? runGlabCommand;
|
||||||
const resolveGlab = createCachedCliPathResolver(options.resolveGlabPath ?? resolveGlabPath);
|
const resolveGlab = createCachedCliPathResolver(options.resolveGlabPath ?? resolveGlabPath);
|
||||||
const resolveRemoteUrl = options.resolveRemoteUrl ?? defaultResolveRemoteUrl;
|
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> {
|
async function run(args: string[], runOptions: GlabCommandRunnerOptions): Promise<string> {
|
||||||
const glabPath = await resolveGlab();
|
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(
|
async function resolveCurrentMergeRequest(
|
||||||
cwd: string,
|
input: Parameters<ForgeService["getCurrentPullRequestStatus"]>[0],
|
||||||
headRef: string,
|
|
||||||
headSha?: string,
|
|
||||||
): Promise<GitLabMergeRequest | null> {
|
): Promise<GitLabMergeRequest | null> {
|
||||||
|
const { cwd, headRef, headRepositoryEndpoint } = input;
|
||||||
|
const force = input.force === true;
|
||||||
const mergeRequests = await listMergeRequestsBySourceBranch(cwd, headRef);
|
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 =
|
const match =
|
||||||
candidates.find((mr) => mapMergeRequestState(mr.state) === "open") ??
|
candidates.find((mr) => mapMergeRequestState(mr.state) === "open") ?? candidates[0] ?? null;
|
||||||
candidates.find(
|
if (mergeRequests.length >= 100 && (!match || mapMergeRequestState(match.state) !== "open"))
|
||||||
(mr) =>
|
throw new ForgeDiscoveryIncompleteError(
|
||||||
mapMergeRequestState(mr.state) !== "open" && headSha !== undefined && mr.sha === headSha,
|
"GitLab merge request discovery exceeded its candidate window",
|
||||||
) ??
|
);
|
||||||
null;
|
|
||||||
return match ? viewMergeRequest(cwd, String(match.iid)) : null;
|
return match ? viewMergeRequest(cwd, String(match.iid)) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1151,7 +1258,7 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
|
|||||||
|
|
||||||
async getCurrentPullRequestStatus(input): Promise<CurrentPullRequestStatus | null> {
|
async getCurrentPullRequestStatus(input): Promise<CurrentPullRequestStatus | null> {
|
||||||
try {
|
try {
|
||||||
const mr = await resolveCurrentMergeRequest(input.cwd, input.headRef, input.headSha);
|
const mr = await resolveCurrentMergeRequest(input);
|
||||||
if (!mr) {
|
if (!mr) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1448,6 +1555,8 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
invalidate(_input: { cwd: string }): void {},
|
invalidate(_input: { cwd: string }): void {
|
||||||
|
projectPaths.clear();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
chmodSync,
|
||||||
existsSync,
|
existsSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
@@ -54,6 +55,7 @@ function makeSources(bundleSource = "export {};\n"): {
|
|||||||
mkdirSync(native, { recursive: true });
|
mkdirSync(native, { recursive: true });
|
||||||
writeFileSync(path.join(native, "pty.node"), "native");
|
writeFileSync(path.join(native, "pty.node"), "native");
|
||||||
writeFileSync(path.join(native, "spawn-helper"), "#!/bin/sh\n", { mode: 0o755 });
|
writeFileSync(path.join(native, "spawn-helper"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
chmodSync(path.join(native, "spawn-helper"), 0o755);
|
||||||
// Another platform's build is not copied.
|
// Another platform's build is not copied.
|
||||||
mkdirSync(path.join(nodePtyDirectory, "prebuilds", "other-platform"));
|
mkdirSync(path.join(nodePtyDirectory, "prebuilds", "other-platform"));
|
||||||
return { bundleDirectory, nodePtyDirectory };
|
return { bundleDirectory, nodePtyDirectory };
|
||||||
|
|||||||
@@ -1623,13 +1623,26 @@ it.each([1, 4])(
|
|||||||
async (version) => {
|
async (version) => {
|
||||||
const paseoHome = createTemporaryHome();
|
const paseoHome = createTemporaryHome();
|
||||||
const old = await leaveWorkerWithTerminal(paseoHome, version);
|
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 });
|
manager = await startRealWorkerManager({ paseoHome });
|
||||||
|
|
||||||
await waitForCondition(
|
expect(await readUnconfirmedTerminalCleanup(paseoHome)).toEqual([]);
|
||||||
() => !isProcessAlive(old.workerPid) && !isProcessAlive(old.programPid),
|
// Startup launches a replacement worker after cleanup. Either old PID may already
|
||||||
10000,
|
// 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();
|
expect(manager.getTerminal(old.terminalId)).toBeUndefined();
|
||||||
const workers = await workerRecords(paseoHome);
|
const workers = await workerRecords(paseoHome);
|
||||||
expect(workers).toHaveLength(1);
|
expect(workers).toHaveLength(1);
|
||||||
|
|||||||
@@ -47,10 +47,11 @@ import {
|
|||||||
isDescendantPath,
|
isDescendantPath,
|
||||||
warmCheckoutShortstatInBackground,
|
warmCheckoutShortstatInBackground,
|
||||||
} from "./checkout-git.js";
|
} 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 { createForgeResolver } from "../services/forge-resolver.js";
|
||||||
import { GitHubCommandError, GitHubCliMissingError } from "../services/github-service.js";
|
import { GitHubCommandError, GitHubCliMissingError } from "../services/github-service.js";
|
||||||
import type { CurrentPullRequestStatus, ForgeService } from "../services/forge-service.js";
|
import type { CurrentPullRequestStatus, ForgeService } from "../services/forge-service.js";
|
||||||
|
import { ForgeDiscoveryIncompleteError } from "../services/forge-service.js";
|
||||||
import {
|
import {
|
||||||
TeaAuthenticationError,
|
TeaAuthenticationError,
|
||||||
TeaCliMissingError,
|
TeaCliMissingError,
|
||||||
@@ -178,6 +179,10 @@ interface RequestedPullRequestTarget {
|
|||||||
headRef: string;
|
headRef: string;
|
||||||
headSha?: string;
|
headSha?: string;
|
||||||
headRepositoryOwner?: string;
|
headRepositoryOwner?: string;
|
||||||
|
headRepositoryPath?: string;
|
||||||
|
headRepositoryHost?: string;
|
||||||
|
headRepositoryEndpoint?: import("../services/forge-service.js").HeadRepositoryEndpoint;
|
||||||
|
changeRequestNumber?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RecordingPullRequestTargetsOptions {
|
interface RecordingPullRequestTargetsOptions {
|
||||||
@@ -194,6 +199,12 @@ function createGitHubServiceRecordingPullRequestTargets(
|
|||||||
headRef: request.headRef,
|
headRef: request.headRef,
|
||||||
...(request.headSha ? { headSha: request.headSha } : {}),
|
...(request.headSha ? { headSha: request.headSha } : {}),
|
||||||
...(request.headRepositoryOwner ? { headRepositoryOwner: request.headRepositoryOwner } : {}),
|
...(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({
|
return createPullRequestStatus({
|
||||||
...options.statusOverrides,
|
...options.statusOverrides,
|
||||||
@@ -1085,6 +1096,9 @@ const x = 1;
|
|||||||
}
|
}
|
||||||
expect(status.aheadOfOrigin).toBeNull();
|
expect(status.aheadOfOrigin).toBeNull();
|
||||||
expect(status.behindOfOrigin).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 () => {
|
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([
|
it.each([
|
||||||
"git@github.com:contributor/paseo.git",
|
"git@github.com:contributor/paseo.git",
|
||||||
"https://github.com/contributor/paseo.git",
|
"https://github.com/contributor/paseo.git",
|
||||||
@@ -3231,6 +3363,8 @@ const x = 1;
|
|||||||
expect(await readPullRequestLookupTargetFromFacts(repoDir, paseoHome)).toEqual({
|
expect(await readPullRequestLookupTargetFromFacts(repoDir, paseoHome)).toEqual({
|
||||||
headRef: "topic",
|
headRef: "topic",
|
||||||
headRepositoryOwner: "contributor",
|
headRepositoryOwner: "contributor",
|
||||||
|
headRepositoryPath: "contributor/paseo",
|
||||||
|
headRepositoryHost: "github.com:",
|
||||||
headSha,
|
headSha,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3472,6 +3606,22 @@ const x = 1;
|
|||||||
expect(callCount).toBe(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 () => {
|
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", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||||
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
|
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]);
|
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 () => {
|
it("passes forced PR status reads through to the GitHub service", async () => {
|
||||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||||
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
|
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 () => {
|
it("does not use stale PR status fallback for forced GitHub errors", async () => {
|
||||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||||
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
|
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { setImmediate } from "node:timers/promises";
|
import { setImmediate } from "node:timers/promises";
|
||||||
import { TTLCache } from "@isaacs/ttlcache";
|
import { TTLCache } from "@isaacs/ttlcache";
|
||||||
import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages";
|
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 { maxSecuredPlaintextByteLength } from "@getpaseo/relay";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
||||||
@@ -23,8 +23,8 @@ import {
|
|||||||
parseAndHighlightDiff,
|
parseAndHighlightDiff,
|
||||||
parseDiff,
|
parseDiff,
|
||||||
} from "../server/utils/diff-highlighter.js";
|
} from "../server/utils/diff-highlighter.js";
|
||||||
import { parseGitHubRepoFromRemote } from "../server/workspace-git-metadata.js";
|
|
||||||
import { createGitHubService } from "../services/github-service.js";
|
import { createGitHubService } from "../services/github-service.js";
|
||||||
|
import { ForgeDiscoveryIncompleteError } from "../services/forge-service.js";
|
||||||
import type {
|
import type {
|
||||||
CurrentPullRequestStatus,
|
CurrentPullRequestStatus,
|
||||||
ForgeAuthState,
|
ForgeAuthState,
|
||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
ForgeCommandError,
|
ForgeCommandError,
|
||||||
} from "../services/forge-cli-command.js";
|
} from "../services/forge-cli-command.js";
|
||||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.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 { runGitCommand, type RunGitCommand } from "./run-git-command.js";
|
||||||
import { readGitFileContents } from "./git-file-contents.js";
|
import { readGitFileContents } from "./git-file-contents.js";
|
||||||
import { unquoteGitPath } from "./git-quoted-path.js";
|
import { unquoteGitPath } from "./git-quoted-path.js";
|
||||||
@@ -97,12 +98,19 @@ const shortstatInFlight = new Map<string, Promise<CheckoutShortstat | null>>();
|
|||||||
interface CheckoutReadCacheOptions {
|
interface CheckoutReadCacheOptions {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
previousPullRequest?: { number?: number; url: string; state: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PullRequestStatusLookupTarget {
|
interface PullRequestStatusLookupTarget {
|
||||||
headRef: string;
|
headRef: string;
|
||||||
headSha?: string;
|
headSha?: string;
|
||||||
headRepositoryOwner?: string;
|
headRepositoryOwner?: string;
|
||||||
|
headRepositoryPath?: string;
|
||||||
|
headRepositoryHost?: string;
|
||||||
|
headRepositoryEndpoint?: import("../services/forge-service.js").HeadRepositoryEndpoint;
|
||||||
|
headRepositoryRemoteUrl?: string;
|
||||||
|
forgeHost?: string;
|
||||||
|
changeRequestNumber?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PullRequestLookupTargetBranchConfig {
|
interface PullRequestLookupTargetBranchConfig {
|
||||||
@@ -151,8 +159,22 @@ function createShortstatCache(ttlMs: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPullRequestStatusCacheKey(cwd: string, headSha: string | null): string {
|
function getPullRequestStatusCacheKey(
|
||||||
return `${resolve(cwd)}\u0000${headSha ?? ""}`;
|
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 {
|
function rememberPullRequestStatus(cacheKey: string, status: PullRequestStatusResult): void {
|
||||||
@@ -944,6 +966,7 @@ export interface CheckoutContext {
|
|||||||
logger?: Pick<Logger, "trace" | "warn">;
|
logger?: Pick<Logger, "trace" | "warn">;
|
||||||
facts?: CheckoutSnapshotFacts | null;
|
facts?: CheckoutSnapshotFacts | null;
|
||||||
runGitCommand?: RunGitCommand;
|
runGitCommand?: RunGitCommand;
|
||||||
|
resolveSshHostname?: typeof resolveSshHostname;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CheckoutSnapshotFacts =
|
export type CheckoutSnapshotFacts =
|
||||||
@@ -965,6 +988,7 @@ export type CheckoutSnapshotFacts =
|
|||||||
branchRemoteName: string | null;
|
branchRemoteName: string | null;
|
||||||
branchMergeRef: string | null;
|
branchMergeRef: string | null;
|
||||||
upstreamStatus: UpstreamStatus | null;
|
upstreamStatus: UpstreamStatus | null;
|
||||||
|
upstreamKnown?: boolean;
|
||||||
pullRequestLookupTarget: PullRequestStatusLookupTarget | null;
|
pullRequestLookupTarget: PullRequestStatusLookupTarget | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1484,25 +1508,6 @@ async function resolvePullRequestStatusLookupTarget(
|
|||||||
branchMergeRef = await getGitConfigValue(cwd, `branch.${currentBranch}.merge`, context);
|
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([
|
const [branchRemoteUrl, originRemoteUrl, resolvedBaseRef] = await Promise.all([
|
||||||
branchRemoteName ? resolveGitRemoteUrl({ cwd, remote: branchRemoteName, context }) : null,
|
branchRemoteName ? resolveGitRemoteUrl({ cwd, remote: branchRemoteName, context }) : null,
|
||||||
getGitConfigValue(cwd, "remote.origin.url", context),
|
getGitConfigValue(cwd, "remote.origin.url", context),
|
||||||
@@ -1822,7 +1827,7 @@ async function getUpstreamStatus(
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
currentBranch: string,
|
currentBranch: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
): Promise<UpstreamStatus | null> {
|
): Promise<{ status: UpstreamStatus | null; known: boolean }> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await getRunGitCommand(context)(
|
const { stdout } = await getRunGitCommand(context)(
|
||||||
[
|
[
|
||||||
@@ -1834,13 +1839,13 @@ async function getUpstreamStatus(
|
|||||||
);
|
);
|
||||||
const [ref = "", track = ""] = stdout.trim().split("\0", 2);
|
const [ref = "", track = ""] = stdout.trim().split("\0", 2);
|
||||||
if (!ref || track === "gone") {
|
if (!ref || track === "gone") {
|
||||||
return null;
|
return { status: null, known: true };
|
||||||
}
|
}
|
||||||
const ahead = Number.parseInt(track.match(/ahead (\d+)/)?.[1] ?? "0", 10);
|
const ahead = Number.parseInt(track.match(/ahead (\d+)/)?.[1] ?? "0", 10);
|
||||||
const behind = Number.parseInt(track.match(/behind (\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 {
|
} catch {
|
||||||
return null;
|
return { status: null, known: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1889,12 +1894,14 @@ function buildPullRequestLookupTargetFromBranchConfig(
|
|||||||
): PullRequestStatusLookupTarget {
|
): PullRequestStatusLookupTarget {
|
||||||
const trackedHeadRef = parseBranchMergeHeadRef(input.branchMergeRef);
|
const trackedHeadRef = parseBranchMergeHeadRef(input.branchMergeRef);
|
||||||
if (!input.branchRemoteName || !trackedHeadRef) {
|
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 remoteRepo = parseRepositoryIdentityFromRemote(input.branchRemoteUrl);
|
||||||
|
const remoteHost = getRepositoryHost(input.branchRemoteUrl);
|
||||||
const originRepo = parseRepositoryIdentityFromRemote(input.originRemoteUrl);
|
const originRepo = parseRepositoryIdentityFromRemote(input.originRemoteUrl);
|
||||||
const isSameRepo = areSameGitHubRepository(remoteRepo, originRepo);
|
const isSameRepo = areSameRepositoryPath(remoteRepo, originRepo);
|
||||||
const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null;
|
const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null;
|
||||||
const normalizedBaseRef = input.resolvedBaseRef ? branchNameFromRef(input.resolvedBaseRef) : null;
|
const normalizedBaseRef = input.resolvedBaseRef ? branchNameFromRef(input.resolvedBaseRef) : null;
|
||||||
if (
|
if (
|
||||||
@@ -1909,12 +1916,83 @@ function buildPullRequestLookupTargetFromBranchConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isSameRepo) {
|
if (isSameRepo) {
|
||||||
return { headRef: trackedHeadRef };
|
return {
|
||||||
|
headRef: trackedHeadRef,
|
||||||
|
...(remoteRepo ? { headRepositoryPath: remoteRepo } : {}),
|
||||||
|
...(remoteHost ? { headRepositoryHost: remoteHost } : {}),
|
||||||
|
...(input.branchRemoteUrl ? { headRepositoryRemoteUrl: input.branchRemoteUrl } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
headRef: trackedHeadRef,
|
headRef: trackedHeadRef,
|
||||||
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
|
...(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;
|
return null;
|
||||||
}
|
}
|
||||||
const location = parseGitRemoteLocation(remoteUrl);
|
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());
|
return Boolean(left && right && left.toLowerCase() === right.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1949,16 +2027,17 @@ function doesLocalBranchNameIdentifyTrackedHead(
|
|||||||
function buildPullRequestLookupTargetFromPushConfig(
|
function buildPullRequestLookupTargetFromPushConfig(
|
||||||
input: PullRequestLookupTargetPushConfig,
|
input: PullRequestLookupTargetPushConfig,
|
||||||
): PullRequestStatusLookupTarget | null {
|
): PullRequestStatusLookupTarget | null {
|
||||||
const pushedHeadRef = parseHeadPushRefspec(input.pushRefspec);
|
const configuredHeadRef = parseHeadPushRefspec(input.pushRefspec);
|
||||||
if (!input.pushRemoteName || !pushedHeadRef || pushedHeadRef === input.currentBranch) {
|
if (input.pushRefspec && !configuredHeadRef) return null;
|
||||||
|
const pushedHeadRef = configuredHeadRef ?? input.currentBranch;
|
||||||
|
if (!input.pushRemoteName) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteRepo = input.pushRemoteUrl ? parseGitHubRepoFromRemote(input.pushRemoteUrl) : null;
|
const remoteRepo = parseRepositoryIdentityFromRemote(input.pushRemoteUrl);
|
||||||
const originRepo = input.originRemoteUrl
|
const remoteHost = getRepositoryHost(input.pushRemoteUrl);
|
||||||
? parseGitHubRepoFromRemote(input.originRemoteUrl)
|
const originRepo = parseRepositoryIdentityFromRemote(input.originRemoteUrl);
|
||||||
: null;
|
const isSameRepo = areSameRepositoryPath(remoteRepo, originRepo);
|
||||||
const isSameRepo = areSameGitHubRepository(remoteRepo, originRepo);
|
|
||||||
const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null;
|
const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null;
|
||||||
const normalizedBaseRef = input.resolvedBaseRef ? branchNameFromRef(input.resolvedBaseRef) : null;
|
const normalizedBaseRef = input.resolvedBaseRef ? branchNameFromRef(input.resolvedBaseRef) : null;
|
||||||
if (pushedHeadRef === normalizedBaseRef && !headRepositoryOwner) {
|
if (pushedHeadRef === normalizedBaseRef && !headRepositoryOwner) {
|
||||||
@@ -1968,6 +2047,9 @@ function buildPullRequestLookupTargetFromPushConfig(
|
|||||||
return {
|
return {
|
||||||
headRef: pushedHeadRef,
|
headRef: pushedHeadRef,
|
||||||
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
|
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
|
||||||
|
...(remoteRepo ? { headRepositoryPath: remoteRepo } : {}),
|
||||||
|
...(remoteHost ? { headRepositoryHost: remoteHost } : {}),
|
||||||
|
...(input.pushRemoteUrl ? { headRepositoryRemoteUrl: input.pushRemoteUrl } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1982,6 +2064,7 @@ function buildPullRequestLookupTargetFromMetadata(
|
|||||||
return {
|
return {
|
||||||
headRef: target.headRef,
|
headRef: target.headRef,
|
||||||
...(target.headRepositoryOwner ? { headRepositoryOwner: target.headRepositoryOwner } : {}),
|
...(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;
|
cwd: string;
|
||||||
inspected: CheckoutInspectionContext;
|
inspected: CheckoutInspectionContext;
|
||||||
metadata: PaseoWorktreeMetadata | null;
|
metadata: PaseoWorktreeMetadata | null;
|
||||||
@@ -2062,13 +2145,56 @@ async function resolveFactsPullRequestLookupTarget(input: {
|
|||||||
branchRemoteUrl: string | null;
|
branchRemoteUrl: string | null;
|
||||||
resolvedBaseRef: string | null;
|
resolvedBaseRef: string | null;
|
||||||
context?: CheckoutContext;
|
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 { cwd, inspected, metadata, context } = input;
|
||||||
const metadataTarget = inspected.currentBranch
|
const metadataTarget = inspected.currentBranch
|
||||||
? buildPullRequestLookupTargetFromMetadata(metadata, inspected.currentBranch)
|
? buildPullRequestLookupTargetFromMetadata(metadata, inspected.currentBranch)
|
||||||
: null;
|
: null;
|
||||||
if (metadataTarget) {
|
if (metadataTarget && inspected.currentBranch) {
|
||||||
return metadataTarget;
|
return resolveMetadataPullRequestTarget(input, metadataTarget, inspected.currentBranch);
|
||||||
}
|
}
|
||||||
|
|
||||||
let target = buildInitialPullRequestLookupTarget({
|
let target = buildInitialPullRequestLookupTarget({
|
||||||
@@ -2093,6 +2219,10 @@ async function resolveFactsPullRequestLookupTarget(input: {
|
|||||||
context,
|
context,
|
||||||
)) ?? target;
|
)) ?? target;
|
||||||
}
|
}
|
||||||
|
if (target && !target.headRepositoryPath && !target.headRepositoryOwner) {
|
||||||
|
const origin = parseRepositoryIdentityFromRemote(inspected.remoteUrl);
|
||||||
|
if (origin) target = { ...target, headRepositoryPath: origin };
|
||||||
|
}
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2135,7 +2265,7 @@ export async function getCheckoutSnapshotFacts(
|
|||||||
let branchRemoteUrl: string | null = null;
|
let branchRemoteUrl: string | null = null;
|
||||||
const upstreamStatusPromise = inspected.currentBranch
|
const upstreamStatusPromise = inspected.currentBranch
|
||||||
? getUpstreamStatus(cwd, inspected.currentBranch, context)
|
? getUpstreamStatus(cwd, inspected.currentBranch, context)
|
||||||
: Promise.resolve(null);
|
: Promise.resolve({ status: null, known: false });
|
||||||
if (inspected.currentBranch) {
|
if (inspected.currentBranch) {
|
||||||
branchRemoteName = await getGitConfigValue(
|
branchRemoteName = await getGitConfigValue(
|
||||||
cwd,
|
cwd,
|
||||||
@@ -2166,7 +2296,14 @@ export async function getCheckoutSnapshotFacts(
|
|||||||
pullRequestLookupTarget,
|
pullRequestLookupTarget,
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
const upstreamStatus = await upstreamStatusPromise;
|
if (pullRequestLookupTarget) {
|
||||||
|
pullRequestLookupTarget = await normalizePullRequestTargetHost(
|
||||||
|
pullRequestLookupTarget,
|
||||||
|
inspected.remoteUrl,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const upstream = await upstreamStatusPromise;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isGit: true,
|
isGit: true,
|
||||||
@@ -2182,7 +2319,8 @@ export async function getCheckoutSnapshotFacts(
|
|||||||
comparisonBaseRef,
|
comparisonBaseRef,
|
||||||
branchRemoteName,
|
branchRemoteName,
|
||||||
branchMergeRef,
|
branchMergeRef,
|
||||||
upstreamStatus,
|
upstreamStatus: upstream.status,
|
||||||
|
upstreamKnown: upstream.known,
|
||||||
pullRequestLookupTarget,
|
pullRequestLookupTarget,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -4307,14 +4445,49 @@ export async function createPullRequest(
|
|||||||
return result;
|
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(
|
export async function getPullRequestStatus(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
forgeService: ForgeService = createGitHubService(),
|
forgeService: ForgeService = createGitHubService(),
|
||||||
options?: CheckoutReadCacheOptions,
|
options?: CheckoutReadCacheOptions,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
): Promise<PullRequestStatusResult> {
|
): Promise<PullRequestStatusResult> {
|
||||||
const headSha = await getCurrentHeadSha(cwd, context);
|
const unavailable = getUnavailablePullRequestStatus(context?.facts);
|
||||||
const cacheKey = getPullRequestStatusCacheKey(cwd, headSha);
|
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) {
|
if (!options?.force) {
|
||||||
const cached = pullRequestStatusCache.get(cacheKey);
|
const cached = pullRequestStatusCache.get(cacheKey);
|
||||||
if (cached) {
|
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) => {
|
.then((status) => {
|
||||||
pullRequestStatusCache.set(cacheKey, status);
|
pullRequestStatusCache.set(cacheKey, status);
|
||||||
rememberPullRequestStatus(cacheKey, status);
|
rememberPullRequestStatus(cacheKey, status);
|
||||||
return status;
|
return status;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (!options?.force && error instanceof ForgeCommandError) {
|
if (
|
||||||
|
!options?.force &&
|
||||||
|
(error instanceof ForgeCommandError || error instanceof ForgeDiscoveryIncompleteError)
|
||||||
|
) {
|
||||||
const stale = lastSuccessfulPullRequestStatus.get(cacheKey);
|
const stale = lastSuccessfulPullRequestStatus.get(cacheKey);
|
||||||
if (stale) {
|
if (stale) {
|
||||||
return stale;
|
return stale;
|
||||||
@@ -4353,27 +4535,11 @@ export async function getPullRequestStatus(
|
|||||||
async function getPullRequestStatusUncached(
|
async function getPullRequestStatusUncached(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
forgeService: ForgeService,
|
forgeService: ForgeService,
|
||||||
|
lookupTarget: PullRequestStatusLookupTarget,
|
||||||
options?: CheckoutReadCacheOptions,
|
options?: CheckoutReadCacheOptions,
|
||||||
context?: CheckoutContext,
|
previousPullRequest?: { number?: number; url: string; state: string } | null,
|
||||||
headSha?: string | null,
|
|
||||||
): Promise<PullRequestStatusResult> {
|
): 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 {
|
try {
|
||||||
const resolvedLookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head, context);
|
|
||||||
const lookupTarget =
|
|
||||||
headSha && !resolvedLookupTarget.headSha
|
|
||||||
? { ...resolvedLookupTarget, headSha }
|
|
||||||
: resolvedLookupTarget;
|
|
||||||
let status: CurrentPullRequestStatus | null;
|
let status: CurrentPullRequestStatus | null;
|
||||||
if (options?.force) {
|
if (options?.force) {
|
||||||
const reason = options.reason;
|
const reason = options.reason;
|
||||||
@@ -4383,6 +4549,7 @@ async function getPullRequestStatusUncached(
|
|||||||
status = await forgeService.getCurrentPullRequestStatus({
|
status = await forgeService.getCurrentPullRequestStatus({
|
||||||
cwd,
|
cwd,
|
||||||
...lookupTarget,
|
...lookupTarget,
|
||||||
|
...(previousPullRequest ? { previousPullRequest } : {}),
|
||||||
force: true,
|
force: true,
|
||||||
reason,
|
reason,
|
||||||
});
|
});
|
||||||
@@ -4390,6 +4557,7 @@ async function getPullRequestStatusUncached(
|
|||||||
status = await forgeService.getCurrentPullRequestStatus({
|
status = await forgeService.getCurrentPullRequestStatus({
|
||||||
cwd,
|
cwd,
|
||||||
...lookupTarget,
|
...lookupTarget,
|
||||||
|
...(previousPullRequest ? { previousPullRequest } : {}),
|
||||||
reason: options?.reason,
|
reason: options?.reason,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const desktopName =
|
|||||||
async function openHomepage(page: Page) {
|
async function openHomepage(page: Page) {
|
||||||
await page.setViewportSize({ width: 1512, height: 930 });
|
await page.setViewportSize({ width: 1512, height: 930 });
|
||||||
await page.goto("/");
|
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) {
|
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 resizeToPhone(page);
|
||||||
await expect.poll(() => cornerProportion(mockup)).toBeCloseTo(desktopCorner, 3);
|
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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { PLUGINS_LINKED } from "~/plugins/launch";
|
import { PLUGINS_LINKED } from "~/plugins/launch";
|
||||||
|
import { categoryHref } from "~/plugins/links";
|
||||||
|
import type { CategorySlug } from "~/plugins/categories";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -10,7 +12,7 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Gem,
|
Gem,
|
||||||
GitFork,
|
GitFork,
|
||||||
LayoutPanelLeft,
|
GitPullRequest,
|
||||||
Laptop,
|
Laptop,
|
||||||
Lock,
|
Lock,
|
||||||
Merge,
|
Merge,
|
||||||
@@ -19,16 +21,17 @@ import {
|
|||||||
Smartphone,
|
Smartphone,
|
||||||
Terminal,
|
Terminal,
|
||||||
Users,
|
Users,
|
||||||
Workflow,
|
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
motion,
|
motion,
|
||||||
AnimatePresence,
|
AnimatePresence,
|
||||||
useInView,
|
useInView,
|
||||||
|
useReducedMotion,
|
||||||
useScroll,
|
useScroll,
|
||||||
useTransform,
|
useTransform,
|
||||||
type Transition,
|
type Transition,
|
||||||
|
type Variants,
|
||||||
} from "framer-motion";
|
} from "framer-motion";
|
||||||
|
|
||||||
// Shared motion presets — hoisted so every JSX site receives the same object
|
// Shared motion presets — hoisted so every JSX site receives the same object
|
||||||
@@ -86,7 +89,14 @@ import {
|
|||||||
PiIcon,
|
PiIcon,
|
||||||
} from "~/components/agent-icons";
|
} from "~/components/agent-icons";
|
||||||
import { DiscordIcon, GitHubIcon, SlackIcon } from "~/components/brand-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 { FAQItem } from "~/components/faq-item";
|
||||||
import { SiteFooter } from "~/components/site-footer";
|
import { SiteFooter } from "~/components/site-footer";
|
||||||
import { SponsorSection, SponsorsSection } from "~/components/sponsorship";
|
import { SponsorSection, SponsorsSection } from "~/components/sponsorship";
|
||||||
@@ -132,8 +142,8 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
|
|||||||
<SocialProofWall />
|
<SocialProofWall />
|
||||||
<MultiProviderSection />
|
<MultiProviderSection />
|
||||||
<TurnkeySection />
|
<TurnkeySection />
|
||||||
<AutomationSection />
|
|
||||||
<ExtensibleSection />
|
<ExtensibleSection />
|
||||||
|
<AutomationSection />
|
||||||
<SponsorsSection placement="page" />
|
<SponsorsSection placement="page" />
|
||||||
<SponsorSection />
|
<SponsorSection />
|
||||||
<PhilosophySection />
|
<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() {
|
function McpAutomationTranscript() {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div className="space-y-5">
|
<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">
|
<UserMessage>{request}</UserMessage>
|
||||||
<p className="text-sm leading-relaxed text-white/75">
|
<div className="space-y-3">{children}</div>
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function McpAgentCall({ issue, provider }: { issue: string; provider: string }) {
|
function UserMessage({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
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">
|
<div className="ml-auto w-fit max-w-md rounded-xl bg-white/[0.07] px-4 py-3">
|
||||||
<span className="text-sky-300/80">create_agent</span>
|
<p className="text-sm leading-relaxed text-white/80">{children}</p>
|
||||||
<span className="text-white/25">{issue}</span>
|
</div>
|
||||||
<span className="text-white/35">{provider}</span>
|
);
|
||||||
<span className="text-white/25">worktree</span>
|
}
|
||||||
|
|
||||||
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -842,17 +861,16 @@ function ExtensibleSection() {
|
|||||||
title={t("landing.plugins.title")}
|
title={t("landing.plugins.title")}
|
||||||
description={t("landing.plugins.description")}
|
description={t("landing.plugins.description")}
|
||||||
>
|
>
|
||||||
<div className="space-y-8">
|
<div className="space-y-12">
|
||||||
<ProductLead icon={Puzzle} title={t("landing.plugins.lead")} actions={pluginActions} />
|
<SelfModifyDemo />
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="space-y-6">
|
||||||
{PLUGIN_CAPABILITIES.map((capability) => (
|
<ProductLead
|
||||||
<FeatureCard
|
icon={Puzzle}
|
||||||
key={capability.key}
|
title={t("landing.plugins.libraryTitle")}
|
||||||
icon={capability.icon}
|
description={t("landing.plugins.libraryDescription")}
|
||||||
title={t(`landing.plugins.${capability.key}Title`)}
|
actions={pluginActions}
|
||||||
description={t(`landing.plugins.${capability.key}Description`)}
|
/>
|
||||||
/>
|
<PluginCategoryLinks />
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<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>
|
<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
|
const PLUGINS_BROWSE = PLUGINS_LINKED
|
||||||
? { href: "/plugins", external: false }
|
? { href: "/plugins", external: false }
|
||||||
: { href: "https://paseo.cafe", external: true };
|
: { 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 = [
|
const PRINCIPLES = [
|
||||||
{ icon: Lock, key: "private" },
|
{ icon: Lock, key: "private" },
|
||||||
{ icon: Compass, key: "independent" },
|
{ icon: Compass, key: "independent" },
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
AudioLines,
|
AudioLines,
|
||||||
BookOpen,
|
|
||||||
Brain,
|
Brain,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
CircleDot,
|
CircleDot,
|
||||||
Copy,
|
Copy,
|
||||||
FilePenLine,
|
Eye,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
Mic,
|
Mic,
|
||||||
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
Split,
|
Split,
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DiffStat } from "./atoms";
|
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.
|
// Icons passed as props are hoisted so they are not rebuilt on every render.
|
||||||
const MODEL_ICON = <CodexIcon size={13} />;
|
const MODEL_ICON = <CodexIcon size={13} />;
|
||||||
@@ -94,70 +94,62 @@ export function AgentTranscript() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PluginToolCall {
|
/**
|
||||||
kind: "read" | "shell" | "write";
|
* A collapsed tool call as the app's timeline shows it. Built-in tools carry a
|
||||||
label: string;
|
* fixed label; Paseo MCP tools name their action, such as "Create agent".
|
||||||
detail: string;
|
*/
|
||||||
|
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[] = [
|
const PLUGIN_TOOL_CALLS: ToolCall[] = [
|
||||||
{ kind: "read", label: "Read", detail: "paseo-plugin/SKILL.md" },
|
{ kind: "read", detail: "paseo-plugin/SKILL.md" },
|
||||||
{
|
{ kind: "shell", detail: "paseo plugin init ~/dev/release-radar" },
|
||||||
kind: "shell",
|
{ kind: "write", detail: "~/dev/release-radar/index.ts" },
|
||||||
label: "Shell",
|
{ kind: "write", detail: "~/dev/release-radar/release-radar.client.tsx" },
|
||||||
detail: "paseo plugin init ~/dev/release-radar",
|
{ kind: "shell", detail: "npm install && npm run typecheck" },
|
||||||
},
|
{ kind: "shell", detail: "paseo plugin install ~/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_DISCOVERY_CALLS = PLUGIN_TOOL_CALLS.slice(0, 1);
|
const PLUGIN_DISCOVERY_CALLS = PLUGIN_TOOL_CALLS.slice(0, 1);
|
||||||
const PLUGIN_BUILD_CALLS = PLUGIN_TOOL_CALLS.slice(1, 4);
|
const PLUGIN_BUILD_CALLS = PLUGIN_TOOL_CALLS.slice(1, 4);
|
||||||
const PLUGIN_INSTALL_CALLS = PLUGIN_TOOL_CALLS.slice(4);
|
const PLUGIN_INSTALL_CALLS = PLUGIN_TOOL_CALLS.slice(4);
|
||||||
|
|
||||||
function PluginToolIcon({ kind }: { kind: PluginToolCall["kind"] }) {
|
function ToolCallIcon({ kind }: { kind: ToolCall["kind"] }) {
|
||||||
if (kind === "read") return <BookOpen size={14} />;
|
if (kind === "read") return <Eye size={14} />;
|
||||||
if (kind === "write") return <FilePenLine size={14} />;
|
if (kind === "write") return <Pencil size={14} />;
|
||||||
|
if (kind === "paseo") return <PaseoMark size={14} />;
|
||||||
return <SquareTerminal 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 (
|
return (
|
||||||
<div className="flex items-center gap-[9px] py-[5px] text-[12.5px] leading-[18px]">
|
<div className="flex items-center gap-[8px] py-[5px] text-[12.5px] leading-[18px] text-mock-fg-muted">
|
||||||
<span className="text-mock-fg-muted">
|
<span className="shrink-0">
|
||||||
<PluginToolIcon kind={call.kind} />
|
<ToolCallIcon 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}
|
|
||||||
</span>
|
</span>
|
||||||
|
<span className="shrink-0">{toolCallLabel(call)}</span>
|
||||||
|
<span className="min-w-0 truncate">{call.detail}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PluginToolGroup({ calls }: { calls: PluginToolCall[] }) {
|
function PluginToolGroup({ calls }: { calls: ToolCall[] }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{calls.map((call) => (
|
{calls.map((call) => (
|
||||||
<PluginToolRow key={`${call.label}:${call.detail}`} call={call} />
|
<ToolCallRow key={`${call.kind}:${call.detail}`} call={call} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { ClaudeIcon, CodexIcon, SourceControlIcon } from "./icons";
|
export { ClaudeIcon, CodexIcon, SourceControlIcon } from "./icons";
|
||||||
|
export { type ToolCall, ToolCallRow } from "./chat";
|
||||||
export { DESIGN_HEIGHT, DESIGN_WIDTH } from "./geometry";
|
export { DESIGN_HEIGHT, DESIGN_WIDTH } from "./geometry";
|
||||||
export { PhoneFrame } from "./mobile/frame";
|
export { PhoneFrame } from "./mobile/frame";
|
||||||
export { MobileChat } from "./mobile/chat";
|
export { MobileChat } from "./mobile/chat";
|
||||||
|
|||||||
@@ -375,19 +375,18 @@
|
|||||||
"otherHostComment": "# Target another host"
|
"otherHostComment": "# Target another host"
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"title": "Extend it with plugins",
|
"title": "Ask Paseo to change Paseo",
|
||||||
"description": "Add new panels and functionality that work in desktop, web and mobile",
|
"description": "Agents in Paseo can change the whole UI without a restart, even on mobile.",
|
||||||
"lead": "Plugins",
|
"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",
|
"browse": "Browse plugins",
|
||||||
"docs": "Plugin docs",
|
"docs": "Plugin docs",
|
||||||
"license": "Paseo is Apache 2.0.",
|
"license": "Paseo is Apache 2.0.",
|
||||||
"fork": "Fork the repo",
|
"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"
|
|
||||||
},
|
},
|
||||||
"philosophy": {
|
"philosophy": {
|
||||||
"title": "Philosophy",
|
"title": "Philosophy",
|
||||||
@@ -447,10 +446,10 @@
|
|||||||
},
|
},
|
||||||
"mockup": {
|
"mockup": {
|
||||||
"alt": "Paseo desktop app with coding agents, a conversation, and a code diff open side by side",
|
"alt": "Paseo desktop app with coding agents, a conversation, and a code diff open side by side",
|
||||||
"build": "Build",
|
"build": "Parallel agents",
|
||||||
"review": "Review",
|
"review": "Code review",
|
||||||
"ship": "Ship",
|
"ship": "Pull requests",
|
||||||
"extend": "Extend",
|
"extend": "Plugins",
|
||||||
"app": {
|
"app": {
|
||||||
"newWorkspace": "New workspace",
|
"newWorkspace": "New workspace",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
|
|||||||
@@ -375,19 +375,18 @@
|
|||||||
"otherHostComment": "# 指定另一台主机"
|
"otherHostComment": "# 指定另一台主机"
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"title": "用插件扩展",
|
"title": "让 Paseo 修改 Paseo",
|
||||||
"description": "添加新的面板和功能,在桌面端、网页端和移动端都能使用",
|
"description": "Paseo 里的 Agent 可以修改整个界面,无需重启,在移动端也一样。",
|
||||||
"lead": "插件",
|
"request": "在侧边栏加一个面板,列出等待我审查的 PR。",
|
||||||
|
"plan": "我会把它做成本地插件并安装。",
|
||||||
|
"done": "审查队列已出现在你的侧边栏。",
|
||||||
|
"panelTitle": "审查队列",
|
||||||
|
"libraryTitle": "400 多个社区插件",
|
||||||
|
"libraryDescription": "开发者用的是和你的 Agent 相同的插件 API",
|
||||||
"browse": "浏览插件",
|
"browse": "浏览插件",
|
||||||
"docs": "插件文档",
|
"docs": "插件文档",
|
||||||
"license": "Paseo 采用 Apache 2.0 许可证。",
|
"license": "Paseo 采用 Apache 2.0 许可证。",
|
||||||
"fork": "Fork 仓库",
|
"fork": "Fork 仓库"
|
||||||
"uiTitle": "界面",
|
|
||||||
"uiDescription": "页面、工作区面板、侧边栏项目、斜杠命令和主题",
|
|
||||||
"lifecycleTitle": "Agent 生命周期",
|
|
||||||
"lifecycleDescription": "修改配置和 MCP 服务器、响应权限请求、发送后续消息",
|
|
||||||
"providersTitle": "Provider",
|
|
||||||
"providersDescription": "把编程 Agent 添加为 Provider,用法与内置 Provider 相同"
|
|
||||||
},
|
},
|
||||||
"philosophy": {
|
"philosophy": {
|
||||||
"title": "理念",
|
"title": "理念",
|
||||||
@@ -447,10 +446,10 @@
|
|||||||
},
|
},
|
||||||
"mockup": {
|
"mockup": {
|
||||||
"alt": "Paseo 桌面应用,并排打开了编程 Agent、一段对话和一份代码 diff",
|
"alt": "Paseo 桌面应用,并排打开了编程 Agent、一段对话和一份代码 diff",
|
||||||
"build": "构建",
|
"build": "并行 Agent",
|
||||||
"review": "审查",
|
"review": "代码审查",
|
||||||
"ship": "发布",
|
"ship": "Pull Request",
|
||||||
"extend": "扩展",
|
"extend": "插件",
|
||||||
"app": {
|
"app": {
|
||||||
"newWorkspace": "新建工作区",
|
"newWorkspace": "新建工作区",
|
||||||
"history": "历史",
|
"history": "历史",
|
||||||
|
|||||||
@@ -680,51 +680,46 @@ test("macOS jobs use latest runners while preserving release architectures", ()
|
|||||||
assert.match(release, /runner: macos-26-intel\n\s+electron_arch: x64/);
|
assert.match(release, /runner: macos-26-intel\n\s+electron_arch: x64/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("macOS signing repair keeps certificate and keychain passwords separate", () => {
|
test("Nix desktop packaging resolves the workspace-local builder", () => {
|
||||||
const workflow = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8");
|
const source = readFileSync(new URL("nix/desktop-package.nix", repoRoot), "utf8");
|
||||||
const match = workflow.match(/<<'KEYCHAIN_FIX'\n([\s\S]*?)\n KEYCHAIN_FIX/);
|
const invocation = source.match(/^\s+([^\n]*electron-builder) \\\n/m)?.[1];
|
||||||
assert.ok(match, "the release workflow repairs the pinned signing tool");
|
assert.ok(invocation, "Nix invokes the desktop packaging tool");
|
||||||
const script = match[1].replace(/^ /gm, "");
|
const directory = mkdtempSync(join(tmpdir(), "nix-builder-resolution-"));
|
||||||
const original = [
|
|
||||||
"return await importCerts(keychainFile, certPaths, cscPasswords);",
|
|
||||||
"async function importCerts(keychainFile, paths, keyPasswords) {",
|
|
||||||
'["import", paths[i], "-P", password]',
|
|
||||||
'["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]',
|
|
||||||
"}",
|
|
||||||
].join("\n");
|
|
||||||
const directory = mkdtempSync(join(tmpdir(), "signing-repair-"));
|
|
||||||
try {
|
try {
|
||||||
const packageDirectory = join(directory, "node_modules/app-builder-lib");
|
const desktop = join(directory, "packages/desktop");
|
||||||
mkdirSync(join(packageDirectory, "out/codeSign"), { recursive: true });
|
mkdirSync(join(desktop, "node_modules/.bin"), { recursive: true });
|
||||||
const manifest = join(packageDirectory, "package.json");
|
mkdirSync(join(desktop, "node_modules/electron-builder"));
|
||||||
const sourcePath = join(packageDirectory, "out/codeSign/macCodeSign.js");
|
writeFileSync(
|
||||||
for (const [version, source, succeeds] of [
|
join(desktop, "node_modules/electron-builder/package.json"),
|
||||||
["26.8.1", original, true],
|
JSON.stringify({
|
||||||
["26.8.1", original.replace("cscPasswords);", "differentArguments);"), false],
|
name: "electron-builder",
|
||||||
["26.18.0", original, false],
|
version: "1.0.0",
|
||||||
]) {
|
bin: { "electron-builder": "../.bin/electron-builder" },
|
||||||
writeFileSync(manifest, JSON.stringify({ name: "app-builder-lib", version }));
|
}),
|
||||||
writeFileSync(sourcePath, source);
|
);
|
||||||
const result = spawnSync(process.execPath, ["-e", script], {
|
writeFileSync(
|
||||||
cwd: directory,
|
join(directory, "package.json"),
|
||||||
encoding: "utf8",
|
JSON.stringify({ private: true, workspaces: ["packages/desktop"] }),
|
||||||
});
|
);
|
||||||
const actual = readFileSync(sourcePath, "utf8");
|
writeFileSync(
|
||||||
if (succeeds) {
|
join(desktop, "package.json"),
|
||||||
assert.equal(result.status, 0, result.stderr);
|
JSON.stringify({
|
||||||
assert.ok(
|
name: "desktop-fixture",
|
||||||
actual.includes("importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)"),
|
private: true,
|
||||||
);
|
devDependencies: { "electron-builder": "1.0.0" },
|
||||||
assert.ok(
|
}),
|
||||||
actual.includes("importCerts(keychainFile, paths, keyPasswords, keychainPassword)"),
|
);
|
||||||
);
|
writeFileSync(
|
||||||
assert.ok(actual.includes('["import", paths[i], "-P", password]'));
|
join(desktop, "node_modules/.bin/electron-builder"),
|
||||||
assert.ok(actual.includes('"-k", keychainPassword, keychainFile]'));
|
'#!/bin/sh\nprintf "workspace-builder\\n"\n',
|
||||||
} else {
|
{ mode: 0o755 },
|
||||||
assert.notEqual(result.status, 0);
|
);
|
||||||
assert.equal(actual, source, "unexpected tool inputs must fail without a partial write");
|
const result = spawnSync("sh", ["-c", `${invocation} --version`], {
|
||||||
}
|
cwd: desktop,
|
||||||
}
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(result.stdout.trim(), "workspace-builder");
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(directory, { recursive: true, force: true });
|
rmSync(directory, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user