diff --git a/CHANGELOG.md b/CHANGELOG.md index 73eca1d..1052523 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,47 @@ pin and a line to read before you move. ## [Unreleased] +### Added + +- **Send a card to another Paseo host.** The launch dialog gains a host chip, next to the isolation, + whenever more than one host is connected. The board's own host still launches through the daemon; + any other online host is reached with `getPaseoClient` and given the same workspace, agent and + timeline row, through one `launchCard` now shared by both paths. The project there is the one + keyed by the card's repository, and the agent opens on that host. On Paseo before 0.9.0, which + has no `useHosts`, the chip is simply absent. +- **Choose where a worktree starts.** A New worktree send gains a base chip: any of the + repository's branches, read from GitHub, or — on a pull request — the pull request's own branch, + which the daemon checks out instead of branching off the default. The base is requested as the + remote-tracking ref, which the daemon fetches first, so the agent starts at the remote tip rather + than at a local default branch nobody has pulled; on the board's own host the plugin also fetches + the branch itself, from the remote that points at the card's repository (a fork's `upstream`). +- **Send to a checkout that already exists.** Existing checkout, a third isolation beside Local and + New worktree, joins a worktree or branch already on the host as it stands — no new worktree, no + new branch. It lists every `git worktree list` entry on the board's own host and the checkouts + Paseo has a workspace on elsewhere, preselects the one on the pull request's head branch, and + adds the agent to the workspace already open there rather than opening a second one. +- **Check a branch out without forking it.** A New worktree started from a branch gains a Same + branch / New branch chip: Same branch checks that branch itself out (the daemon's + checkout-branch), creating the local branch from the card's repository when there is none yet. +- **The last host and base are remembered**, the host with the other launch defaults and the base + per repository, separately for pull requests and for issues and discussions. + +### Fixed + +- A worktree send no longer forks a branch behind your back. The daemon answers a checkout of a + branch some worktree already holds by cutting `-1` from it, and a pull request whose head + branch is already local by checking it out as `-1`. The dialog now refuses the first, naming + the checkout to choose under Existing checkout, and on the board's own host the second checks out + the local branch itself. + +- The sidebar item, settings screen, command and timeline row used the `Github` Lucide icon, which + newer Paseo apps no longer ship — the plugin failed to load with "Unknown Lucide icon: Github". + They use `GitBranch`. + +### Changed + +- The `@getpaseo/*` development dependencies move to 0.11.1, for the multi-host client API. + ## [1.0.1] — 2026-09-12 ### Changed diff --git a/README.md b/README.md index a627008..34f99d4 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,13 @@ date that answers "what has actually moved". Each card reports the date the list rendered from Markdown, with images proxied through the daemon. From there: approve, merge with any method the repository allows, edit labels, open on GitHub — or **send to chat**, which starts a Paseo agent with a prompt template of your choosing, in a workspace of your choosing, with the -issue or pull request pinned to the top of the conversation. +issue or pull request pinned to the top of the conversation. The agent can run on any connected +Paseo host that has the repository as a project. A new worktree can start from any branch — as a +new branch cut from it, or that branch itself checked out with no new branch on top — or, for a pull +request, from the pull request's own branch. Or skip the new worktree entirely: **Existing +checkout** joins a worktree or branch already on the host, whoever made it, and preselects the one +on the pull request's branch. The last host and, per repository, the last base are what the next +card opens on. ![One pull request open in the detail panel: title, author, branches, checks, the Approve, Merge, Send to chat and Open on GitHub actions, and the rendered body.](images/detail-panel.png) @@ -86,6 +92,13 @@ inputs.paseo-github.url = "github:alysnnix/paseo-github-integration"; - **GitHub search backs the sweep**, so its rules apply: results are capped per query, and a watched owner with thousands of open items shows the most recently updated slice rather than all of them. +- **Another host is matched by `origin` alone.** On the board's own host a fork is found through + its `upstream` remote too; on any other host the project's `origin` has to be the card's + repository, and a base branch has to have been fetched there at least once. There, too, + Existing checkout lists only the checkouts Paseo has a workspace open on — a worktree another tool + made appears once Paseo has opened it — and a pull request whose branch is already local but not + checked out is checked out as Paseo's `-1` copy; choose the branch with Same branch + instead. - **One GitHub account at a time**, the one `gh` is authenticated as. There is no account switcher. - **Images hosted outside GitHub are not loaded**, deliberately: they would report your IP address to whoever wrote the comment. They render as a link you can open yourself. diff --git a/client/board/github-board.tsx b/client/board/github-board.tsx index 3ca2152..36d1559 100644 --- a/client/board/github-board.tsx +++ b/client/board/github-board.tsx @@ -190,7 +190,8 @@ export function GitHubBoard(props: PluginSurfaceProps) { key={sendTarget.item.id} item={sendTarget.item} initialPrompt={sendTarget.prompt} - hostLabel={props.host.label} + pullRequest={sendTarget.pullRequest} + boardHost={props.host} styles={styles} accentColor={props.theme.colors.accent} onCancel={() => setSendTarget(null)} diff --git a/client/board/use-board-overlays.tsx b/client/board/use-board-overlays.tsx index b7e16dc..d729dcf 100644 --- a/client/board/use-board-overlays.tsx +++ b/client/board/use-board-overlays.tsx @@ -13,6 +13,13 @@ import type { Styles } from "../theme/use-styles"; import { LABEL_MENU_MAX_HEIGHT, LABEL_MENU_WIDTH, MENU_MARGIN } from "./label-menu"; import type { LabelMenuTarget } from "./label-menu"; +/** The card the launch dialog is open on, its rendered prompt, and whether it is a pull request. */ +export interface SendTarget { + item: BoardItem; + prompt: string; + pullRequest: boolean; +} + /** * What `useBoardOverlays` needs from the other board hooks, threaded through * rather than reached for. @@ -38,8 +45,8 @@ export interface UseBoardOverlaysResult { labelTarget: LabelMenuTarget | null; setLabelTarget: (next: LabelMenuTarget | null) => void; applyItemLabels: (itemId: string, labels: string[]) => void; - sendTarget: { item: BoardItem; prompt: string } | null; - setSendTarget: (next: { item: BoardItem; prompt: string } | null) => void; + sendTarget: SendTarget | null; + setSendTarget: (next: SendTarget | null) => void; handleLaunched: (result: LaunchResult) => void; rootRef: React.RefObject; showSettings: boolean; @@ -65,7 +72,7 @@ export function useBoardOverlays( /** The surface shows one of two things; plugins cannot route between surfaces. */ const [showSettings, setShowSettings] = useState(false); /** The card the launch dialog is open on, with its prompt already rendered. */ - const [sendTarget, setSendTarget] = useState<{ item: BoardItem; prompt: string } | null>(null); + const [sendTarget, setSendTarget] = useState(null); /** The card the label menu is open on, and where on this surface to draw it. */ const [labelTarget, setLabelTarget] = useState(null); /** The card the detail panel is open on. Its column picks the send template. */ @@ -211,7 +218,11 @@ export function useBoardOverlays( const projectId = board?.repositoryProjects[item.repository] ?? null; const template = promptValues === null ? item.url : templateFor(promptValues, type, projectId); - setSendTarget({ item, prompt: renderTemplate(template, item) }); + setSendTarget({ + item, + prompt: renderTemplate(template, item), + pullRequest: type === "draft-prs" || type === "open-prs", + }); }, [board, promptValues], ); @@ -237,12 +248,16 @@ export function useBoardOverlays( const handleLaunched = useCallback( (result: LaunchResult) => { setSendTarget(null); - toast.show(`Created “${result.workspaceName}” in ${result.projectName}. Opening it…`, { + const where = + result.serverId === props.host.id ? result.projectName : `${result.projectName} on ${result.hostLabel}`; + toast.show(`Created “${result.workspaceName}” in ${where}. Opening it…`, { variant: "success", }); - props.navigation?.openAgent({ agentId: result.agentId }); + // `serverId` routes to the host the agent actually runs on, which is not + // this surface's own when the dialog sent the card elsewhere. + props.navigation?.openAgent({ agentId: result.agentId, serverId: result.serverId }); }, - [props.navigation, toast], + [props.host.id, props.navigation, toast], ); const renderRow = useCallback( diff --git a/client/launch/isolation-control.tsx b/client/launch/isolation-control.tsx index 86791c5..da57a22 100644 --- a/client/launch/isolation-control.tsx +++ b/client/launch/isolation-control.tsx @@ -3,46 +3,115 @@ import { View } from "react-native"; import type { Isolation } from "../../shared/board"; import type { Styles } from "../theme/use-styles"; import { ChoicePopover, ControlChip, ISOLATION_CHOICES } from "./pickers"; +import type { Choice, PickerId } from "./pickers"; +import { BRANCH_MODE_CHOICES } from "./use-base-branch"; import type { SendProject } from "./use-send-dialog"; +/** One chip and the menu it opens, as the row below lays them out. */ +export interface ChipMenu { + choices: readonly Choice[]; + selectedId: string | null; + label: string; + onSelect: (id: string) => void; +} + /** - * Where the workspace is cut: the dialog's first control row, and one of its - * four popovers. A non-git project has no worktree to cut, so the chip reads - * Local and the press is disabled rather than offering a choice that fails. + * Where the workspace is cut: the dialog's first control row. It holds the host + * the card is sent to — shown only when more than one is connected, so a + * single-host setup sees the dialog it always had — the isolation, and what + * that isolation needs next: for a new worktree, the branch it starts from and, + * when that is a branch, whether a new branch is cut from it or it is checked + * out itself; for an existing checkout, which one. A non-git project has + * neither to offer, so that chip reads Local and the press is disabled rather + * than offering a choice that fails. * * Its own row, out-stacking the scrim so its popover stays clickable — the - * row raises above the second control row's while its own popover is open. + * row raises above the second control row's while one of its popovers is open. */ export function IsolationControl({ styles, project, isolation, - open, - onToggle, - onSelect, + picker, + onTogglePicker, + onSelectIsolation, + host, + base, + branchMode, + checkout, }: { styles: Styles; project: SendProject | null; isolation: Isolation; - open: boolean; - onToggle: () => void; - onSelect: (id: string) => void; + picker: PickerId | null; + onTogglePicker: (id: PickerId) => void; + onSelectIsolation: (id: string) => void; + host: ChipMenu; + base: ChipMenu; + /** Null unless the worktree starts from a branch. */ + branchMode: ChipMenu | null; + checkout: ChipMenu; }) { + const chips: { id: PickerId; menu: ChipMenu; shown: boolean }[] = [ + { id: "base", menu: base, shown: isolation === "worktree" }, + { + id: "branch-mode", + menu: branchMode ?? { choices: BRANCH_MODE_CHOICES, selectedId: null, label: "", onSelect: () => {} }, + shown: isolation === "worktree" && branchMode !== null, + }, + { id: "checkout", menu: checkout, shown: isolation === "existing" }, + ]; + const raised = picker === "host" || picker === "isolation" || chips.some((chip) => chip.id === picker); return ( - + + {host.choices.length > 1 ? ( + onTogglePicker("host")} /> + ) : null} choice.id === isolation)?.label ?? "Local"} disabled={project === null || !project.supportsWorktree} - onPress={onToggle} + onPress={() => onTogglePicker("isolation")} /> - {open ? ( + {picker === "isolation" ? ( + ) : null} + {chips.map((chip) => + chip.shown ? ( + onTogglePicker(chip.id)} + /> + ) : null, + )} + {chips.map((chip) => + chip.shown && picker === chip.id ? ( + + ) : null, + )} + {picker === "host" ? ( + ) : null} diff --git a/client/launch/pickers.tsx b/client/launch/pickers.tsx index a0588d7..7bc6d1b 100644 --- a/client/launch/pickers.tsx +++ b/client/launch/pickers.tsx @@ -25,14 +25,27 @@ export interface ProviderChoice extends Choice { } /** Which control's popover is open; only ever one at a time. */ -export type PickerId = "isolation" | "model" | "thinking" | "mode"; +export type PickerId = + | "host" + | "isolation" + | "base" + | "branch-mode" + | "checkout" + | "model" + | "thinking" + | "mode"; export const ISOLATION_CHOICES: readonly Choice[] = [ { id: "local", label: "Local", description: "Work in the project's own checkout." }, { id: "worktree", label: "New worktree", - description: "Cut a fresh git worktree on its own branch.", + description: "Cut a fresh git worktree, on a new branch or an existing one.", + }, + { + id: "existing", + label: "Existing checkout", + description: "Join a worktree or branch already on the host, as it stands.", }, ]; diff --git a/client/launch/send-dialog.tsx b/client/launch/send-dialog.tsx index ebe388b..67e84e7 100644 --- a/client/launch/send-dialog.tsx +++ b/client/launch/send-dialog.tsx @@ -25,10 +25,10 @@ export { readProviders, resolveConfiguration, toChoice }; * card: where the workspace is cut, which agent runs it, how hard it thinks, * which permission mode it runs under, and the first message. * - * The host is not a choice here. A plugin surface is bound to the daemon that - * contributed it — `usePaseo()` is that daemon's client and nothing reaches - * another one — and the board itself is that host's `gh`. Switching hosts is the - * surface header's job, so the dialog names the host rather than offering it. + * The host is a choice too, when more than one is connected. The board itself + * stays bound to the daemon that contributed it — the cards are that host's + * `gh` — but the agent can run on any online host that has the repository as a + * project; `useLaunchTarget` resolves which, and where the project is there. * * The dialog's own state — the project, the provider snapshot, the settled * configuration, which popover is open, the launch call — lives in @@ -38,7 +38,8 @@ export { readProviders, resolveConfiguration, toChoice }; export function SendDialog({ item, initialPrompt, - hostLabel, + pullRequest, + boardHost, styles, accentColor, onCancel, @@ -47,19 +48,29 @@ export function SendDialog({ item: BoardItem; /** The card's prompt template, already rendered. The user may rewrite it. */ initialPrompt: string; - hostLabel: string; + /** Whether the card is a pull request, which can start on its own branch. */ + pullRequest: boolean; + /** The host the board belongs to, and the dialog's default target. */ + boardHost: { id: string; label: string }; styles: Styles; accentColor: string; onCancel: () => void; onLaunched: (result: LaunchResult) => void; }) { - const dialog = useSendDialogState({ item, initialPrompt, onCancel, onLaunched }); + const dialog = useSendDialogState({ + item, + pullRequest, + boardHost, + initialPrompt, + onCancel, + onLaunched, + }); return ( - {hostLabel} + {dialog.hostLabel} {dialog.project === null ? "" : ` · ${dialog.project.name}`} @@ -70,9 +81,13 @@ export function SendDialog({ styles={styles} project={dialog.project} isolation={dialog.isolation} - open={dialog.picker === "isolation"} - onToggle={() => dialog.togglePicker("isolation")} - onSelect={dialog.selectIsolation} + picker={dialog.picker} + onTogglePicker={dialog.togglePicker} + onSelectIsolation={dialog.selectIsolation} + host={dialog.host} + base={dialog.base} + branchMode={dialog.branchMode} + checkout={dialog.checkout} /> {/* The host's input, not React Native's: it registers focus with the diff --git a/client/launch/use-base-branch.tsx b/client/launch/use-base-branch.tsx new file mode 100644 index 0000000..1b3c0a6 --- /dev/null +++ b/client/launch/use-base-branch.tsx @@ -0,0 +1,196 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRpc } from "@getpaseo/plugin/client"; + +import type { BaseChoice, BoardItem } from "../../shared/board"; +import { listBranches } from "../../shared/board"; +import type { WorktreeStart } from "../../shared/launch"; +import type { ChipMenu } from "./isolation-control"; +import type { Choice } from "./pickers"; + +/** The menu id of "this pull request's own branch"; branches are listed by name. */ +const PULL_REQUEST_ID = ":pull-request"; + +/** What a worktree started from a branch does with it: cut a new one, or take it as is. */ +export const BRANCH_MODE_CHOICES: readonly Choice[] = [ + { id: "branch-off", label: "New branch", description: "Cut a new branch from it, as Paseo does." }, + { + id: "checkout", + label: "Same branch", + description: "Check that branch itself out, with no new branch on top.", + }, +]; + +/** + * The start a *remote* host is asked for. Its project was matched by its own + * key, which Paseo takes from `origin`, so `origin` is the remote that points at + * the card's repository. The board's own host resolves this server-side + * instead, where it can see a fork's `upstream` and fetch first. + */ +export function remoteStartFor(base: BaseChoice | null): WorktreeStart { + if (base === null) return { kind: "default" }; + if (base.kind === "pull-request") return { kind: "pull-request" }; + return base.checkout === true + ? { kind: "branch", branch: base.name } + : { kind: "ref", ref: `refs/remotes/origin/${base.name}` }; +} + +/** The branch-mode chip's menu, or null while there is no branch to take as is. */ +export function branchModeMenu( + mode: BaseBranch["branchMode"], + onSelect: (id: string) => void, +): ChipMenu | null { + if (mode === null) return null; + const label = BRANCH_MODE_CHOICES.find((choice) => choice.id === mode)?.label ?? ""; + return { choices: BRANCH_MODE_CHOICES, selectedId: mode, label, onSelect }; +} + +function branchChoice(name: string, checkout: boolean): BaseChoice { + return checkout ? { kind: "branch", name, checkout: true } : { kind: "branch", name }; +} + +/** What one base reads as: its menu id, its branch mode, and the branch it would check out. */ +function describeBase(base: BaseChoice | null, pullRequestHead: string | null) { + if (base === null) return { baseId: null, branchMode: null, checkoutBranch: null }; + if (base.kind === "pull-request") { + return { baseId: PULL_REQUEST_ID, branchMode: null, checkoutBranch: pullRequestHead }; + } + const checkout = base.checkout === true; + return { + baseId: base.name, + branchMode: checkout ? ("checkout" as const) : ("branch-off" as const), + checkoutBranch: checkout ? base.name : null, + }; +} + +/** What `useBaseBranch` exposes to the dialog. */ +export interface BaseBranch { + /** Null until the branch list has loaded, and if it could not be; the daemon then picks. */ + base: BaseChoice | null; + baseId: string | null; + label: string; + choices: readonly Choice[]; + selectBase: (id: string) => void; + /** Null unless the base is a branch, the only start that can be cut or taken as is. */ + branchMode: "branch-off" | "checkout" | null; + selectBranchMode: (id: string) => void; + /** The pull request's head branch, when it is in the card's own repository. */ + pullRequestHead: string | null; + /** + * The branch this start would check out rather than cut, which no worktree + * may already hold: the pull request's head, or a branch taken as is. + */ + checkoutBranch: string | null; +} + +/** + * Where a new worktree starts, for one card: its own branch for a pull request, + * otherwise a branch of its repository — whatever this repository was last sent + * with for this kind of card, else the pull request's branch or the default. + * + * The list is GitHub's, read once per dialog by the board's own daemon, so it + * is the same whichever host the card ends up on. A remembered branch that no + * longer exists, or has fallen out of the hundred most recent, falls back to + * the default rather than failing the send. + */ +export function useBaseBranch({ + item, + pullRequest, +}: { + item: BoardItem; + pullRequest: boolean; +}): BaseBranch { + const load = useRpc(listBranches); + const [listed, setListed] = useState<{ + defaultBranch: string | null; + branches: string[]; + remembered: BaseChoice | null; + pullRequestHead: string | null; + } | null>(null); + const [chosen, setChosen] = useState(null); + + useEffect(() => { + let cancelled = false; + load({ repository: item.repository, url: item.url, pullRequest, number: item.number }) + .then((result) => { + if (!cancelled) setListed(result); + }) + .catch((cause: unknown) => { + // Not fatal: without a list the worktree starts where the daemon would + // have started it before this menu existed. + console.warn("[github-board] could not list branches", cause); + }); + return () => { + cancelled = true; + }; + }, [item.number, item.repository, item.url, load, pullRequest]); + + const choices = useMemo(() => { + if (listed === null) return []; + const branches = listed.branches.map((name) => ({ + id: name, + label: `From ${name}`, + description: name === listed.defaultBranch ? "Default branch" : null, + })); + return pullRequest + ? [ + { + id: PULL_REQUEST_ID, + label: `PR #${item.number} branch`, + description: "Check out this pull request's own branch", + }, + ...branches, + ] + : branches; + }, [item.number, listed, pullRequest]); + + const offered = useCallback( + (choice: BaseChoice | null): choice is BaseChoice => + choice !== null && + (choice.kind === "pull-request" + ? pullRequest + : (listed?.branches.includes(choice.name) ?? false)), + [listed, pullRequest], + ); + + let base: BaseChoice | null = null; + if (listed !== null) { + const fallback: BaseChoice | null = pullRequest + ? { kind: "pull-request" } + : listed.defaultBranch === null + ? null + : { kind: "branch", name: listed.defaultBranch }; + base = offered(chosen) ? chosen : offered(listed.remembered) ? listed.remembered : fallback; + } + + const pullRequestHead = listed?.pullRequestHead ?? null; + const { baseId, branchMode, checkoutBranch } = describeBase(base, pullRequestHead); + + // A new base keeps the branch mode the old one had, so the toggle reads as a + // setting of the chip beside it rather than resetting under the user. + const checkout = branchMode === "checkout"; + const selectBase = useCallback( + (id: string) => setChosen(id === PULL_REQUEST_ID ? { kind: "pull-request" } : branchChoice(id, checkout)), + [checkout], + ); + + const selectBranchMode = useCallback( + (id: string) => { + if (base?.kind === "branch") setChosen(branchChoice(base.name, id === "checkout")); + }, + [base], + ); + + // Taken as is, the chip names the branch itself rather than "From" it. + const listedLabel = choices.find((choice) => choice.id === baseId)?.label ?? "Default branch"; + return { + base, + baseId, + label: checkout && baseId !== null ? baseId : listedLabel, + choices, + selectBase, + branchMode, + selectBranchMode, + pullRequestHead, + checkoutBranch, + }; +} diff --git a/client/launch/use-checkouts.tsx b/client/launch/use-checkouts.tsx new file mode 100644 index 0000000..40d3d84 --- /dev/null +++ b/client/launch/use-checkouts.tsx @@ -0,0 +1,161 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRpc } from "@getpaseo/plugin/client"; + +import type { BoardItem, Checkout, Isolation } from "../../shared/board"; +import { listCheckouts } from "../../shared/board"; +import { checkedOutConflict, listProjectCheckouts } from "../../shared/launch"; +import type { Choice } from "./pickers"; +import type { HostApi, SendProject } from "./use-launch-target"; + +/** What `useCheckouts` exposes to the dialog. */ +export interface Checkouts { + /** Null until the selected host has answered. */ + checkouts: readonly Checkout[] | null; + /** The checkout an Existing send would join; null while there is none to offer. */ + selected: Checkout | null; + choices: readonly Choice[]; + label: string; + select: (path: string) => void; +} + +/** + * The checkouts of the card's project on the selected host, for an Existing + * send to join and for a new worktree to be checked against. + * + * The board's own host answers with every `git worktree list` entry, made by + * whatever made it. Any other host is read through its API, which sees only the + * checkouts Paseo has a workspace open on: a worktree another tool cut there + * shows up once Paseo has opened it, and not before. + * + * Preselected: the checkout already on the pull request's head branch, which is + * the one a card is most likely being sent back to; otherwise the main + * checkout, the one choice that means the same thing on every card. + */ +export function useCheckouts({ + item, + project, + api, + isRemote, + hostId, + pullRequestHead, +}: { + item: BoardItem; + project: SendProject | null; + api: HostApi | null; + isRemote: boolean; + hostId: string; + pullRequestHead: string | null; +}): Checkouts { + const load = useRpc(listCheckouts); + const [listed, setListed] = useState<{ hostId: string; checkouts: Checkout[] } | null>(null); + const [chosen, setChosen] = useState(null); + + const projectId = project?.id ?? null; + const rootPath = project?.rootPath ?? null; + const supportsWorktree = project?.supportsWorktree === true; + useEffect(() => { + setChosen(null); + if (projectId === null || rootPath === null || !supportsWorktree) return undefined; + if (isRemote && api === null) return undefined; + let cancelled = false; + const lookup = + !isRemote || api === null + ? load({ repository: item.repository, url: item.url }).then((result) => result.checkouts) + : listProjectCheckouts(api, { projectId, rootPath }, null); + lookup + .then((checkouts) => { + if (!cancelled) setListed({ hostId, checkouts }); + }) + .catch((cause: unknown) => { + // Not fatal: Existing simply has nothing to offer, and the other two + // isolations never needed the list. + console.warn("[github-board] could not list checkouts", cause); + if (!cancelled) setListed({ hostId, checkouts: [] }); + }); + return () => { + cancelled = true; + }; + }, [api, hostId, isRemote, item.repository, item.url, load, projectId, rootPath, supportsWorktree]); + + // An answer for the previously selected host is not this host's answer. + const checkouts = listed?.hostId === hostId ? listed.checkouts : null; + + const selected = useMemo(() => { + if (checkouts === null || checkouts.length === 0) return null; + return ( + checkouts.find((checkout) => checkout.path === chosen) ?? + (pullRequestHead === null ? undefined : checkouts.find((checkout) => checkout.branch === pullRequestHead)) ?? + checkouts.find((checkout) => checkout.main) ?? + checkouts[0] ?? + null + ); + }, [checkouts, chosen, pullRequestHead]); + + const choices = useMemo( + () => + (checkouts ?? []).map((checkout) => ({ + id: checkout.path, + label: checkoutLabel(checkout), + description: [ + checkout.main ? "Main checkout" : null, + checkout.branch !== null && checkout.branch === pullRequestHead ? "This pull request's branch" : null, + checkout.workspaceName === null ? null : `Workspace “${checkout.workspaceName}”`, + checkout.path, + ] + .filter((part): part is string => part !== null) + .join(" · "), + })), + [checkouts, pullRequestHead], + ); + + const select = useCallback((path: string) => setChosen(path), []); + + return { + checkouts, + selected, + choices, + label: selected === null ? (checkouts === null ? "Checkouts…" : "No checkouts") : checkoutLabel(selected), + select, + }; +} + +/** + * What the chosen isolation needs from the checkouts before a send can go. + * + * `error` is why it cannot go as configured, before it is tried: a new worktree + * that would check out a branch some checkout already holds — which the daemon + * answers by quietly forking it — or an Existing send with nothing to join. + * The board's own host checks the first again with git at send time; on any + * other host this is the only check there is. `existing` is the checkout an + * Existing send joins, and `ready` is false while one is still being looked up. + */ +export function sendTarget({ + isolation, + checkouts, + checkoutBranch, + project, + hostLabel, +}: { + isolation: Isolation; + checkouts: Checkouts; + checkoutBranch: string | null; + /** The project and host, as the message names them. */ + project: SendProject | null; + hostLabel: string; +}): { error: string | null; ready: boolean; existing: Checkout | null } { + if (isolation === "worktree") { + const error = checkedOutConflict(checkouts.checkouts ?? [], checkoutBranch); + return { error, ready: error === null, existing: null }; + } + if (isolation === "local") return { error: null, ready: true, existing: null }; + const { selected } = checkouts; + const error = + checkouts.checkouts !== null && selected === null + ? `${project?.name ?? "This project"} has no checkout on ${hostLabel} that Paseo can see.` + : null; + return { error, ready: selected !== null, existing: selected }; +} + +function checkoutLabel(checkout: Checkout): string { + return checkout.branch ?? `Detached at ${checkout.path.split("/").pop() ?? checkout.path}`; +} diff --git a/client/launch/use-launch-target.tsx b/client/launch/use-launch-target.tsx new file mode 100644 index 0000000..601eec0 --- /dev/null +++ b/client/launch/use-launch-target.tsx @@ -0,0 +1,199 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import * as pluginClient from "@getpaseo/plugin/client"; +import { usePaseo, useRpc } from "@getpaseo/plugin/client"; + +import type { BoardItem } from "../../shared/board"; +import { sendOptions } from "../../shared/board"; +import { findProjectByKey, launchCard, repositoryIdFor } from "../../shared/launch"; +import type { LaunchInput, LaunchOutcome } from "../../shared/launch"; +import type { Choice } from "./pickers"; + +/** + * `useHosts` and `getPaseoClient` arrived in Paseo 0.9.0, and this plugin still + * loads on 0.8. The app hands a plugin its SDK as a plain module object, so on + * an older app they are simply absent: the dialog then offers the board's own + * host only, which is what it did before the picker existed. + */ +const multiHost = pluginClient as Partial; +const getPaseoClient = multiHost.getPaseoClient; +const NO_HOSTS: readonly never[] = []; +const useHosts = multiHost.useHosts ?? (() => NO_HOSTS); + +/** A host's whole API, as both `usePaseo()` and `getPaseoClient()` return it. */ +export type HostApi = ReturnType; + +export interface SendProject { + id: string; + name: string; + rootPath: string; + supportsWorktree: boolean; +} + +/** What `useLaunchTarget` exposes: where the card goes, and the project it lands in there. */ +export interface LaunchTarget { + /** Every host a card can be sent to right now; one entry hides the chip. */ + hosts: readonly Choice[]; + hostId: string; + hostLabel: string; + /** False for the host this board belongs to, whose daemon runs the send itself. */ + isRemote: boolean; + /** The selected host's API, or null while a remote one cannot be reached. */ + api: HostApi | null; + selectHost: (id: string) => void; + project: SendProject | null; + projectError: string | null; +} + +/** + * Which Paseo host a card is sent to, and the project it lands in there. + * + * The board is still bound to the daemon that contributed it — the cards are + * that host's `gh` — but the *send* need not be: since Paseo 0.11 a plugin + * client can borrow any online host's API with `getPaseoClient`, the same + * `PaseoApi` the daemon hands `sendToChatHandler`. So the board's own host keeps + * its RPC path, which can match a fork through its `upstream` remote, and any + * other host is asked directly for a project keyed by the card's repository. + * + * Offered hosts are the online ones plus the board's own, which is online by + * construction — the surface could not have rendered otherwise. The last host a + * card was sent to is preselected while it is still online, and the board's + * host otherwise, the same "a preference, not a promise" rule the model follows. + */ +export function useLaunchTarget({ + item, + boardHost, + savedHostId, +}: { + item: BoardItem; + boardHost: { id: string; label: string }; + /** Undefined until the saved defaults have loaded; null for the board's host. */ + savedHostId: string | null | undefined; +}): LaunchTarget { + const local = usePaseo(); + const loadOptions = useRpc(sendOptions); + const configured = useHosts(); + + const hosts = useMemo(() => { + const online = configured.filter( + (host) => host.status === "online" && host.serverId !== boardHost.id, + ); + return [ + { id: boardHost.id, label: boardHost.label, description: "This board's host" }, + ...online.map((host) => ({ id: host.serverId, label: host.label, description: null })), + ]; + }, [boardHost.id, boardHost.label, configured]); + + const [chosen, setChosen] = useState(null); + const savedOnline = + savedHostId === undefined || savedHostId === null + ? undefined + : hosts.find((host) => host.id === savedHostId)?.id; + // A host that went offline after it was picked falls back to the board's. + const hostId = hosts.find((host) => host.id === chosen)?.id ?? savedOnline ?? boardHost.id; + const isRemote = hostId !== boardHost.id; + const hostLabel = hosts.find((host) => host.id === hostId)?.label ?? boardHost.label; + + const [remote, setRemote] = useState<{ api: HostApi | null; error: string | null }>({ + api: null, + error: null, + }); + useEffect(() => { + if (!isRemote || getPaseoClient === undefined) return; + try { + setRemote({ api: getPaseoClient(hostId), error: null }); + } catch (cause: unknown) { + const detail = cause instanceof Error ? cause.message : String(cause); + setRemote({ api: null, error: `${hostLabel} cannot be reached: ${detail}` }); + } + }, [hostId, hostLabel, isRemote]); + const api = isRemote ? remote.api : local; + + const [resolved, setResolved] = useState<{ + hostId: string; + project: SendProject | null; + error: string | null; + } | null>(null); + + useEffect(() => { + // Nothing is resolved until the saved host is known, or the dialog would + // look the card up on the board's host only to throw the answer away. + if (savedHostId === undefined) return undefined; + if (isRemote && api === null) return undefined; + let cancelled = false; + const settle = (project: SendProject | null, error: string | null) => { + if (!cancelled) setResolved({ hostId, project, error }); + }; + const lookup: Promise = + !isRemote || api === null + ? loadOptions({ repository: item.repository, url: item.url }).then((result) => result.project) + : findRemoteProject(api, item, hostLabel); + lookup + .then((project) => settle(project, null)) + .catch((cause: unknown) => settle(null, cause instanceof Error ? cause.message : String(cause))); + return () => { + cancelled = true; + }; + }, [api, hostId, hostLabel, isRemote, item, loadOptions, savedHostId]); + + const selectHost = useCallback((id: string) => setChosen(id), []); + + // An answer for the previously selected host is not this host's answer. + const current = resolved?.hostId === hostId ? resolved : null; + return { + hosts, + hostId, + hostLabel, + isRemote, + api, + selectHost, + project: current?.project ?? null, + projectError: (isRemote ? remote.error : null) ?? current?.error ?? null, + }; +} + +async function findRemoteProject(api: HostApi, item: BoardItem, hostLabel: string): Promise { + const repositoryId = repositoryIdFor(item.repository, item.url); + if (repositoryId === null) { + throw new Error(`${item.repository} has no repository URL to match a project against.`); + } + const project = await findProjectByKey(api, repositoryId); + if (project === undefined) { + throw new Error( + `No project on ${hostLabel} has ${item.repository} as its git remote. Add it as a project there, then send this card again.`, + ); + } + return { + id: project.projectId, + name: project.displayName, + rootPath: project.rootPath, + supportsWorktree: project.kind === "git", + }; +} + +/** + * A send to a host other than the board's, run from the dialog against that + * host's own API — its project as `useLaunchTarget` found it — and then the + * board's daemon told what to open on next. That save is not fatal: the agent + * is already running. + */ +export async function launchOnRemoteHost( + api: HostApi, + project: SendProject, + input: LaunchInput, + remember: () => Promise, +): Promise { + const result = await launchCard( + api, + { + projectId: project.id, + rootPath: project.rootPath, + displayName: project.name, + kind: project.supportsWorktree ? "git" : "directory", + }, + input, + ); + await remember().catch((cause: unknown) => { + console.warn("[github-board] could not save launch defaults", cause); + }); + return result; +} diff --git a/client/launch/use-send-dialog.tsx b/client/launch/use-send-dialog.tsx index ef91178..ee1dfa7 100644 --- a/client/launch/use-send-dialog.tsx +++ b/client/launch/use-send-dialog.tsx @@ -1,11 +1,17 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { usePaseo, useRpc } from "@getpaseo/plugin/client"; +import { useRpc } from "@getpaseo/plugin/client"; import { useToast } from "@getpaseo/plugin/client/react-native"; import { Keyboard } from "react-native"; import type { BoardItem, Isolation, LaunchDefaults } from "../../shared/board"; -import { sendOptions, sendToChat } from "../../shared/board"; +import { launchDefaults, saveLaunchDefaults, sendToChat } from "../../shared/board"; +import type { ChipMenu } from "./isolation-control"; import type { Choice, ModelChoice, PickerId, ProviderChoice } from "./pickers"; +import { branchModeMenu, remoteStartFor, useBaseBranch } from "./use-base-branch"; +import { sendTarget, useCheckouts } from "./use-checkouts"; +import { type HostApi, launchOnRemoteHost, type SendProject, useLaunchTarget } from "./use-launch-target"; + +export type { SendProject }; /** * One entry of the host's provider snapshot — every provider it knows about, @@ -14,7 +20,7 @@ import type { Choice, ModelChoice, PickerId, ProviderChoice } from "./pickers"; * may only import the host-provided modules, which `@getpaseo/protocol` is not. */ export type ProviderEntry = Awaited< - ReturnType["providers"]["snapshot"]> + ReturnType >["entries"][number]; /** What one agent is launched with, once every field has been checked against the host. */ @@ -26,7 +32,7 @@ export interface Configuration { } /** A configuration as it was *asked for*: any field may be missing or stale. */ -export type DesiredConfiguration = Omit; +export type DesiredConfiguration = Omit; export function toChoice(option: { id: string; @@ -103,18 +109,14 @@ export function resolveConfiguration( }; } -export interface SendProject { - id: string; - name: string; - rootPath: string; - supportsWorktree: boolean; -} - export interface LaunchResult { workspaceId: string; workspaceName: string; projectName: string; agentId: string; + /** The host the agent runs on, so the app can open it there. */ + serverId: string; + hostLabel: string; } /** What `useSendDialogState` exposes to the dialog's own layout. */ @@ -122,6 +124,12 @@ export interface UseSendDialogResult { prompt: string; setPrompt: (next: string) => void; project: SendProject | null; + hostLabel: string; + host: ChipMenu; + base: ChipMenu; + /** Null unless a new worktree starts from a branch, which can be cut or taken as is. */ + branchMode: ChipMenu | null; + checkout: ChipMenu; error: string | null; busy: boolean; picker: PickerId | null; @@ -154,26 +162,30 @@ export interface UseSendDialogResult { */ export function useSendDialogState({ item, + pullRequest, + boardHost, initialPrompt, onCancel, onLaunched, }: { item: BoardItem; + pullRequest: boolean; + /** The host the board belongs to, as the surface's props name it. */ + boardHost: { id: string; label: string }; initialPrompt: string; onCancel: () => void; onLaunched: (result: LaunchResult) => void; }): UseSendDialogResult { - const paseo = usePaseo(); - const loadOptions = useRpc(sendOptions); + const loadDefaults = useRpc(launchDefaults); + const saveDefaults = useRpc(saveLaunchDefaults); const launch = useRpc(sendToChat); const toast = useToast(); const [prompt, setPrompt] = useState(initialPrompt); - const [project, setProject] = useState(null); const [defaults, setDefaults] = useState(null); const [entries, setEntries] = useState(null); const [configuration, setConfiguration] = useState(null); - const [isolation, setIsolation] = useState("local"); + const [chosenIsolation, setIsolation] = useState("local"); const [picker, setPicker] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -224,15 +236,14 @@ export function useSendDialogState({ [busy, initialPrompt, onCancel, picker, prompt, toast], ); - // Which project this card belongs to, and what the last send was set to. + // What the last send was set to, including the host it went to. useEffect(() => { let cancelled = false; - loadOptions({ repository: item.repository, url: item.url }) + loadDefaults({}) .then((result) => { if (cancelled) return; - setProject(result.project); - setDefaults(result.defaults); - setIsolation(result.defaults.isolation); + setDefaults(result); + setIsolation(result.isolation); }) .catch((cause: unknown) => { if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause)); @@ -240,7 +251,34 @@ export function useSendDialogState({ return () => { cancelled = true; }; - }, [item.repository, item.url, loadOptions]); + }, [loadDefaults]); + + const target = useLaunchTarget({ + item, + boardHost, + savedHostId: defaults === null ? undefined : defaults.hostId, + }); + const { project, api: hostApi } = target; + const baseBranch = useBaseBranch({ item, pullRequest }); + const checkouts = useCheckouts({ + item, + project, + api: hostApi, + isRemote: target.isRemote, + hostId: target.hostId, + pullRequestHead: baseBranch.pullRequestHead, + }); + // A worktree preference does not survive into a project with none to offer, + // or the dialog would show a choice its own picker cannot. + const isolation: Isolation = project?.supportsWorktree === true ? chosenIsolation : "local"; + + const sendable = sendTarget({ + isolation, + checkouts, + checkoutBranch: baseBranch.checkoutBranch, + project, + hostLabel: target.hostLabel, + }); /** * The snapshot is taken against the project's checkout rather than the @@ -249,9 +287,11 @@ export function useSendDialogState({ */ const cwd = project?.rootPath ?? null; useEffect(() => { - if (cwd === null) return undefined; + // A new host offers its own providers, so the old host's list goes first. + setEntries(null); + if (cwd === null || hostApi === null) return undefined; let cancelled = false; - paseo.providers + hostApi.providers .snapshot({ cwd }) .then((snapshot) => { if (!cancelled) setEntries(snapshot.entries); @@ -262,7 +302,7 @@ export function useSendDialogState({ // Discovery is lazy on the daemon, so a provider still loading when the // snapshot was taken arrives later as an update rather than as a second // reply. An update for another directory is not ours to adopt. - const unsubscribe = paseo.providers.subscribe((update) => { + const unsubscribe = hostApi.providers.subscribe((update) => { if (cancelled) return; if (update.cwd !== undefined && update.cwd !== cwd) return; setEntries(update.entries); @@ -271,7 +311,7 @@ export function useSendDialogState({ cancelled = true; unsubscribe(); }; - }, [cwd, paseo]); + }, [cwd, hostApi]); const providers = useMemo(() => (entries === null ? null : readProviders(entries)), [entries]); @@ -314,7 +354,7 @@ export function useSendDialogState({ ); const selectIsolation = useCallback((id: string) => { - setIsolation(id === "worktree" ? "worktree" : "local"); + setIsolation(id === "worktree" || id === "existing" ? id : "local"); setPicker(null); }, []); @@ -328,14 +368,47 @@ export function useSendDialogState({ setPicker(null); }, []); + const selectBase = useCallback( + (id: string) => { + baseBranch.selectBase(id); + setPicker(null); + }, + [baseBranch], + ); + + const selectBranchMode = useCallback( + (id: string) => { + baseBranch.selectBranchMode(id); + setPicker(null); + }, + [baseBranch], + ); + + const selectCheckout = useCallback( + (path: string) => { + checkouts.select(path); + setPicker(null); + }, + [checkouts], + ); + + const selectHost = useCallback( + (id: string) => { + target.selectHost(id); + setError(null); + setPicker(null); + }, + [target], + ); + const send = useCallback(() => { - if (configuration === null || busy) return; + if (configuration === null || project === null || busy || !sendable.ready) return; const text = prompt.trim(); if (text === "") return; setBusy(true); setError(null); setPicker(null); - launch({ + const input = { repository: item.repository, number: item.number, title: item.title, @@ -350,8 +423,22 @@ export function useSendDialogState({ model: configuration.model, modeId: configuration.modeId, thinkingOptionId: configuration.thinkingOptionId, - }) - .then(onLaunched) + }; + const hostId = target.isRemote ? target.hostId : null; + const base = isolation === "worktree" ? baseBranch.base : null; + const { existing } = sendable; + const sent = + !target.isRemote || hostApi === null + ? launch({ ...input, hostId, pullRequest, pullRequestHead: baseBranch.pullRequestHead, base, existing }) + : launchOnRemoteHost(hostApi, project, { ...input, start: remoteStartFor(base), existing }, () => + // The board's daemon never saw this send, so it is told what to open on next. + saveDefaults({ + launch: { ...configuration, isolation, hostId }, + base: base === null ? null : { repository: item.repository, url: item.url, pullRequest, choice: base }, + }), + ); + sent + .then((result) => onLaunched({ ...result, serverId: target.hostId, hostLabel: target.hostLabel })) .catch((cause: unknown) => { // Left open on failure, with everything still typed in: the workspace // may or may not exist, but the user's prompt certainly should not be @@ -359,9 +446,13 @@ export function useSendDialogState({ setBusy(false); setError(cause instanceof Error ? cause.message : String(cause)); }); - }, [busy, configuration, isolation, item, launch, onLaunched, prompt]); + }, [baseBranch.base, baseBranch.pullRequestHead, busy, configuration, hostApi, isolation, item, launch, onLaunched, project, prompt, pullRequest, saveDefaults, sendable, target]); - const ready = configuration !== null && project !== null && prompt.trim() !== ""; + const ready = + configuration !== null && + project !== null && + prompt.trim() !== "" && + sendable.ready; const thinkingOptions = model?.thinkingOptions ?? []; const modes = provider?.modes ?? []; @@ -369,7 +460,17 @@ export function useSendDialogState({ prompt, setPrompt, project, - error, + hostLabel: target.hostLabel, + host: { choices: target.hosts, selectedId: target.hostId, label: target.hostLabel, onSelect: selectHost }, + base: { choices: baseBranch.choices, selectedId: baseBranch.baseId, label: baseBranch.label, onSelect: selectBase }, + branchMode: branchModeMenu(baseBranch.branchMode, selectBranchMode), + checkout: { + choices: checkouts.choices, + selectedId: checkouts.selected?.path ?? null, + label: checkouts.label, + onSelect: selectCheckout, + }, + error: error ?? target.projectError ?? sendable.error, busy, picker, togglePicker, diff --git a/client/timeline.tsx b/client/timeline.tsx index df86ca4..7de9ab4 100644 --- a/client/timeline.tsx +++ b/client/timeline.tsx @@ -81,7 +81,7 @@ export function BoardTimelineCard({ onPress={() => openExternalUrl(url)} > - + {repository} #{number} diff --git a/index.client.tsx b/index.client.tsx index f43c224..20ffc01 100644 --- a/index.client.tsx +++ b/index.client.tsx @@ -26,13 +26,13 @@ export default function contribute(client: PluginClientContext) { client.addSidebarItem({ id: "board", title: "GitHub", - icon: "Github", + icon: "GitBranch", surface: "board", }); client.addSettingsScreen({ id: "board", title: "GitHub board", - icon: "Github", + icon: "GitBranch", Component: BoardSettingsScreen, }); client.addCommandCenterItem({ @@ -48,7 +48,7 @@ export default function contribute(client: PluginClientContext) { client.addCommandCenterItem({ id: "open-board", title: "Open GitHub board", - icon: "Github", + icon: "GitBranch", keywords: ["github", "issues", "pull requests", "prs", "discussions"], context: "global", onSelect(context) { diff --git a/index.server.ts b/index.server.ts index 31eef75..66ee2f6 100644 --- a/index.server.ts +++ b/index.server.ts @@ -6,7 +6,14 @@ import { loadCommentsHandler } from "./server/items/comments"; import { approveHandler, mergeHandler } from "./server/items/review"; import { loadItemHandler } from "./server/items/details"; import { loadImageHandler } from "./server/images/images"; -import { sendOptionsHandler, sendToChatHandler } from "./server/launch/handler"; +import { + launchDefaultsHandler, + listCheckoutsHandler, + saveLaunchDefaultsHandler, + sendOptionsHandler, + sendToChatHandler, +} from "./server/launch/handler"; +import { listBranchesHandler } from "./server/launch/branches"; import { listProjectsHandler } from "./server/projects/list"; import { loadProjectHandler } from "./server/projects/single"; import { @@ -23,8 +30,12 @@ import { loadImage, loadItem, loadProject, + launchDefaults, legacySettingsTaken, + listBranches, + listCheckouts, mergePullRequest, + saveLaunchDefaults, saveLogin, takeLegacySettings, sendOptions, @@ -45,6 +56,10 @@ export default function contribute(server: PluginServerContext) { server.handle(legacySettingsTaken, legacySettingsTakenHandler); server.handle(sendOptions, sendOptionsHandler); server.handle(sendToChat, sendToChatHandler); + server.handle(launchDefaults, launchDefaultsHandler); + server.handle(saveLaunchDefaults, saveLaunchDefaultsHandler); + server.handle(listBranches, listBranchesHandler); + server.handle(listCheckouts, listCheckoutsHandler); server.handle(listLabels, listLabelsHandler); server.handle(toggleLabel, toggleLabelHandler); server.handle(approvePullRequest, approveHandler); diff --git a/package-lock.json b/package-lock.json index 722cb84..49f7dab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "paseo-github-integration", - "version": "0.5.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo-github-integration", - "version": "0.5.0", + "version": "1.0.1", "dependencies": { - "@getpaseo/plugin": "0.8.0-beta.1" + "@getpaseo/plugin": "0.11.1" }, "devDependencies": { - "@getpaseo/client": "0.8.0-beta.1", - "@getpaseo/protocol": "0.8.0-beta.1", + "@getpaseo/client": "0.11.1", + "@getpaseo/protocol": "0.11.1", "@tanstack/react-query": "^5.102.8", "@types/node": "^26.5.0", "@types/react": "~19.2.0", @@ -522,26 +522,26 @@ } }, "node_modules/@getpaseo/client": { - "version": "0.8.0-beta.1", - "resolved": "https://registry.npmjs.org/@getpaseo/client/-/client-0.8.0-beta.1.tgz", - "integrity": "sha512-+JHqeg4eCWYa3TqMNN1FLaDWpoXM6PrPArOpzfV/Qm+2Mhw/bA07QS+zjMzDpVz7QZ+z6eN/96qAbQ1eFOD6Zg==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@getpaseo/client/-/client-0.11.1.tgz", + "integrity": "sha512-heuP3W1iVQp7ra41+4/HtGTs1JNA1gBrL0QnztMjqj/qeOk5DUT5VUIylv218+cVf0tcaEKiVETCTQCJ6p/O/g==", "dependencies": { - "@getpaseo/protocol": "0.8.0-beta.1", - "@getpaseo/relay": "0.8.0-beta.1", + "@getpaseo/protocol": "0.11.1", + "@getpaseo/relay": "0.11.1", "zod": "^4.4.3" } }, "node_modules/@getpaseo/plugin": { - "version": "0.8.0-beta.1", - "resolved": "https://registry.npmjs.org/@getpaseo/plugin/-/plugin-0.8.0-beta.1.tgz", - "integrity": "sha512-7/4cAfd3+GNB8rEYytFbp+Z8x/QfXlLIGMa8pcdn2YgrQUuO8Hjc3L9CGan3IoAYva/ttQn9biCOvvVWcDmMoQ==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@getpaseo/plugin/-/plugin-0.11.1.tgz", + "integrity": "sha512-OVekDrwjh4TJXdWLqAA+BfhixFSPHGpnJ/7SqludG3c6L/ucNaatezLiCyAoYx50ynHpc3RnbbxHIIGsGlGAdg==", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { - "@getpaseo/client": "0.8.0-beta.1", - "@getpaseo/protocol": "0.8.0-beta.1", + "@getpaseo/client": "0.11.1", + "@getpaseo/protocol": "0.11.1", "react": "~19.1.0", "react-native": ">=0.81.5", "zod": "^4.4.3" @@ -553,18 +553,18 @@ } }, "node_modules/@getpaseo/protocol": { - "version": "0.8.0-beta.1", - "resolved": "https://registry.npmjs.org/@getpaseo/protocol/-/protocol-0.8.0-beta.1.tgz", - "integrity": "sha512-7GQfNTe8cGgWPQDvY4F6CwEe75HdtPB7d+XQ+SZYRWM4IGkQhgXPM6hGFaDqut9LAweKNYllttA2FaDAJmmo+g==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@getpaseo/protocol/-/protocol-0.11.1.tgz", + "integrity": "sha512-L3/wI9/lDGfcY/0JEkbOt2pQ/pQzEziVd4sNvFap90eo5ItU6Nf+IIznJFKnJkhaDG0Pono8kmugqspgbostxQ==", "dependencies": { "semver": "^7.8.5", "zod": "^4.4.3" } }, "node_modules/@getpaseo/relay": { - "version": "0.8.0-beta.1", - "resolved": "https://registry.npmjs.org/@getpaseo/relay/-/relay-0.8.0-beta.1.tgz", - "integrity": "sha512-aZGmgbQEU/DgPCW/zOdKce//nWiuMU1N5ikMG1q0z2acRQNPwdAok9fQO3iuNIcTENF/32azpWqgsr/qeMp5gA==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@getpaseo/relay/-/relay-0.11.1.tgz", + "integrity": "sha512-H2VFDdRRTR11RyGOZH1uoN1g24ZqlktPLq/0YYAryLNlmrRCIxu+z1dv3M0R6/sV6Tje/1ejxi7lGpQA2Pvd6w==", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -1779,7 +1779,7 @@ "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2760,7 +2760,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -3060,6 +3060,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5874,9 +5875,9 @@ } }, "node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index bfc655b..8acc813 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,11 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/plugin": "0.8.0-beta.1" + "@getpaseo/plugin": "0.11.1" }, "devDependencies": { - "@getpaseo/client": "0.8.0-beta.1", - "@getpaseo/protocol": "0.8.0-beta.1", + "@getpaseo/client": "0.11.1", + "@getpaseo/protocol": "0.11.1", "@tanstack/react-query": "^5.102.8", "@types/node": "^26.5.0", "@types/react": "~19.2.0", diff --git a/server/launch/branches.ts b/server/launch/branches.ts new file mode 100644 index 0000000..7fdb651 --- /dev/null +++ b/server/launch/branches.ts @@ -0,0 +1,94 @@ +import type { z } from "zod"; +import type { listBranches } from "../../shared/board"; +import { repositoryIdFor } from "../../shared/launch"; +import { gh } from "../github/gh"; +import { baseKey, readSettings } from "../settings/settings"; + +/** + * The default branch and the hundred most recently committed others, in one + * query. A repository with more branches than that keeps its long tail off the + * menu, the same trade the label menu makes: the branches anyone starts work + * from are the recent ones. + */ +const BRANCHES_QUERY = `query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + defaultBranchRef { name } + refs(refPrefix: "refs/heads/", first: 100, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) { + nodes { name } + } + } +}`; + +/** The same, plus a pull request's head branch, asked in the same round trip. */ +const PULL_REQUEST_BRANCHES_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + defaultBranchRef { name } + refs(refPrefix: "refs/heads/", first: 100, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) { + nodes { name } + } + pullRequest(number: $number) { headRefName isCrossRepository } + } +}`; + +interface BranchesPayload { + data?: { + repository?: { + defaultBranchRef?: { name?: unknown } | null; + refs?: { nodes?: unknown }; + pullRequest?: { headRefName?: unknown; isCrossRepository?: unknown } | null; + } | null; + }; +} + +export async function listBranchesHandler({ + repository, + url, + pullRequest, + number, +}: z.output): Promise> { + const [owner, name] = repository.split("/"); + if (owner === undefined || name === undefined || owner === "" || name === "") { + throw new Error(`"${repository}" is not an owner/name repository.`); + } + const [raw, settings] = await Promise.all([ + gh([ + "api", + "graphql", + "-f", + `query=${pullRequest ? PULL_REQUEST_BRANCHES_QUERY : BRANCHES_QUERY}`, + "-f", + `owner=${owner}`, + "-f", + `name=${name}`, + ...(pullRequest ? ["-F", `number=${number}`] : []), + ]), + readSettings(), + ]); + + const found = (JSON.parse(raw) as BranchesPayload).data?.repository; + const defaultName = found?.defaultBranchRef?.name; + const defaultBranch = typeof defaultName === "string" && defaultName !== "" ? defaultName : null; + const nodes: unknown[] = Array.isArray(found?.refs?.nodes) ? found.refs.nodes : []; + const names = nodes + .map((node) => (typeof node === "object" && node !== null ? (node as { name?: unknown }).name : null)) + .filter((entry): entry is string => typeof entry === "string" && entry !== ""); + // The default first, then the rest by recency, so the menu opens on the usual choice. + const branches = defaultBranch === null ? names : [defaultBranch, ...names.filter((n) => n !== defaultBranch)]; + + const repositoryId = repositoryIdFor(repository, url); + const remembered = repositoryId === null ? undefined : settings.bases[baseKey(repositoryId, pullRequest)]; + return { defaultBranch, branches, remembered: remembered ?? null, pullRequestHead: sameRepositoryHead(found?.pullRequest) }; +} + +/** + * A pull request's head branch, when it lives in the card's own repository. A + * fork's head is a branch of *its* repository: a local branch of that name says + * nothing about whether it tracks this pull request. + */ +export function sameRepositoryHead( + pullRequest: { headRefName?: unknown; isCrossRepository?: unknown } | null | undefined, +): string | null { + const head = pullRequest?.headRefName; + if (pullRequest?.isCrossRepository !== false || typeof head !== "string" || head === "") return null; + return head; +} diff --git a/server/launch/checkouts.ts b/server/launch/checkouts.ts new file mode 100644 index 0000000..a5ba7b7 --- /dev/null +++ b/server/launch/checkouts.ts @@ -0,0 +1,50 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { parseWorktreeList } from "../../shared/launch"; +import { MAX_OUTPUT_BYTES } from "../github/gh"; + +const execFileAsync = promisify(execFile); + +/** + * Every worktree of the checkout at `root`, whoever made it — Paseo, another + * tool, or `git worktree add` by hand — with the branch each is on. Null for a + * directory git cannot read, which leaves the dialog offering only what Paseo + * itself knows about rather than failing to open. + */ +export async function gitWorktrees(root: string): Promise<{ path: string; branch: string | null }[] | null> { + try { + const { stdout } = await execFileAsync("git", ["-C", root, "worktree", "list", "--porcelain"], { + maxBuffer: MAX_OUTPUT_BYTES, + }); + return parseWorktreeList(stdout); + } catch (cause: unknown) { + console.warn(`[github-board] could not list worktrees of ${root}`, cause); + return null; + } +} + +/** Whether `root` has a local branch of this name, checked out anywhere or not. */ +export async function localBranchExists(root: string, branch: string): Promise { + return execFileAsync("git", ["-C", root, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]).then( + () => true, + () => false, + ); +} + +/** + * Creates a local branch from the card repository's remote when there is none + * yet, so a checkout of a branch only GitHub has resolves — the daemon itself + * would only try `origin`, which on a fork is the fork. An existing local + * branch is left exactly where it is: it may hold work nobody has pushed. + * Failure is left to the daemon, which reports the unknown branch by name. + */ +export async function ensureLocalBranch(root: string, remote: string, branch: string): Promise { + if (await localBranchExists(root, branch)) return; + await execFileAsync( + "git", + ["-C", root, "fetch", "--quiet", remote, `refs/heads/${branch}:refs/heads/${branch}`], + { maxBuffer: MAX_OUTPUT_BYTES, timeout: 30_000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }, + ).catch((cause: unknown) => { + console.warn(`[github-board] could not fetch ${remote}/${branch} as a local branch`, cause); + }); +} diff --git a/server/launch/handler.ts b/server/launch/handler.ts index 798c42d..78e4c82 100644 --- a/server/launch/handler.ts +++ b/server/launch/handler.ts @@ -1,20 +1,27 @@ import type { z } from "zod"; import type { PluginHandlerContext } from "@getpaseo/plugin/server"; -import type { sendOptions, sendToChat } from "../../shared/board"; -import { BOARD_ITEM_TIMELINE_KIND, BOARD_ITEM_TIMELINE_VERSION } from "../../shared/timeline"; -import { readSettings, updateSettings } from "../settings/settings"; -import { findProject, repositoryIdFor } from "./project-index"; +import type { + BaseChoice, + LaunchDefaults, + launchDefaults, + listCheckouts, + saveLaunchDefaults, + sendOptions, + sendToChat, +} from "../../shared/board"; +import { checkedOutConflict, launchCard, listProjectCheckouts, repositoryIdFor } from "../../shared/launch"; +import type { WorktreeStart } from "../../shared/launch"; +import { baseKey, readSettings, updateSettings } from "../settings/settings"; +import { ensureLocalBranch, gitWorktrees, localBranchExists } from "./checkouts"; +import { fetchBaseBranch, findProject, remoteNameFor } from "./project-index"; import type { PaseoApi, ProjectRecord } from "./project-index"; -/** Workspace titles are capped at the same length the daemon caps agent titles. */ -const MAX_TITLE_CHARS = 200; - /** * The project one card can be sent to, or a refusal that says what to do about * it. Both handlers below start here, so "no project" reads the same whether * the dialog is opening or the send is running. */ -async function requireProject( +export async function requireProject( paseo: PaseoApi, repository: string, url: string, @@ -33,6 +40,16 @@ async function requireProject( return project; } +export async function listCheckoutsHandler( + { repository, url }: z.output, + { paseo }: PluginHandlerContext, +): Promise> { + const project = await requireProject(paseo, repository, url); + if (project.kind !== "git") return { checkouts: [] }; + const worktrees = await gitWorktrees(project.rootPath); + return { checkouts: await listProjectCheckouts(paseo, project, worktrees) }; +} + export async function sendOptionsHandler( { repository, url }: z.output, { paseo }: PluginHandlerContext, @@ -56,110 +73,112 @@ export async function sendOptionsHandler( }; } -export async function sendToChatHandler( - { - repository, - number, - title, - url, - author, - labels, - prompt, - isolation, - provider, - model, - modeId, - thinkingOptionId, - }: z.output, - { paseo }: PluginHandlerContext, -): Promise> { - const project = await requireProject(paseo, repository, url); - if (isolation === "worktree" && project.kind !== "git") { - throw new Error(`${project.displayName} is not a git checkout, so it cannot be worktreed.`); - } +export async function launchDefaultsHandler( + _input: z.output, +): Promise> { + return (await readSettings()).launch; +} - const trimmed = title.trim(); - /** - * `firstAgentContext` is passed here and *only* here, because this handler - * really does create the agent it promises. The daemon reads it two ways: as - * naming context for the workspace and its branch, and as `expectsInitialAgent`, - * which flips the new workspace to an optimistic `running`. A caller that - * passes it and then creates nothing leaves a workspace spinning until it - * settles on `done`. - */ - const workspace = await paseo.workspaces.create({ - title: (trimmed === "" ? `${repository} #${number}` : trimmed).slice(0, MAX_TITLE_CHARS), - firstAgentContext: { prompt, attachments: [] }, - source: - isolation === "worktree" - ? { - // No `worktreeSlug`: the daemon mints a mnemonic one, and then - // renames the branch after the prompt once the agent is running. - kind: "worktree", - cwd: project.rootPath, - projectId: project.projectId, - } - : { kind: "directory", path: project.rootPath, projectId: project.projectId }, - }); +export async function saveLaunchDefaultsHandler({ + launch, + base, +}: z.output): Promise> { + await saveLaunch(launch, base === null ? null : { ...base, base: base.choice }); + return { ok: true }; +} - /** - * The agent is created *in* the workspace, so the SDK places it on the - * workspace's own directory — the worktree's path, not the project root, when - * one was cut. `prompt` rides along as the first message rather than being - * sent afterwards, so there is no window where the workspace exists with a - * silent agent in it. - */ - const agent = await workspace.agents - .create({ - config: { - // `provider/model`, which is the only spelling the SDK accepts. - provider: `${provider}/${model}`, - ...(modeId === null ? {} : { modeId }), - ...(thinkingOptionId === null ? {} : { thinkingOptionId }), - }, - prompt, - }) - .catch((cause: unknown) => { - const detail = cause instanceof Error ? cause.message : String(cause); - throw new Error( - `Workspace “${workspace.name ?? project.displayName}” was created, but the agent could not be started: ${detail}`, - ); - }); +/** + * Saves the next card's defaults and, for a worktree send, the base it used — + * merged into the remembered bases rather than replacing them, so one + * repository's choice never forgets another's. + */ +async function saveLaunch( + launch: LaunchDefaults, + base: { repository: string; url: string; pullRequest: boolean; base: BaseChoice } | null, +): Promise { + const repositoryId = base === null ? null : repositoryIdFor(base.repository, base.url); + if (base === null || repositoryId === null) { + await updateSettings({ launch }); + return; + } + const { bases } = await readSettings(); + await updateSettings({ + launch, + bases: { ...bases, [baseKey(repositoryId, base.pullRequest)]: base.base }, + }); +} - /** - * The card the agent is working on, as a persisted row in its own transcript. - * - * It lands *after* the opening prompt rather than above it, because the prompt - * rides along with `agents.create` and there is no agent to append to until - * that has resolved. That ordering is the price of not opening a window where - * the workspace exists with a silent agent in it, which matters more. - * - * Deliberately not fatal. By this point the workspace and the agent both - * exist and the prompt has been delivered — the send the user asked for has - * happened. Failing it here would report an error for a launch that worked and - * invite the user to send the card a second time, which would cut a second - * worktree. A missing row costs the transcript its header and nothing else. - */ - await agent.timeline - .append({ - type: "plugin", - id: `${BOARD_ITEM_TIMELINE_KIND}:${repository}#${number}`, - kind: BOARD_ITEM_TIMELINE_KIND, - version: BOARD_ITEM_TIMELINE_VERSION, - data: { repository, number, title, url, author, labels }, - }) - .catch((cause: unknown) => { - console.warn("[github-board] could not append the timeline row", cause); - }); +/** + * A send to the host this board belongs to. The workspace and agent are made by + * `launchCard`, the same function the dialog runs itself for a send to any + * other connected host; what only this side can do is find the project through + * a fork's other git remotes, fetch a base branch the checkout has never seen, + * and save the next card's defaults in the same round trip. + */ +export async function sendToChatHandler( + { hostId, pullRequest, pullRequestHead, base, ...input }: z.output, + { paseo }: PluginHandlerContext, +): Promise> { + const project = await requireProject(paseo, input.repository, input.url); + const start = + input.isolation === "worktree" + ? await resolveStart(project.rootPath, input.repository, input.url, base, pullRequestHead) + : ({ kind: "default" } as const); + const result = await launchCard(paseo, project, { ...input, start }); // Saved only once the send has actually worked, so a configuration that the // host rejected is not what the next card opens on. - await updateSettings({ launch: { provider, model, modeId, thinkingOptionId, isolation } }); + const { provider, model, modeId, thinkingOptionId, isolation } = input; + await saveLaunch( + { provider, model, modeId, thinkingOptionId, isolation, hostId }, + isolation === "worktree" && base !== null + ? { repository: input.repository, url: input.url, pullRequest, base } + : null, + ); - return { - workspaceId: workspace.id, - workspaceName: workspace.name ?? project.displayName, - projectName: project.displayName, - agentId: agent.id, - }; + return result; +} + +/** + * The daemon is asked for the base as a remote-tracking ref on the remote that + * points at the card's repository — a fork's `upstream`, not its `origin` — and + * that ref is fetched first, so a branch the checkout has never seen resolves. + * No base, or no such remote, leaves the daemon on the default branch. + * + * The two starts that check a branch out rather than cut one are checked + * against the worktrees first, because the daemon forks instead of failing + * (see `WorktreeStart`): a branch some worktree already has is refused with + * a pointer at Existing checkout, and a pull request whose head branch is + * already local is checked out as that branch rather than as a `-1` copy of it. + */ +async function resolveStart( + root: string, + repository: string, + url: string, + base: BaseChoice | null, + pullRequestHead: string | null, +): Promise { + const checkoutBranch = base?.kind === "pull-request" ? pullRequestHead : base?.checkout === true ? base.name : null; + if (checkoutBranch !== null) { + const worktrees = (await gitWorktrees(root)) ?? []; + const conflict = checkedOutConflict( + worktrees.map((worktree) => ({ ...worktree, main: false, workspaceId: null, workspaceName: null })), + checkoutBranch, + ); + if (conflict !== null) throw new Error(conflict); + } + if (base?.kind === "pull-request") { + return pullRequestHead !== null && (await localBranchExists(root, pullRequestHead)) + ? { kind: "branch", branch: pullRequestHead } + : { kind: "pull-request" }; + } + const repositoryId = repositoryIdFor(repository, url); + const remote = repositoryId === null ? null : await remoteNameFor(root, repositoryId); + if (base === null || remote === null) return { kind: "default" }; + if (base.checkout === true) { + await ensureLocalBranch(root, remote, base.name); + return { kind: "branch", branch: base.name }; + } + await fetchBaseBranch(root, remote, base.name); + return { kind: "ref", ref: `refs/remotes/${remote}/${base.name}` }; } diff --git a/server/launch/project-index.ts b/server/launch/project-index.ts index b4b7a9c..3ad0b52 100644 --- a/server/launch/project-index.ts +++ b/server/launch/project-index.ts @@ -4,6 +4,8 @@ import type { PluginHandlerContext } from "@getpaseo/plugin/server"; import { MAX_OUTPUT_BYTES } from "../github/gh"; import { Cache } from "../cache/cache"; +export { repositoryIdFor } from "../../shared/launch"; + const execFileAsync = promisify(execFile); /** @@ -56,24 +58,6 @@ async function readProjects(paseo: PaseoApi): Promise { })); } -/** - * A repository's identity as both a project key and a git remote spell it: - * `//`, lowercased. The host comes from the item's own URL - * rather than a hardcoded `github.com`, so a GitHub Enterprise card matches the - * enterprise project and not a same-named repository on github.com. - */ -export function repositoryIdFor(repository: string, url: string): string | null { - if (!repository.includes("/")) return null; - let host: string; - try { - host = new URL(url).host; - } catch { - return null; - } - if (host === "") return null; - return `${host}/${repository}`.toLowerCase(); -} - /** * Normalises any git remote URL to the same `//` form, * covering the scp-like `git@host:owner/name.git` that `new URL` cannot parse @@ -118,6 +102,11 @@ function normalizeRemoteUrl(remote: string): string | null { * nothing rather than failing the search for every other project. */ async function gitRemotes(root: string): Promise { + return [...new Set((await gitRemoteEntries(root)).map((entry) => entry.repositoryId))]; +} + +/** Each remote's name and the repository it points at, fetch and push lines alike. */ +async function gitRemoteEntries(root: string): Promise<{ name: string; repositoryId: string }[]> { let stdout: string; try { ({ stdout } = await execFileAsync("git", ["-C", root, "remote", "-v"], { @@ -126,14 +115,40 @@ async function gitRemotes(root: string): Promise { } catch { return []; } - const seen = new Set(); + const entries: { name: string; repositoryId: string }[] = []; for (const line of stdout.split("\n")) { - const url = line.trim().split(/\s+/)[1]; - if (url === undefined) continue; - const normalized = normalizeRemoteUrl(url); - if (normalized !== null) seen.add(normalized); + const [name, url] = line.trim().split(/\s+/); + if (name === undefined || url === undefined) continue; + const repositoryId = normalizeRemoteUrl(url); + if (repositoryId !== null) entries.push({ name, repositoryId }); } - return [...seen]; + return entries; +} + +/** + * The remote in the checkout at `root` that points at this repository — `origin` + * when it does, else whichever does, like a fork's `upstream`. That is the + * remote a base branch is read from, because the branch list came from this + * repository and not from the fork. + */ +export async function remoteNameFor(root: string, repositoryId: string): Promise { + const matching = (await gitRemoteEntries(root)).filter((entry) => entry.repositoryId === repositoryId); + return (matching.find((entry) => entry.name === "origin") ?? matching[0])?.name ?? null; +} + +/** + * Fetches one branch into its remote-tracking ref, so a branch created on + * GitHub after this checkout last fetched still resolves as a worktree base. + * Failure is left to the daemon, which reports the missing ref by name. + */ +export async function fetchBaseBranch(root: string, remote: string, branch: string): Promise { + await execFileAsync( + "git", + ["-C", root, "fetch", "--quiet", remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`], + { maxBuffer: MAX_OUTPUT_BYTES, timeout: 30_000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }, + ).catch((cause: unknown) => { + console.warn(`[github-board] could not fetch ${remote}/${branch}`, cause); + }); } /** diff --git a/server/settings/settings.ts b/server/settings/settings.ts index 0fbff18..166f18c 100644 --- a/server/settings/settings.ts +++ b/server/settings/settings.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import type { z } from "zod"; import type { + BaseChoice, LaunchDefaults, PromptSet, PromptSettings, @@ -49,6 +50,7 @@ const EMPTY_LAUNCH: LaunchDefaults = { modeId: null, thinkingOptionId: null, isolation: "local", + hostId: null, }; /** @@ -87,6 +89,13 @@ export interface Settings { /** Null until the user pins one; the caller falls back to the gh viewer. */ login: string | null; launch: LaunchDefaults; + /** + * The base each repository was last sent with, keyed `//` + * for issues and discussions and `…#pr` for pull requests — a pull request + * usually wants its own branch, an issue a branch to start from, so one + * choice must not overwrite the other. + */ + bases: Record; /** * Non-null only on a file written before the move, and only until the app * acknowledges having taken them. Every write puts them back untouched, so a @@ -98,6 +107,7 @@ export interface Settings { const EMPTY_SETTINGS: Settings = { login: null, launch: { ...EMPTY_LAUNCH }, + bases: {}, legacy: null, }; @@ -173,10 +183,32 @@ function readLaunch(value: unknown): LaunchDefaults { model: asString(raw.model), modeId: asString(raw.modeId), thinkingOptionId: asString(raw.thinkingOptionId), - isolation: raw.isolation === "worktree" ? "worktree" : "local", + isolation: raw.isolation === "worktree" || raw.isolation === "existing" ? raw.isolation : "local", + hostId: asString(raw.hostId), }; } +/** Remembered bases, dropping any entry an older or hand-edited file got wrong. */ +function readBases(value: unknown): Record { + if (typeof value !== "object" || value === null) return {}; + const bases: Record = {}; + for (const [key, raw] of Object.entries(value as Record)) { + if (typeof raw !== "object" || raw === null) continue; + const entry = raw as Record; + if (entry.kind === "pull-request") bases[key] = { kind: "pull-request" }; + const name = asString(entry.name); + if (entry.kind === "branch" && name !== null) { + bases[key] = entry.checkout === true ? { kind: "branch", name, checkout: true } : { kind: "branch", name }; + } + } + return bases; +} + +/** The key a card's base is remembered under; see `Settings.bases`. */ +export function baseKey(repositoryId: string, pullRequest: boolean): string { + return pullRequest ? `${repositoryId}#pr` : repositoryId; +} + export async function readSettings(): Promise { for (const path of [settingsPath(), legacyIdSettingsPath()]) { try { @@ -187,6 +219,7 @@ export async function readSettings(): Promise { return { login: typeof login === "string" && login.trim() !== "" ? login.trim() : null, launch: readLaunch(record.launch), + bases: readBases(record.bases), legacy: readLegacy(record), }; } catch { diff --git a/shared/board.ts b/shared/board.ts index 54a63e1..d208d6f 100644 --- a/shared/board.ts +++ b/shared/board.ts @@ -326,11 +326,14 @@ export const loadProject = defineRpc({ }); /** - * How the workspace is cut: on the project's own checkout, or on a fresh Paseo - * worktree branched off it. The daemon spells these as the two `source` kinds - * of `workspace.create`, and only a git project can be worktreed. + * How the workspace is cut: on the project's own checkout, on a fresh Paseo + * worktree, or on a checkout already on the host — a worktree or branch some + * earlier send, another tool or the user made — taken as it stands. The daemon + * spells a new worktree as a `worktree` source of `workspace.create` and the + * other two as a `directory` one, and only a git project offers anything but + * Local. */ -export const IsolationSchema = z.enum(["local", "worktree"]); +export const IsolationSchema = z.enum(["local", "worktree", "existing"]); export type Isolation = z.output; @@ -353,6 +356,12 @@ export const LaunchDefaultsSchema = z.object({ /** The model's thinking level, when it has any. */ thinkingOptionId: z.string().nullable(), isolation: IsolationSchema, + /** + * The Paseo host the last card was sent to, by the app's `serverId`. Null + * means the host the board itself belongs to — also the fallback when the + * saved one is no longer connected. + */ + hostId: z.string().nullable(), }); export type LaunchDefaults = z.output; @@ -391,6 +400,111 @@ export const sendOptions = defineRpc({ }), }); +/** + * Where a new worktree starts: the card's own pull request branch, checked out + * by the daemon, or one of the repository's branches — a new branch cut from + * it, or, with `checkout`, that branch itself checked out with no new branch on + * top. Ignored for a Local or Existing send, which works in a checkout as it + * stands. + * + * `checkout` is optional rather than defaulted so a base remembered before it + * existed still parses, and reads as the branch-off it always was. + */ +export const BaseChoiceSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("pull-request") }), + z.object({ kind: z.literal("branch"), name: z.string().min(1), checkout: z.boolean().optional() }), +]); + +export type BaseChoice = z.output; + +/** A card's repository, and whether the card is a pull request: what a base is remembered by. */ +const BaseKeySchema = z.object({ + repository: z.string(), + url: z.string(), + pullRequest: z.boolean(), +}); + +/** + * The branches a worktree can start from, read from GitHub with the board's own + * `gh` — the same list whichever host the card is sent to — plus the base this + * repository was last sent with, for this kind of card. + */ +export const listBranches = defineRpc({ + name: "board.list-branches", + input: BaseKeySchema.extend({ number: z.number().int() }), + output: z.object({ + defaultBranch: z.string().nullable(), + /** Most recently committed first, capped at 100; the default is always included. */ + branches: z.array(z.string()), + remembered: BaseChoiceSchema.nullable(), + /** + * A pull request's head branch, so the dialog can find a checkout already + * on it. Null for anything else, and for a pull request from a fork, whose + * head branch name says nothing about which local branch tracks it. + */ + pullRequestHead: z.string().nullable(), + }), +}); + +/** + * One checkout of a project on a host, as an Existing send can join it: the + * main checkout or any worktree of it, with the branch it is on right now. + */ +export const CheckoutSchema = z.object({ + path: z.string(), + /** Null for a detached HEAD. */ + branch: z.string().nullable(), + /** True for the project's own checkout, as opposed to a worktree of it. */ + main: z.boolean(), + /** + * The Paseo workspace already open on this directory, which the agent then + * joins rather than opening a second workspace beside it. Null when Paseo has + * none — a worktree another tool made. + */ + workspaceId: z.string().nullable(), + workspaceName: z.string().nullable(), +}); + +export type Checkout = z.output; + +/** + * The checkouts of a card's project on the board's own host: every worktree + * `git worktree list` reports, whoever made it, matched to the Paseo workspaces + * open on them. Another host is asked from the dialog instead, where only the + * checkouts Paseo has a workspace for can be seen. + */ +export const listCheckouts = defineRpc({ + name: "board.list-checkouts", + input: z.object({ repository: z.string(), url: z.string() }), + output: z.object({ checkouts: z.array(CheckoutSchema) }), +}); + +/** + * What the last send was configured with, on its own. The dialog reads it + * before it knows which host it is sending to — `sendOptions` needs a project + * on *this* daemon, which a card sent to another host may not have. + */ +export const launchDefaults = defineRpc({ + name: "board.launch-defaults", + input: z.object({}), + output: LaunchDefaultsSchema, +}); + +/** + * Saves the next card's defaults after a send that did not go through + * `sendToChat` — one launched on another host straight from the dialog, which + * this daemon never saw. + */ +export const saveLaunchDefaults = defineRpc({ + name: "board.save-launch-defaults", + input: z.object({ + launch: LaunchDefaultsSchema, + /** Null for a Local or Existing send, which leaves the remembered base alone. */ + base: BaseKeySchema.extend({ choice: BaseChoiceSchema }).nullable(), + }), + output: z.object({ ok: z.literal(true) }), +}); + /** * Hands one card to a fresh workspace and starts the conversation: the * repository is matched to a Paseo project on the daemon, a workspace is @@ -427,6 +541,19 @@ export const sendToChat = defineRpc({ model: z.string().min(1), modeId: z.string().nullable(), thinkingOptionId: z.string().nullable(), + /** The host's `serverId`, saved with the defaults; this daemon is always the target. */ + hostId: z.string().nullable(), + /** Whether the card is a pull request, which is what its base is remembered under. */ + pullRequest: z.boolean(), + /** Where the worktree starts; null for a Local or Existing send. */ + base: BaseChoiceSchema.nullable(), + /** + * The pull request's head branch, as `listBranches` answered it, so a + * pull-request start can see a local branch of that name already there. + */ + pullRequestHead: z.string().nullable(), + /** The checkout an Existing send joins; null otherwise. */ + existing: CheckoutSchema.pick({ path: true, workspaceId: true, workspaceName: true }).nullable(), }), output: z.object({ workspaceId: z.string(), diff --git a/shared/launch.test.ts b/shared/launch.test.ts new file mode 100644 index 0000000..7992fa8 --- /dev/null +++ b/shared/launch.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from "vitest"; +import { + checkedOutConflict, + findProjectByKey, + launchCard, + listProjectCheckouts, + parseWorktreeList, + repositoryIdFor, +} from "./launch"; +import type { LaunchApi, LaunchInput, LaunchProject, WorkspaceListApi } from "./launch"; + +const projects = { + projects: { + list: async () => ({ + projects: [ + { + projectId: "p1", + projectRootPath: "/srv/nika", + projectDisplayName: "nika", + projectKey: "remote:github.com/sands-lab/nika", + projectKind: "git", + }, + { + projectId: "p2", + projectRootPath: "/srv/scratch", + projectDisplayName: "scratch", + projectKey: "host:abc:/srv/scratch", + projectKind: "directory", + }, + ], + }), + }, +}; + +describe("findProjectByKey", () => { + it("matches a card to the project keyed by its repository, ignoring case", async () => { + const id = repositoryIdFor("Sands-Lab/NIKA", "https://github.com/Sands-Lab/NIKA/issues/3"); + expect(id).toBe("github.com/sands-lab/nika"); + const project = await findProjectByKey(projects, id ?? ""); + expect(project).toEqual({ projectId: "p1", rootPath: "/srv/nika", displayName: "nika", kind: "git" }); + }); + + it("finds nothing for a repository no project is keyed by", async () => { + expect(await findProjectByKey(projects, "github.com/octocat/hello")).toBeUndefined(); + }); +}); + +describe("launchCard", () => { + const input: LaunchInput = { + repository: "sands-lab/nika", + number: 3, + title: " ", + url: "https://github.com/sands-lab/nika/issues/3", + author: "octocat", + labels: ["bug"], + prompt: "Fix it", + isolation: "worktree", + start: { kind: "default" }, + existing: null, + provider: "claude", + model: "opus", + modeId: null, + thinkingOptionId: "high", + }; + + function fakeApi() { + const calls: { workspace?: unknown; joined?: string; agent?: unknown; row?: unknown } = {}; + const agents = { + create: async (agentOptions: unknown) => { + calls.agent = agentOptions; + return { + id: "a1", + timeline: { + append: async (row: unknown) => { + calls.row = row; + throw new Error("not fatal"); + }, + }, + }; + }, + }; + const api: LaunchApi = { + workspaces: { + create: async (options) => { + calls.workspace = options; + return { id: "w1", name: null, agents }; + }, + ref: (workspaceId) => { + calls.joined = workspaceId; + return { id: workspaceId, name: null, agents }; + }, + }, + }; + return { api, calls }; + } + + const git: LaunchProject = { projectId: "p1", rootPath: "/srv/nika", displayName: "nika", kind: "git" }; + + it("cuts a worktree, starts the agent, and survives a failed timeline row", async () => { + const { api, calls } = fakeApi(); + const result = await launchCard(api, git, input); + expect(result).toEqual({ workspaceId: "w1", workspaceName: "nika", projectName: "nika", agentId: "a1" }); + expect(calls.workspace).toMatchObject({ + title: "sands-lab/nika #3", + source: { kind: "worktree", cwd: "/srv/nika", projectId: "p1" }, + }); + expect(calls.agent).toEqual({ + config: { provider: "claude/opus", thinkingOptionId: "high" }, + prompt: "Fix it", + }); + expect(calls.row).toMatchObject({ type: "plugin", data: { number: 3, labels: ["bug"] } }); + }); + + it("branches off a remote-tracking base, or checks out the pull request", async () => { + const branch = fakeApi(); + await launchCard(branch.api, git, { ...input, start: { kind: "ref", ref: "refs/remotes/origin/dev" } }); + expect(branch.calls.workspace).toMatchObject({ + source: { kind: "worktree", action: "branch-off", refName: "refs/remotes/origin/dev" }, + }); + + const pr = fakeApi(); + await launchCard(pr.api, git, { ...input, start: { kind: "pull-request" } }); + expect(pr.calls.workspace).toMatchObject({ + source: { + kind: "worktree", + action: "checkout", + checkoutSource: { kind: "change_request", forge: "github", number: 3 }, + }, + }); + }); + + it("checks a branch out as it is, with no new branch on top", async () => { + const { api, calls } = fakeApi(); + await launchCard(api, git, { ...input, start: { kind: "branch", branch: "feat/x" } }); + expect(calls.workspace).toMatchObject({ + source: { kind: "worktree", action: "checkout", refName: "feat/x" }, + }); + expect(calls.workspace).not.toHaveProperty("source.checkoutSource"); + }); + + it("joins the workspace an existing checkout already has, creating nothing", async () => { + const { api, calls } = fakeApi(); + const result = await launchCard(api, git, { + ...input, + isolation: "existing", + existing: { path: "/srv/nika-feat", workspaceId: "w9", workspaceName: "feat" }, + }); + expect(calls.workspace).toBeUndefined(); + expect(calls.joined).toBe("w9"); + expect(result).toMatchObject({ workspaceId: "w9", workspaceName: "feat", agentId: "a1" }); + }); + + it("opens a directory workspace on an existing checkout Paseo has none on", async () => { + const { api, calls } = fakeApi(); + await launchCard(api, git, { + ...input, + isolation: "existing", + existing: { path: "/srv/nika-other", workspaceId: null, workspaceName: null }, + }); + expect(calls.joined).toBeUndefined(); + expect(calls.workspace).toMatchObject({ + source: { kind: "directory", path: "/srv/nika-other", projectId: "p1" }, + }); + }); + + it("refuses an existing send with no checkout chosen", async () => { + const { api } = fakeApi(); + await expect(launchCard(api, git, { ...input, isolation: "existing" })).rejects.toThrow( + "Choose the checkout", + ); + }); + + it("explains a base branch the target checkout has never fetched", async () => { + const api: LaunchApi = { + workspaces: { + create: async () => { + throw new Error("Base branch not found: refs/remotes/origin/dev"); + }, + ref: () => { + throw new Error("not joined"); + }, + }, + }; + await expect( + launchCard(api, git, { ...input, start: { kind: "ref", ref: "refs/remotes/origin/dev" } }), + ).rejects.toThrow("origin/dev has never been fetched in nika on that host"); + }); + + it("refuses a worktree on a project that is not a git checkout", async () => { + const { api } = fakeApi(); + await expect(launchCard(api, { ...git, kind: "directory" }, input)).rejects.toThrow( + "cannot be worktreed", + ); + }); +}); + +const PORCELAIN = `worktree /srv/nika +HEAD 1111111111111111111111111111111111111111 +branch refs/heads/main + +worktree /srv/nika-feat +HEAD 2222222222222222222222222222222222222222 +branch refs/heads/feat/x + +worktree /srv/nika-detached +HEAD 3333333333333333333333333333333333333333 +detached + +worktree /srv/gone +HEAD 4444444444444444444444444444444444444444 +branch refs/heads/old +prunable gitdir file points to non-existent location +`; + +describe("parseWorktreeList", () => { + it("reads every live worktree with its branch, dropping prunable ones", () => { + expect(parseWorktreeList(PORCELAIN)).toEqual([ + { path: "/srv/nika", branch: "main" }, + { path: "/srv/nika-feat", branch: "feat/x" }, + { path: "/srv/nika-detached", branch: null }, + ]); + }); +}); + +describe("listProjectCheckouts", () => { + const workspaces = ( + entries: Awaited>["entries"], + ): WorkspaceListApi & { asked?: unknown } => { + const api: WorkspaceListApi & { asked?: unknown } = { + workspaces: { + list: async (options) => { + api.asked = options; + return { entries }; + }, + }, + }; + return api; + }; + const project = { projectId: "p1", rootPath: "/srv/nika" }; + + it("matches git's worktrees to the most recently active workspace on each", async () => { + const api = workspaces([ + { id: "old", name: "Old", workspaceDirectory: "/srv/nika", activityAt: "2026-01-01T00:00:00Z" }, + { id: "new", name: "New", workspaceDirectory: "/srv/nika/", activityAt: "2026-02-01T00:00:00Z" }, + { id: "feat", name: "Feat", workspaceDirectory: "/srv/nika-feat", activityAt: null }, + ]); + const checkouts = await listProjectCheckouts(api, project, parseWorktreeList(PORCELAIN)); + expect(api.asked).toEqual({ filter: { projectId: "p1" } }); + expect(checkouts).toEqual([ + { path: "/srv/nika", branch: "main", main: true, workspaceId: "new", workspaceName: "New" }, + { path: "/srv/nika-detached", branch: null, main: false, workspaceId: null, workspaceName: null }, + { path: "/srv/nika-feat", branch: "feat/x", main: false, workspaceId: "feat", workspaceName: "Feat" }, + ]); + }); + + it("falls back to the workspaces Paseo has, plus the root, without git", async () => { + const api = workspaces([ + { + id: "feat", + name: "Feat", + workspaceDirectory: "/srv/nika-feat", + activityAt: null, + gitRuntime: { currentBranch: "feat/x" }, + }, + ]); + expect(await listProjectCheckouts(api, project, null)).toEqual([ + { path: "/srv/nika", branch: null, main: true, workspaceId: null, workspaceName: null }, + { path: "/srv/nika-feat", branch: "feat/x", main: false, workspaceId: "feat", workspaceName: "Feat" }, + ]); + }); +}); + +describe("checkedOutConflict", () => { + const checkouts = [ + { path: "/srv/nika-feat", branch: "feat/x", main: false, workspaceId: null, workspaceName: null }, + ]; + + it("names the checkout already holding the branch", () => { + expect(checkedOutConflict(checkouts, "feat/x")).toContain("already checked out at /srv/nika-feat"); + }); + + it("finds nothing for a free branch, or no branch at all", () => { + expect(checkedOutConflict(checkouts, "feat/y")).toBeNull(); + expect(checkedOutConflict(checkouts, null)).toBeNull(); + }); +}); diff --git a/shared/launch.ts b/shared/launch.ts new file mode 100644 index 0000000..8d56043 --- /dev/null +++ b/shared/launch.ts @@ -0,0 +1,427 @@ +import type { Checkout, Isolation } from "./board"; +import { BOARD_ITEM_TIMELINE_KIND, BOARD_ITEM_TIMELINE_VERSION } from "./timeline"; + +/** + * Cutting a workspace for one card and starting its agent, written once for + * both places a send can run: the daemon's `sendToChatHandler`, for the host + * the board belongs to, and the launch dialog itself, for any *other* connected + * host the user picked. + * + * The two hand it different APIs — the handler's `paseo`, the dialog's + * `getPaseoClient(serverId)` — of the same `PaseoApi` type. `shared/` may not + * import that type (a runtime-specific SDK entry, see + * `scripts/check-import-boundaries.mjs`), so `LaunchApi` below names only the + * slice this module calls, and both APIs satisfy it structurally. + * + * Like `shared/timeline.ts`, which is its one runtime import, this file imports + * nothing else at runtime — `./board` is types only — so it stays loadable from + * the standalone server transpile. + */ + +/** Workspace titles are capped at the same length the daemon caps agent titles. */ +const MAX_TITLE_CHARS = 200; + +export interface LaunchProject { + projectId: string; + rootPath: string; + displayName: string; + /** `git`, `non_git`, or `directory`; only `git` can be worktreed. */ + kind: string; +} + +interface LaunchAgent { + readonly id: string; + readonly timeline: { + append(item: { + type: "plugin"; + id: string; + kind: string; + version: number; + data: { repository: string; number: number; title: string; url: string; author: string | null; labels: string[] }; + }): Promise; + }; +} + +interface LaunchWorkspace { + readonly id: string; + readonly name: string | null; + readonly agents: { + create(options: { + config: { provider: string; modeId?: string; thinkingOptionId?: string }; + prompt: string; + }): Promise; + }; +} + +type WorktreeSource = { kind: "worktree"; cwd: string; projectId: string } & ( + | { action?: undefined } + | { action: "branch-off"; refName: string } + | { action: "checkout"; refName: string } + | { action: "checkout"; checkoutSource: { kind: "change_request"; forge: "github"; number: number } } +); + +export interface LaunchApi { + readonly workspaces: { + create(options: { + title: string; + firstAgentContext: { prompt: string; attachments: [] }; + source: WorktreeSource | { kind: "directory"; path: string; projectId: string }; + }): Promise; + /** A workspace that already exists, which an Existing send's agent joins. */ + ref(workspaceId: string): LaunchWorkspace; + }; +} + +/** The slice of a host's workspace list an Existing send's checkouts are read from. */ +export interface WorkspaceListApi { + readonly workspaces: { + list(options: { filter: { projectId: string } }): Promise<{ + entries: readonly { + id: string; + name: string; + workspaceDirectory?: string | undefined; + activityAt: string | null; + gitRuntime?: { currentBranch?: string | null | undefined } | null | undefined; + }[]; + }>; + }; +} + +/** + * Where a worktree starts, as the daemon is asked for it. `ref` is always a + * remote-tracking ref (`refs/remotes//`): the daemon fetches a + * remote-tracking base before branching, so the agent starts at the remote tip + * rather than at a local branch nobody has pulled — but only a ref the checkout + * has fetched at least once resolves, which is why the board's own host fetches + * it first (`sendToChatHandler`). `pull-request` has the daemon fetch and check + * out the card's own head branch, forks included. `branch` has it check out a + * local branch as it is, with no new branch on top — fetched from `origin` + * first when there is no local branch of that name yet. `default` leaves the + * base to the daemon, which branches off the checkout's default branch as it + * stands. + * + * The daemon quietly forks rather than fails in two cases a caller has to rule + * out first, because the fork is exactly the stray branch these starts exist to + * avoid: `branch` on a branch some worktree already has checked out gets a + * `-1` cut from it, and `pull-request` with a local branch of the head's + * name already there checks the pull request out as `-1`. + */ +export type WorktreeStart = + | { kind: "ref"; ref: string } + | { kind: "branch"; branch: string } + | { kind: "pull-request" } + | { kind: "default" }; + +/** Every project a host's daemon knows about, as `projects.list` reports it. */ +export interface ProjectListApi { + readonly projects: { + list(): Promise<{ + projects: readonly { + projectId: string; + projectRootPath: string; + projectDisplayName: string; + projectKey?: string | null | undefined; + projectKind: string; + }[]; + }>; + }; +} + +export interface LaunchInput { + repository: string; + number: number; + title: string; + url: string; + author: string | null; + labels: string[]; + prompt: string; + isolation: Isolation; + /** Ignored unless `isolation` is `worktree`. */ + start: WorktreeStart; + /** Required when `isolation` is `existing`, ignored otherwise. */ + existing: Pick | null; + provider: string; + model: string; + modeId: string | null; + thinkingOptionId: string | null; +} + +export interface LaunchOutcome { + workspaceId: string; + workspaceName: string; + projectName: string; + agentId: string; +} + +/** + * A repository's identity as both a project key and a git remote spell it: + * `//`, lowercased. The host comes from the item's own URL + * rather than a hardcoded `github.com`, so a GitHub Enterprise card matches the + * enterprise project and not a same-named repository on github.com. + */ +export function repositoryIdFor(repository: string, url: string): string | null { + if (!repository.includes("/")) return null; + let host: string; + try { + host = new URL(url).host; + } catch { + return null; + } + if (host === "") return null; + return `${host}/${repository}`.toLowerCase(); +} + +/** + * The project on some host whose own key is this repository. Paseo keys a + * project with a git remote as `remote://`, which is exactly + * a card's identity. + * + * This is the whole lookup for a *remote* host: the board's own host also + * scans every project's other git remotes (a fork's `upstream`), but that needs + * a `git` subprocess on the machine holding the checkout, which the dialog + * cannot run on someone else's daemon. + */ +export async function findProjectByKey( + paseo: ProjectListApi, + repositoryId: string, +): Promise { + const { projects } = await paseo.projects.list(); + const match = projects.find( + (project) => (project.projectKey ?? "").toLowerCase() === `remote:${repositoryId}`, + ); + if (match === undefined) return undefined; + return { + projectId: match.projectId, + rootPath: match.projectRootPath, + displayName: match.projectDisplayName, + kind: match.projectKind, + }; +} + +/** + * No `worktreeSlug` in either: the daemon mints a mnemonic one, and then renames + * the branch after the prompt once the agent is running. A checked-out pull + * request keeps its own head branch name instead. + */ +function worktreeSource(project: LaunchProject, start: WorktreeStart, number: number): WorktreeSource { + const where = { kind: "worktree" as const, cwd: project.rootPath, projectId: project.projectId }; + switch (start.kind) { + case "default": + return where; + case "pull-request": + return { ...where, action: "checkout", checkoutSource: { kind: "change_request", forge: "github", number } }; + case "branch": + // A bare `refName` with no `checkoutSource` is the daemon's checkout-branch. + return { ...where, action: "checkout", refName: start.branch }; + case "ref": + return { ...where, action: "branch-off", refName: start.ref }; + } +} + +/** + * The workspace an Existing send's agent goes into: the one Paseo already has + * open on that directory, or else a new one on it — a worktree another tool + * made has none until now. Either way nothing is cut and no branch is made; + * the agent works on whatever the checkout has checked out. + */ +async function existingWorkspace( + paseo: LaunchApi, + project: LaunchProject, + existing: NonNullable, + create: (source: { kind: "directory"; path: string; projectId: string }) => Promise, +): Promise { + if (existing.workspaceId === null) { + return create({ kind: "directory", path: existing.path, projectId: project.projectId }); + } + const joined = paseo.workspaces.ref(existing.workspaceId); + // A handle made from a bare id knows no name until refreshed; the list did. + return { id: joined.id, name: joined.name ?? existing.workspaceName, agents: joined.agents }; +} + +export async function launchCard( + paseo: LaunchApi, + project: LaunchProject, + input: LaunchInput, +): Promise { + const { repository, number, title, url, author, labels, prompt, isolation, start, existing } = input; + if (isolation === "worktree" && project.kind !== "git") { + throw new Error(`${project.displayName} is not a git checkout, so it cannot be worktreed.`); + } + if (isolation === "existing" && existing === null) { + throw new Error("Choose the checkout to send this card to."); + } + + const trimmed = title.trim(); + /** + * `firstAgentContext` is passed here and *only* here, because this function + * really does create the agent it promises. The daemon reads it two ways: as + * naming context for the workspace and its branch, and as `expectsInitialAgent`, + * which flips the new workspace to an optimistic `running`. A caller that + * passes it and then creates nothing leaves a workspace spinning until it + * settles on `done`. + */ + const create = (source: Parameters[0]["source"]) => + paseo.workspaces + .create({ + title: (trimmed === "" ? `${repository} #${number}` : trimmed).slice(0, MAX_TITLE_CHARS), + firstAgentContext: { prompt, attachments: [] }, + source, + }) + .catch((cause: unknown) => { + const detail = cause instanceof Error ? cause.message : String(cause); + if (isolation === "worktree" && start.kind === "ref" && detail.includes("Base branch not found")) { + throw new Error( + `${start.ref.replace(/^refs\/remotes\//, "")} has never been fetched in ${project.displayName} on that host. Run \`git fetch\` there, then send this card again.`, + ); + } + if (isolation === "worktree" && start.kind === "branch" && detail.includes("Unknown branch")) { + throw new Error( + `${start.branch} is neither a local branch nor on origin in ${project.displayName} on that host. Run \`git fetch\` there, then send this card again.`, + ); + } + throw cause; + }); + const workspace = + isolation === "existing" && existing !== null + ? await existingWorkspace(paseo, project, existing, create) + : await create( + isolation === "worktree" + ? worktreeSource(project, start, number) + : { kind: "directory", path: project.rootPath, projectId: project.projectId }, + ); + + /** + * The agent is created *in* the workspace, so the SDK places it on the + * workspace's own directory — the worktree's path, not the project root, when + * one was cut. `prompt` rides along as the first message rather than being + * sent afterwards, so there is no window where the workspace exists with a + * silent agent in it. + */ + const agent = await workspace.agents + .create({ + config: { + // `provider/model`, which is the only spelling the SDK accepts. + provider: `${input.provider}/${input.model}`, + ...(input.modeId === null ? {} : { modeId: input.modeId }), + ...(input.thinkingOptionId === null ? {} : { thinkingOptionId: input.thinkingOptionId }), + }, + prompt, + }) + .catch((cause: unknown) => { + const detail = cause instanceof Error ? cause.message : String(cause); + throw new Error( + `Workspace “${workspace.name ?? project.displayName}” was created, but the agent could not be started: ${detail}`, + ); + }); + + /** + * The card the agent is working on, as a persisted row in its own transcript. + * + * It lands *after* the opening prompt rather than above it, because the prompt + * rides along with `agents.create` and there is no agent to append to until + * that has resolved. That ordering is the price of not opening a window where + * the workspace exists with a silent agent in it, which matters more. + * + * Deliberately not fatal. By this point the workspace and the agent both + * exist and the prompt has been delivered — the send the user asked for has + * happened. Failing it here would report an error for a launch that worked and + * invite the user to send the card a second time, which would cut a second + * worktree. A missing row costs the transcript its header and nothing else. + */ + await agent.timeline + .append({ + type: "plugin", + id: `${BOARD_ITEM_TIMELINE_KIND}:${repository}#${number}`, + kind: BOARD_ITEM_TIMELINE_KIND, + version: BOARD_ITEM_TIMELINE_VERSION, + data: { repository, number, title, url, author, labels }, + }) + .catch((cause: unknown) => { + console.warn("[github-board] could not append the timeline row", cause); + }); + + return { + workspaceId: workspace.id, + workspaceName: workspace.name ?? project.displayName, + projectName: project.displayName, + agentId: agent.id, + }; +} + +/** + * `git worktree list --porcelain`, read into the checkouts it names: one block + * per worktree, the main checkout first. A bare repository has no files to work + * in and a prunable worktree's directory is gone, so neither is offered. + */ +export function parseWorktreeList(stdout: string): { path: string; branch: string | null }[] { + const worktrees: { path: string; branch: string | null }[] = []; + for (const block of stdout.split(/\n\s*\n/)) { + const lines = block.split("\n").map((line) => line.trim()); + const path = lines.find((line) => line.startsWith("worktree "))?.slice("worktree ".length); + if (path === undefined || path === "") continue; + if (lines.some((line) => line === "bare" || line.startsWith("prunable"))) continue; + const ref = lines.find((line) => line.startsWith("branch "))?.slice("branch ".length); + worktrees.push({ path, branch: ref === undefined ? null : ref.replace(/^refs\/heads\//, "") }); + } + return worktrees; +} + +function samePath(a: string, b: string): boolean { + return a.replace(/\/+$/, "") === b.replace(/\/+$/, ""); +} + +/** + * Every checkout of one project an Existing send can join, from what its host + * reports. `worktrees` is `git worktree list` where it could be run — the + * board's own host — and null elsewhere, where the checkouts are only those + * Paseo has a workspace open on, plus the project's own root. + * + * Each checkout is matched to the workspace open on its directory, the most + * recently active one when Local sends have stacked several on the main + * checkout. Main first, then by path, so the menu reads the same each time. + */ +export async function listProjectCheckouts( + paseo: WorkspaceListApi, + project: Pick, + worktrees: readonly { path: string; branch: string | null }[] | null, +): Promise { + const { entries } = await paseo.workspaces.list({ filter: { projectId: project.projectId } }); + const workspaces = [...entries] + .filter((entry) => typeof entry.workspaceDirectory === "string" && entry.workspaceDirectory !== "") + .sort((a, b) => (b.activityAt ?? "").localeCompare(a.activityAt ?? "")); + + const found: { path: string; branch: string | null }[] = + worktrees === null + ? [{ path: project.rootPath, branch: null }, ...workspaces.map((entry) => ({ + path: entry.workspaceDirectory ?? "", + branch: entry.gitRuntime?.currentBranch ?? null, + }))] + : [...worktrees]; + + const checkouts: Checkout[] = []; + for (const { path, branch } of found) { + if (checkouts.some((checkout) => samePath(checkout.path, path))) continue; + const workspace = workspaces.find((entry) => samePath(entry.workspaceDirectory ?? "", path)); + checkouts.push({ + path, + // git's answer when there is one; the workspace's own reading otherwise. + branch: branch ?? workspace?.gitRuntime?.currentBranch ?? null, + main: samePath(path, project.rootPath), + workspaceId: workspace?.id ?? null, + workspaceName: workspace?.name ?? null, + }); + } + return checkouts.sort((a, b) => Number(b.main) - Number(a.main) || a.path.localeCompare(b.path)); +} + +/** + * Why a new worktree on `branch` would not be what was asked for: some checkout + * already has it, so the daemon would fork it instead (see `WorktreeStart`). + * Null when nothing does. + */ +export function checkedOutConflict(checkouts: readonly Checkout[], branch: string | null): string | null { + if (branch === null) return null; + const holder = checkouts.find((checkout) => checkout.branch === branch); + if (holder === undefined) return null; + return `${branch} is already checked out at ${holder.path}, so a new worktree would fork it. Choose Existing checkout to work there instead.`; +}