diff --git a/CLAUDE.md b/CLAUDE.md index a5abd4e0c..a763b616f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas | | [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer | | [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end | +| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers | | [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries | | [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config | | [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP | diff --git a/SECURITY.md b/SECURITY.md index 757363ef4..b09425b68 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -68,6 +68,10 @@ Paseo validates the `Host` header on every HTTP request and every WebSocket upgr Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their authentication. Each agent provider handles its own credentials. Paseo never stores or transmits provider API keys. Agents run in your user context with your existing credentials. +## Forge host trust + +Paseo only talks to a forge host that is either a known cloud host or one the forge CLI is already authenticated to. It never probes or routes credentials to an unauthenticated, remote-derived host. + ## Reporting vulnerabilities If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue. diff --git a/docs/architecture.md b/docs/architecture.md index 167e74737..8ba52a3ca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -189,7 +189,7 @@ Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a sess Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default. -New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names. +New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.forge.set_auto_merge.request` and `checkout.forge.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names. **Notable session message types:** @@ -271,14 +271,14 @@ Two workspaces can share the same `cwd` (e.g. a `directory` workspace and a `loc **Directory-backed (shared by same-`cwd` workspaces) — keyed by `(serverId, cwd)`, never by `workspaceId`:** -| Surface | Key | Source | -| ---------------------- | -------------------------------------------------------- | ------------------------------------------------------- | -| Git status | `checkoutStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` | -| Git diff | `checkoutDiffQueryKey(serverId, cwd, mode, baseRef, ws)` | `packages/app/src/git/query-keys.ts` | -| GitHub PR status | `checkoutPrStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` | -| PR pane timeline | `prPaneTimelineQueryKey({ serverId, cwd, prNumber })` | `packages/app/src/git/pull-request-panel/query-keys.ts` | -| File preview content | `["workspaceFile", serverId, cwd, path]` | `packages/app/src/components/file-pane.tsx` | -| File explorer listings | fetched via `listDirectory(workspaceRoot, path)` | `packages/app/src/hooks/use-file-explorer-actions.ts` | +| Surface | Key | Source | +| ----------------------- | -------------------------------------------------------- | ------------------------------------------------------- | +| Git status | `checkoutStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` | +| Git diff | `checkoutDiffQueryKey(serverId, cwd, mode, baseRef, ws)` | `packages/app/src/git/query-keys.ts` | +| Forge change request | `checkoutPrStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` | +| Change request timeline | `prPaneTimelineQueryKey({ serverId, cwd, prNumber })` | `packages/app/src/git/pull-request-panel/query-keys.ts` | +| File preview content | `["workspaceFile", serverId, cwd, path]` | `packages/app/src/components/file-pane.tsx` | +| File explorer listings | fetched via `listDirectory(workspaceRoot, path)` | `packages/app/src/hooks/use-file-explorer-actions.ts` | **Workspace-owned (independent per workspace) — keyed by `workspaceId` (falling back to `cwd` only when no `workspaceId` exists):** diff --git a/docs/forge-providers.md b/docs/forge-providers.md new file mode 100644 index 000000000..f633a1922 --- /dev/null +++ b/docs/forge-providers.md @@ -0,0 +1,176 @@ +# Adding a Git Forge to Paseo + +Paseo's forge layer is a registry/manifest system. A forge is a runtime concern: +shared protocol messages carry neutral/open facts, the server adapter owns +behavior, and the app owns bundled presentation/runtime interpretation. + +The maintainer litmus test is the rule of thumb: + +> Adding a new forge means adding files in a new directory/module that implement +> an interface, plus one entry in the centralized registry/manifest for that +> package. + +## The Three Registrations + +For forge `acme`, the expected end state is: + +1. **Protocol manifest** - optional, only when the forge should be presented by + shared manifest data. Add one `ForgeDefinition` to + `packages/protocol/src/forge-manifest.ts`. + +2. **Server adapter** - add `packages/server/src/services/acme-service.ts` + implementing `ForgeService`, any adapter-owned fact types/guards/constants + beside it, and one `defaultForgeRegistry` entry in + `packages/server/src/services/forge-registry.ts`. + +3. **App modules** - a forge splits into a pure logic half and a view half so + logic consumers (URL builders, merge-capability, native checks, and the + Node-based e2e harness) never pull the client rendering stack: + - `packages/app/src/git/forges/acme.ts` - logic: `id`, optional `urlGrammar`, + optional `facts` (schema, merge-capability, native-check fallbacks). No + React/React-Native imports. Register in `CLIENT_FORGE_LOGIC_MODULES` in + `packages/app/src/git/forges/index.ts`. + - `packages/app/src/git/forges/acme.view.tsx` - view: `icon` (SVG component + under `packages/app/src/components/icons/`), optional `brandColor`, optional + `paneContributions`. Register in `CLIENT_FORGE_VIEW_MODULES` in + `packages/app/src/git/forges/view.ts`. + +There should be no protocol typed-union arm, no central app icon/color/url/facts +map, and no central server union of known forge facts. + +## Protocol + +`forgeSpecific` on PR status is an open envelope: + +```ts +z.object({ forge: z.string() }).passthrough(); +``` + +The `forgeSpecific.forge` field is a **facts-family tag**, not the workspace +brand id. Gitea, Forgejo, and Codeberg can all emit `forgeSpecific.forge === +"gitea"` when they share the same facts shape, while top-level `status.forge` +keeps the brand id (`"gitea"`, `"forgejo"`, `"codeberg"`). + +Protocol does not validate per-forge fact fields. Consumers that understand a +facts family validate at runtime with their own schema/guard. Unknown or +schema-mismatched facts render neutrally instead of failing the whole message +parse. This is the version-skew win: an old client can receive facts from a +newer forge and still show the PR/MR in a neutral state. + +Shipped GitHub compatibility stays separate: + +- `status.github` remains accepted for released peers. +- The server keeps the `COMPAT(forgeSpecific)` mirror that copies GitHub facts + into `status.github` for older clients. +- Do not add a compatibility shim unless a released peer (<= 0.1.102) can + actually produce the state. + +## Server + +The server-wide status type only promises: + +```ts +type ForgeSpecificStatusFacts = { forge: string } & Record; +``` + +Adapter-owned files define the typed shapes and guards, for example +`github-facts.ts`, `gitlab-facts.ts`, and `gitea-facts.ts`. The adapter can keep +strong internal types for construction and command guards, but shared server +code must not grow a central list of forge fact arms. + +Register the adapter in `defaultForgeRegistry` with: + +- `createService` +- `matchesHost` from manifest `cloudHosts` +- `probeHost` when self-hosted/Enterprise detection is supported + +Cloud hosts in the manifest are a bounded public-host list, not a self-host +allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge +host that is either a known cloud host or one the CLI is already authenticated +to. Adapter probes must not make anonymous HTTP requests to remote-derived +hosts, and adapters must not route credentials to an unauthenticated host. + +## App + +Each app forge splits into two modules so pure logic never imports the client +rendering stack: + +`acme.ts` exports a `ClientForgeLogicModule`: + +- `id` +- optional `urlGrammar` +- optional `facts` registration (schema, merge-capability, native-check fallbacks) + +`acme.view.tsx` exports a `ClientForgeViewModule`: + +- `id` +- `icon` +- `brandColor` (`null` for neutral; GitHub intentionally uses `null`) +- optional `paneContributions` + +Two registries live under `packages/app/src/git/forges/`: +`CLIENT_FORGE_LOGIC_MODULES` (`index.ts`) drives URL grammar, merge-capability +derivation, and native fallback checks; `CLIENT_FORGE_VIEW_MODULES` (`view.ts`) +drives icon/color lookup and PR-pane contributions. Logic consumers must import +the logic registry only — importing the view registry (or a `.view.tsx` module) +from a logic path pulls react-native and breaks the Node-based e2e harness. + +Per-forge brand colors live on the module, not in `styles/theme.ts`. Use the +Unistyles-safe pattern from `docs/unistyles.md`: no `useUnistyles()`. Brand icon +call sites use `withUnistyles` and a `uniProps` mapping such as: + +```ts +(theme) => ({ color: theme.colorScheme === "light" ? colors.light : colors.dark }); +``` + +Facts modules use one source of truth: a Zod schema. Helpers like +`defineForgeFacts`, `defineNativeFallbackCheck`, and `definePaneContribution` +derive guards from `schema.safeParse` and re-parse before invoking typed +derivers/renderers. That keeps typed derivers away from the open wire envelope. + +## Checklist + +To add `acme`: + +1. Add `acme` to `FORGE_DEFINITIONS` if the shared manifest should know its + label, nouns, icon kind, sign-in CLI, or cloud hosts. +2. Add `acme-service.ts` implementing `ForgeService`. +3. Add `acme-facts.ts` beside the adapter if it reports native facts. +4. Add one `defaultForgeRegistry` entry. +5. Add `packages/app/src/git/forges/acme.ts` (logic) and + `packages/app/src/git/forges/acme.view.tsx` (view). +6. Add one `CLIENT_FORGE_LOGIC_MODULES` entry (`index.ts`) and one + `CLIENT_FORGE_VIEW_MODULES` entry (`view.ts`). +7. Add/update the icon component only if the client bundle should show a brand + mark. +8. If the forge's CI/data model does not fit an existing required + `ForgeService` field, widen the shared interface (plus the protocol schema + and its guards) instead of faking a value — e.g. Gitea Actions runs carry no + check-run id, so `GetCheckDetailsOptions.checkRunId` became optional with + `workflowRunId` as the alternative address. Expect this step to touch + `forge-service.ts`, `messages.ts`, and the call-site guards of the other + adapters. Widening a shared field is not forge-local: it also affects the + already-shipped forges/GitHub call sites and the capability-gated RPC (e.g. + `forgeCheckDetails`), so verify every consumer rather than assuming the change + only reaches the new adapter. +9. Run targeted tests: manifest/registry/resolver, the adapter test, protocol + checkout PR schema, app forge URL/presentation tests, app merge capability, + and any PR-pane native data tests touched. + +Run `npm run typecheck` after each implementation slice. If protocol or client +declarations are stale, run `npm run build:client`; if server/CLI declarations +are stale, run `npm run build:server`. + +## Gotchas + +- GitHub is a normal registry entry plus released compatibility shims. Keep all + real shims tagged with `COMPAT(name)`. +- Gitea-family facts use `forgeSpecific.forge === "gitea"` even when the + top-level brand is Forgejo or Codeberg. +- Brand icons are bundled React components, so they cannot come from protocol + manifest data. +- Source URL grammars are app-side because blob/tree path syntax is + forge-specific. If a forge has no grammar, omit the "Open on ..." source link + rather than constructing a wrong URL. +- GitLab pipeline status constants belong to the GitLab adapter/client module, + not protocol. diff --git a/docs/glossary.md b/docs/glossary.md index 89392767d..cdbdd0565 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -13,9 +13,12 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym. - **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/protocol/src/messages.ts:2113`). - **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`). +- **Forge** — Git hosting service behind Paseo's change-request features: GitHub, GitLab, Gitea, Forgejo, or a future registered adapter. Code: `ForgeService`, `forge-registry`, `forge-resolver`. Use `forge` for internal abstraction and registry IDs; use concrete forge names only when a behavior or RPC is forge-specific. +- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads. +- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo. - **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym. - **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label. -- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, GitHub PR info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). +- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). - **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). - **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`. - **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). @@ -28,7 +31,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent). - **Heartbeat** — Cron-style prompt sent back into the same agent/conversation. MCP: `create_heartbeat`. Use for reminders and babysitting where the status should return inline. - **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`). -- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`). +- **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`). - **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent. - **Composer input** — The text-entry surface inside the composer. Code: `MessageInput` (`packages/app/src/composer/input/input.tsx`). - **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar". diff --git a/docs/i18n.md b/docs/i18n.md index 44b55b478..90e9ff326 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -37,6 +37,15 @@ npx vitest run packages/app/src/i18n/resources.test.ts --bail=1 The parity test catches missing keys across English and every supported locale resource. +## Forge-Variant Copy + +Strings that vary by git forge follow a two-tier rule: + +- **Indeclinable tokens** — brand names ("GitHub", "GitLab"), the PR/MR initialism, number prefixes (`#`/`!`) — are interpolated into a single key (`"Refresh git and {{brand}} state"`). These tokens stay latin and uninflected in every supported locale, so one string per locale suffices. The value comes from the forge manifest via `getForgePresentation`. +- **Sentences containing the full change-request noun** ("pull request" / "merge request" inflects and takes gender/case in translation) use the i18next `context` mechanism: the base key carries the pull-request wording and an `_mr` sibling carries the merge-request wording (`pullRequest` / `pullRequest_mr`). Call sites pass `t(key, { context: getForgePresentation(forge).changeRequestContext })`; an undefined or unknown context falls back to the base key. + +Keys scale per vocabulary family (PR vs MR), not per forge: a new forge picks an existing family in its manifest entry and needs zero locale edits. + ## Migration Order Client UI translation is staged so each pass can migrate complete local copy clusters and keep reviews focused. diff --git a/docs/rpc-namespacing.md b/docs/rpc-namespacing.md index cd949ffdb..5ddb7220e 100644 --- a/docs/rpc-namespacing.md +++ b/docs/rpc-namespacing.md @@ -3,14 +3,14 @@ New WebSocket session RPCs use dotted names with the direction as the final segment: ```ts -checkout.github.set_auto_merge.request; -checkout.github.set_auto_merge.response; +checkout.forge.set_auto_merge.request; +checkout.forge.set_auto_merge.response; ``` The namespace reads left to right: - Domain: `checkout` -- Provider or subsystem: `github` +- Namespace segment: `forge` - Operation: `set_auto_merge`; this segment is a verb, not a noun. If you would name an RPC `noun.request`, name it `get_noun.request` instead. - Direction: `request` or `response` @@ -21,8 +21,8 @@ Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or tran For ordinary correlated RPCs, a `.request` has a matching `.response` with the same prefix. The daemon client may derive the response type mechanically: ```ts -checkout.github.set_auto_merge.request; -// -> checkout.github.set_auto_merge.response +checkout.forge.set_auto_merge.request; +// -> checkout.forge.set_auto_merge.response ``` Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema. @@ -33,7 +33,7 @@ Requests keep their parameters at the top level: ```ts { - type: "checkout.github.set_auto_merge.request", + type: "checkout.forge.set_auto_merge.request", cwd: "/repo", enabled: true, mergeMethod: "squash", @@ -45,7 +45,7 @@ Responses put correlated result data under `payload`: ```ts { - type: "checkout.github.set_auto_merge.response", + type: "checkout.forge.set_auto_merge.response", payload: { cwd: "/repo", enabled: true, @@ -58,14 +58,16 @@ Responses put correlated result data under `payload`: Keep `requestId` in both request and response payloads. It is the correlation key. -## Provider Namespacing +## Forge Namespacing -Provider-specific behavior belongs under the provider segment: +Forge-neutral behavior currently uses `checkout.forge.*` for checkout-scoped operations and `forge.search.*` for forge search; forge-specific names belong here only after schema and session handlers exist: -- `checkout.github.*` for GitHub-specific checkout operations -- `checkout.gitlab.*` for future GitLab-specific checkout operations +- `checkout.forge.*` for operations whose request/response shape is genuinely + forge-neutral and whose implementation dispatches through the forge resolver. +- `checkout.github.*` for existing GitHub-specific compatibility RPCs while + callers migrate to the neutral `checkout.forge.*` shape -Do not put GitHub-specific enums or semantics into generic checkout RPC names. A generic RPC should only exist when the behavior is genuinely provider-neutral. +Do not put GitHub-specific enums or semantics into `checkout.forge.*` RPC names. A generic forge RPC should only exist when the behavior is genuinely forge-neutral. ## Compatibility diff --git a/packages/app/src/attachments/attachment-pill-content.tsx b/packages/app/src/attachments/attachment-pill-content.tsx index 540e0230a..8acc585fb 100644 --- a/packages/app/src/attachments/attachment-pill-content.tsx +++ b/packages/app/src/attachments/attachment-pill-content.tsx @@ -12,6 +12,7 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages"; import type { WorkspaceComposerAttachment } from "@/attachments/types"; import { getFileTypeLabel } from "@/attachments/file-types"; import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils"; +import { getForgePresentation } from "@/git/forge"; import { ICON_SIZE, type Theme } from "@/styles/theme"; export interface AttachmentPillContent { @@ -27,10 +28,16 @@ function getReviewSubtitle(count: number, t: TFunction): string { } function getPullRequestContextSubtitle(attachment: WorkspaceComposerAttachment): string { - if (attachment.kind === "github.pull_request_check") { + if ( + attachment.kind === "forge.change_request_check" || + attachment.kind === "github.pull_request_check" + ) { return "Check logs"; } - if (attachment.kind === "github.pull_request_comment") { + if ( + attachment.kind === "forge.change_request_comment" || + attachment.kind === "github.pull_request_comment" + ) { return "Comment"; } return "Review"; @@ -57,12 +64,21 @@ export function getAgentAttachmentPillContent( title: t("message.attachments.review"), subtitle: getReviewSubtitle(attachment.comments.length, t), }; + case "forge_change_request": { + const presentation = getForgePresentation(attachment.forge ?? "github"); + return { + icon: attachmentGithubPrIcon, + title: attachment.title, + subtitle: `${presentation.changeRequestAbbrev} ${presentation.numberPrefix}${attachment.number}`, + }; + } case "github_pr": return { icon: attachmentGithubPrIcon, title: attachment.title, subtitle: `PR #${attachment.number}`, }; + case "forge_issue": case "github_issue": return { icon: attachmentGithubIssueIcon, diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index 2199ce29f..b862a543a 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -1,6 +1,6 @@ import type { AgentAttachment, - GitHubSearchItem, + ForgeSearchItem, UploadedFileAttachment, } from "@getpaseo/protocol/messages"; @@ -54,6 +54,9 @@ export interface BrowserElementAttachment { } export type PullRequestContextAttachmentKind = + | "forge.change_request_comment" + | "forge.change_request_review" + | "forge.change_request_check" | "github.pull_request_comment" | "github.pull_request_review" | "github.pull_request_check"; @@ -67,6 +70,9 @@ interface PullRequestContextAttachmentFields { } export type PullRequestContextAttachment = + | ({ kind: "forge.change_request_comment" } & PullRequestContextAttachmentFields) + | ({ kind: "forge.change_request_review" } & PullRequestContextAttachmentFields) + | ({ kind: "forge.change_request_check" } & PullRequestContextAttachmentFields) | ({ kind: "github.pull_request_comment" } & PullRequestContextAttachmentFields) | ({ kind: "github.pull_request_review" } & PullRequestContextAttachmentFields) | ({ kind: "github.pull_request_check" } & PullRequestContextAttachmentFields); @@ -89,10 +95,13 @@ export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker"; export type UserComposerAttachment = | { kind: "image"; metadata: AttachmentMetadata } | { kind: "file"; attachment: UploadedFileAttachment } - | { kind: "github_issue"; item: GitHubSearchItem } + | { kind: "forge_issue"; item: ForgeSearchItem } + | { kind: "forge_change_request"; item: ForgeSearchItem } + // COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106 + | { kind: "github_issue"; item: ForgeSearchItem } | { kind: "github_pr"; - item: GitHubSearchItem; + item: ForgeSearchItem; owner?: typeof NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER; }; diff --git a/packages/app/src/attachments/workspace-attachment-utils.ts b/packages/app/src/attachments/workspace-attachment-utils.ts index 141c51d01..d41d11d6b 100644 --- a/packages/app/src/attachments/workspace-attachment-utils.ts +++ b/packages/app/src/attachments/workspace-attachment-utils.ts @@ -10,6 +10,9 @@ export function isPullRequestContextAttachment( attachment: ComposerAttachment | undefined, ): attachment is PullRequestContextAttachment { return ( + attachment?.kind === "forge.change_request_comment" || + attachment?.kind === "forge.change_request_review" || + attachment?.kind === "forge.change_request_check" || attachment?.kind === "github.pull_request_comment" || attachment?.kind === "github.pull_request_review" || attachment?.kind === "github.pull_request_check" diff --git a/packages/app/src/attachments/workspace-attachments-store.ts b/packages/app/src/attachments/workspace-attachments-store.ts index 7010c06dc..ea5b55720 100644 --- a/packages/app/src/attachments/workspace-attachments-store.ts +++ b/packages/app/src/attachments/workspace-attachments-store.ts @@ -81,12 +81,15 @@ function areWorkspaceAttachmentsEqual( } function getContextAttachmentKey(attachment: WorkspaceComposerAttachment): string | null { - const isContextAttachment = - attachment.kind === "chat_history" || - attachment.kind === "github.pull_request_comment" || - attachment.kind === "github.pull_request_review" || - attachment.kind === "github.pull_request_check"; - if (!isContextAttachment) { + if ( + attachment.kind !== "chat_history" && + attachment.kind !== "forge.change_request_comment" && + attachment.kind !== "forge.change_request_review" && + attachment.kind !== "forge.change_request_check" && + attachment.kind !== "github.pull_request_comment" && + attachment.kind !== "github.pull_request_review" && + attachment.kind !== "github.pull_request_check" + ) { return null; } return JSON.stringify({ diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 942818b97..0af8a0398 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -347,6 +347,7 @@ function ExplorerSidebarContent({ testID="explorer-tab-pr" > ; +} diff --git a/packages/app/src/components/icons/forgejo-icon.tsx b/packages/app/src/components/icons/forgejo-icon.tsx new file mode 100644 index 000000000..b42229bab --- /dev/null +++ b/packages/app/src/components/icons/forgejo-icon.tsx @@ -0,0 +1,8 @@ +import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon"; + +const FORGEJO_ICON_PATH = + "M16.7773 0c1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.1175a7.0759 7.0759 0 0 1 4.148-1.4205l.1176-.001 1.3385.0002c.4973-.8827 1.4434-1.4788 2.5288-1.4788 1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.319c.8827.4973 1.4788 1.4434 1.4788 2.5287 0 1.602-1.2986 2.9005-2.9005 2.9005-1.6018 0-2.9004-1.2986-2.9004-2.9005 0-1.0853.596-2.0314 1.4788-2.5287l-.0002-9.9831c0-3.887 3.1195-7.0453 6.9915-7.108l.1176-.001h1.3385C14.7458.5962 15.692 0 16.7773 0ZM7.2227 19.9052c-.6596 0-1.1943.5347-1.1943 1.1943s.5347 1.1943 1.1943 1.1943 1.1944-.5347 1.1944-1.1943-.5348-1.1943-1.1944-1.1943Zm9.5546-10.4644c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Zm0-7.7346c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Z"; + +export function ForgejoIcon(props: SvgPathIconProps) { + return ; +} diff --git a/packages/app/src/components/icons/gitea-icon.tsx b/packages/app/src/components/icons/gitea-icon.tsx new file mode 100644 index 000000000..69181ea45 --- /dev/null +++ b/packages/app/src/components/icons/gitea-icon.tsx @@ -0,0 +1,8 @@ +import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon"; + +const GITEA_ICON_PATH = + "M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"; + +export function GiteaIcon(props: SvgPathIconProps) { + return ; +} diff --git a/packages/app/src/components/icons/gitlab-icon.tsx b/packages/app/src/components/icons/gitlab-icon.tsx new file mode 100644 index 000000000..694d23b70 --- /dev/null +++ b/packages/app/src/components/icons/gitlab-icon.tsx @@ -0,0 +1,8 @@ +import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon"; + +const GITLAB_ICON_PATH = + "M23.6004 9.5927l-.0337-.0862L20.3 .9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.462-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z"; + +export function GitLabIcon(props: SvgPathIconProps) { + return ; +} diff --git a/packages/app/src/components/icons/svg-path-icon.tsx b/packages/app/src/components/icons/svg-path-icon.tsx new file mode 100644 index 000000000..438a4cc75 --- /dev/null +++ b/packages/app/src/components/icons/svg-path-icon.tsx @@ -0,0 +1,24 @@ +import Svg, { Path } from "react-native-svg"; + +export interface SvgPathIconProps { + size?: number; + color?: string; +} + +interface SvgPathIconInput extends SvgPathIconProps { + path: string; + viewBox: string; +} + +export function SvgPathIcon({ + size = 16, + color = "currentColor", + path, + viewBox, +}: SvgPathIconInput) { + return ( + + + + ); +} diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 69e77bc29..bad34715b 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -84,6 +84,7 @@ import { } from "@/components/ui/dropdown-menu"; import { SyncedLoader } from "@/components/synced-loader"; import { useToast } from "@/contexts/toast-context"; +import { getForgePresentation, normalizeForge } from "@/git/forge"; import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning"; import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder"; import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration"; @@ -319,12 +320,14 @@ export function PrBadge({ hint }: { hint: PrHint }) { const textStyle = isHovered ? prBadgeTextHoveredCombined : prBadgeStyles.text; const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state); + const presentation = getForgePresentation(normalizeForge(hint.forge)); return ( )} + {presentation.numberPrefix} {hint.number} diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx index 6b69e4c15..c8629dec4 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -1,4 +1,5 @@ import { memo, useCallback, useMemo, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { ActivityIndicator, Pressable, @@ -18,13 +19,14 @@ import { Monitor, SquareTerminal, } from "lucide-react-native"; -import { GitHubIcon } from "@/components/icons/github-icon"; import { WorkspaceHoverCard } from "@/components/workspace-hover-card"; import { SyncedLoader } from "@/components/synced-loader"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import { useAppSettings } from "@/hooks/use-settings"; import type { Theme } from "@/styles/theme"; import type { PrHint } from "@/git/use-pr-status-query"; +import { getForgePresentation, normalizeForge } from "@/git/forge"; +import { ForgeBrandIcon } from "@/git/forge-icon"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color"; import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; @@ -52,7 +54,6 @@ const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purp const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedGitPullRequest = withUnistyles(GitPullRequest); -const ThemedGitHubIcon = withUnistyles(GitHubIcon); const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedCircleAlert = withUnistyles(CircleAlert); const ThemedSyncedLoader = withUnistyles(SyncedLoader); @@ -62,6 +63,10 @@ const ThemedFolderGit2 = withUnistyles(FolderGit2); const ThemedGlobe = withUnistyles(Globe); const ThemedSquareTerminal = withUnistyles(SquareTerminal); +function renderChecksBadgeForgeIcon(icon: string) { + return ; +} + type SidebarWorkspaceScriptIconKind = "service" | "command"; export function SidebarWorkspaceRowFrame({ @@ -156,7 +161,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte {workspace.prHint ? ( - + ) : null} @@ -290,6 +295,7 @@ function StatusDotOverlay({ } function PrBadge({ hint }: { hint: PrHint }) { + const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); const handlePress = useCallback( (event: GestureResponderEvent) => { @@ -303,6 +309,7 @@ function PrBadge({ hint }: { hint: PrHint }) { [isHovered], ); const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state); + const presentation = getForgePresentation(normalizeForge(hint.forge)); const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []); const handleHoverIn = useCallback(() => setIsHovered(true), []); @@ -316,7 +323,10 @@ function PrBadge({ hint }: { hint: PrHint }) { return ( )} + {presentation.numberPrefix} {hint.number} ); } -function ChecksBadge({ checks }: { checks: PrHint["checks"] }) { +function ChecksBadge({ checks, forge }: { checks: PrHint["checks"]; forge: PrHint["forge"] }) { if (!checks || checks.length === 0) return null; const failed = checks.filter((check) => check.status === "failure").length; if (failed === 0) return null; + const icon = getForgePresentation(normalizeForge(forge)).icon; return ( - + {renderChecksBadgeForgeIcon(icon)} {failed} failed ); diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx index 8bbd2318c..ca2930c25 100644 --- a/packages/app/src/components/workspace-hover-card.tsx +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -23,7 +23,8 @@ import { GitBranch, Server, } from "lucide-react-native"; -import { GitHubIcon } from "@/components/icons/github-icon"; +import { getForgePresentation, normalizeForge } from "@/git/forge"; +import { ForgeBrandIcon } from "@/git/forge-icon"; import type { Theme } from "@/styles/theme"; import { DiffStat } from "@/components/diff-stat"; import { Pressable } from "react-native"; @@ -319,7 +320,11 @@ function WorkspaceHoverCardContent({ {prHint?.checks && prHint.checks.length > 0 ? ( <> - + ) : null} @@ -343,7 +348,6 @@ function HostRow({ serverId }: { serverId: string }): ReactElement | null { } const ThemedExternalLink = withUnistyles(ExternalLink); -const ThemedGitHubIcon = withUnistyles(GitHubIcon); const ThemedCircleCheck = withUnistyles(CircleCheck); const ThemedCircleDot = withUnistyles(CircleDot); const ThemedCircleX = withUnistyles(CircleX); @@ -375,6 +379,10 @@ function InfoRow({ ); } +function renderChecksSummaryForgeIcon(icon: string, iconUniProps: typeof foregroundColorMapping) { + return ; +} + function CopyableInfoRow({ icon: Icon, value, @@ -488,9 +496,11 @@ function ChecksSummaryPill({ function ChecksSummaryContent({ checks, + forge, hovered, }: { checks: NonNullable; + forge: PrHint["forge"]; hovered: boolean; }) { const { t } = useTranslation(); @@ -498,13 +508,14 @@ function ChecksSummaryContent({ const labelStyle = hovered ? checksSummaryLabelHoveredCombined : styles.checksSummaryLabel; const iconUniProps = hovered ? foregroundColorMapping : foregroundMutedColorMapping; + const icon = getForgePresentation(normalizeForge(forge)).icon; return ( <> {hovered ? ( ) : ( - + renderChecksSummaryForgeIcon(icon, iconUniProps) )} {t("workspace.git.pr.sections.checks")} @@ -518,9 +529,11 @@ function ChecksSummaryContent({ function ChecksSummaryPressable({ checks, + forge, url, }: { checks: NonNullable; + forge: PrHint["forge"]; url: string; }) { const handlePress = useCallback(() => { @@ -529,9 +542,9 @@ function ChecksSummaryPressable({ const renderChildren = useCallback( ({ hovered }: { pressed: boolean; hovered?: boolean }) => ( - + ), - [checks], + [checks, forge], ); return ( diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 5dd113688..1ebe3ea9c 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -18,7 +18,10 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; import { encodeImages } from "@/utils/encode-images"; import { toErrorMessage } from "@/utils/error-messages"; -import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit"; +import { + resolveComposerAttachmentSubmitFormat, + splitComposerAttachmentsForSubmit, +} from "@/composer/attachments/submit"; import type { CreateAgentRequestOptions, DaemonClient, @@ -171,6 +174,9 @@ export function WorkspaceSetupDialog() { const [pendingAction, setPendingAction] = useState<"chat" | null>(null); const serverId = pendingWorkspaceSetup?.serverId ?? ""; + const supportsForgeSearch = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true, + ); const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? ""; const displayName = pendingWorkspaceSetup?.displayName?.trim() ?? ""; const workspace = createdWorkspace; @@ -309,7 +315,11 @@ export function WorkspaceSetupDialog() { throw new Error(t("workspaceSetup.errors.selectModel")); } - const wirePayload = splitComposerAttachmentsForSubmit(attachments); + const wirePayload = splitComposerAttachmentsForSubmit(attachments, { + format: resolveComposerAttachmentSubmitFormat({ + supportsForgeAttachments: supportsForgeSearch, + }), + }); const encodedImages = await encodeImages(wirePayload.images); const workspaceDirectory = requireWorkspaceDirectory({ workspaceId: ensuredWorkspace.id, @@ -363,6 +373,7 @@ export function WorkspaceSetupDialog() { t, toast, withConnectedClient, + supportsForgeSearch, ], ); diff --git a/packages/app/src/composer/actions.test.ts b/packages/app/src/composer/actions.test.ts index ad29711a5..facc6a909 100644 --- a/packages/app/src/composer/actions.test.ts +++ b/packages/app/src/composer/actions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages"; +import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { AttachmentMetadata, ComposerAttachment, @@ -38,7 +38,7 @@ const imageMetadata: AttachmentMetadata = { createdAt: 1, }; -const issueItem: GitHubSearchItem = { +const issueItem: ForgeSearchItem = { kind: "issue", number: 101, title: "Fix composer attachments", @@ -50,8 +50,8 @@ const issueItem: GitHubSearchItem = { headRefName: null, }; -const prItem: GitHubSearchItem = { - kind: "pr", +const prItem: ForgeSearchItem = { + kind: "change_request", number: 202, title: "Refactor composer attachments", url: "https://github.com/acme/paseo/pull/202", @@ -357,8 +357,9 @@ describe("dispatchComposerAgentMessage", () => { expect(call.options.images).toEqual([{ data: image.id, mimeType: image.mimeType }]); expect(call.options.attachments).toEqual([ { - type: "github_pr", - mimeType: "application/github-pr", + type: "forge_change_request", + mimeType: "application/paseo-forge-change-request", + forge: "github", number: 202, title: "Refactor composer attachments", url: "https://github.com/acme/paseo/pull/202", @@ -380,6 +381,34 @@ describe("dispatchComposerAgentMessage", () => { expect(userMessage.optimistic).toBe(true); }); + it("can send legacy GitHub attachment payloads for old daemons", async () => { + const client = createFakeSendClient(); + const stream = createFakeStream(); + + await dispatchComposerAgentMessage({ + client, + agentId: "agent", + text: "send old attachment", + attachments: [{ kind: "forge_change_request", item: prItem }], + attachmentSubmitFormat: "legacy-github", + encodeImages: passthroughEncodeImages, + stream, + }); + + expect(client.calls[0].options.attachments).toEqual([ + { + type: "github_pr", + mimeType: "application/github-pr", + number: 202, + title: "Refactor composer attachments", + url: "https://github.com/acme/paseo/pull/202", + body: "PR body", + baseRefName: "main", + headRefName: "composer-attachments", + }, + ]); + }); + it("appends to the existing head when one is present", async () => { const existingItem: StreamItem = { kind: "user_message", @@ -694,12 +723,12 @@ describe("openComposerAttachment", () => { describe("toggleGithubAttachment", () => { it("appends a GitHub issue when not already attached", () => { const next = toggleGithubAttachment([], issueItem); - expect(next).toEqual([{ kind: "github_issue", item: issueItem }]); + expect(next).toEqual([{ kind: "forge_issue", item: issueItem }]); }); it("appends a GitHub PR when not already attached", () => { const next = toggleGithubAttachment([], prItem); - expect(next).toEqual([{ kind: "github_pr", item: prItem }]); + expect(next).toEqual([{ kind: "forge_change_request", item: prItem }]); }); it("removes an existing GitHub item with the same kind+number", () => { @@ -712,12 +741,12 @@ describe("toggleGithubAttachment", () => { { kind: "github_issue", item: issueItem }, { kind: "github_pr", item: prItem }, ]; - const otherIssue: GitHubSearchItem = { ...issueItem, number: 999 }; + const otherIssue: ForgeSearchItem = { ...issueItem, number: 999 }; const next = toggleGithubAttachment(start, otherIssue); expect(next).toEqual([ { kind: "github_issue", item: issueItem }, { kind: "github_pr", item: prItem }, - { kind: "github_issue", item: otherIssue }, + { kind: "forge_issue", item: otherIssue }, ]); }); }); @@ -747,7 +776,7 @@ describe("toggleGithubAttachmentFromPicker", () => { markGithubAttachmentRemoved, }); - expect(next).toEqual([{ kind: "github_issue", item: issueItem }]); + expect(next).toEqual([{ kind: "forge_issue", item: issueItem }]); expect(markGithubAttachmentRemoved).not.toHaveBeenCalled(); }); }); @@ -755,8 +784,8 @@ describe("toggleGithubAttachmentFromPicker", () => { describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => { it("locates items via their composite kind:number id", () => { expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem); - expect(findGithubItemByOption([issueItem, prItem], "pr:202")).toBe(prItem); - expect(findGithubItemByOption([issueItem], "pr:404")).toBeUndefined(); + expect(findGithubItemByOption([issueItem, prItem], "change_request:202")).toBe(prItem); + expect(findGithubItemByOption([issueItem], "change_request:404")).toBeUndefined(); }); it("recognizes when an attachment list already contains a matching GitHub item", () => { diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index f2a90c8b7..8fd16f37c 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -1,4 +1,4 @@ -import type { GitHubSearchItem } from "@getpaseo/protocol/messages"; +import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { AttachmentMetadata, ComposerAttachment, @@ -8,7 +8,10 @@ import { isWorkspaceAttachment, userAttachmentsOnly, } from "@/attachments/workspace-attachment-utils"; -import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit"; +import { + splitComposerAttachmentsForSubmit, + type ComposerAttachmentSubmitFormat, +} from "@/composer/attachments/submit"; import { appendOptimisticUserMessageToStream, buildOptimisticUserMessage, @@ -162,6 +165,7 @@ export interface DispatchComposerAgentMessageInput { agentId: string; text: string; attachments: ComposerAttachment[]; + attachmentSubmitFormat?: ComposerAttachmentSubmitFormat; encodeImages: ( images: AttachmentMetadata[], ) => Promise | undefined>; @@ -171,7 +175,9 @@ export interface DispatchComposerAgentMessageInput { export async function dispatchComposerAgentMessage( input: DispatchComposerAgentMessageInput, ): Promise { - const wirePayload = splitComposerAttachmentsForSubmit(input.attachments); + const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, { + format: input.attachmentSubmitFormat, + }); const messageId = generateMessageId(); const userMessage = buildOptimisticUserMessage({ id: messageId, @@ -342,33 +348,44 @@ export function openComposerAttachment(input: OpenComposerAttachmentInput): void input.openExternalUrl(input.attachment.item.url); } -export function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment { - return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item }; +export function buildForgeAttachment(item: ForgeSearchItem): UserComposerAttachment { + return item.kind === "change_request" + ? { kind: "forge_change_request", item } + : { kind: "forge_issue", item }; } -function isGithubAttachment( +function isForgeAttachment( attachment: UserComposerAttachment, -): attachment is Extract { - return attachment.kind === "github_issue" || attachment.kind === "github_pr"; +): attachment is Extract< + UserComposerAttachment, + { kind: "forge_issue" | "forge_change_request" | "github_issue" | "github_pr" } +> { + return ( + attachment.kind === "forge_issue" || + attachment.kind === "forge_change_request" || + // COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106 + attachment.kind === "github_issue" || + attachment.kind === "github_pr" + ); } -export function toggleGithubAttachment( +export function toggleForgeAttachment( current: UserComposerAttachment[], - item: GitHubSearchItem, + item: ForgeSearchItem, ): UserComposerAttachment[] { const matches = (attachment: UserComposerAttachment) => - isGithubAttachment(attachment) && + isForgeAttachment(attachment) && attachment.item.kind === item.kind && attachment.item.number === item.number; if (current.some(matches)) { return current.filter((attachment) => !matches(attachment)); } - return [...current, buildGithubAttachment(item)]; + return [...current, buildForgeAttachment(item)]; } interface ToggleGithubAttachmentFromPickerInput { current: UserComposerAttachment[]; - item: GitHubSearchItem; + item: ForgeSearchItem; markGithubAttachmentRemoved: (attachment: UserComposerAttachment) => void; } @@ -379,31 +396,33 @@ export function toggleGithubAttachmentFromPicker({ }: ToggleGithubAttachmentFromPickerInput): UserComposerAttachment[] { const existingAttachment = current.find( (attachment) => - isGithubAttachment(attachment) && + isForgeAttachment(attachment) && attachment.item.kind === item.kind && attachment.item.number === item.number, ); if (existingAttachment) { markGithubAttachmentRemoved(existingAttachment); } - return toggleGithubAttachment(current, item); + return toggleForgeAttachment(current, item); } export function findGithubItemByOption( - items: readonly GitHubSearchItem[], + items: readonly ForgeSearchItem[], optionId: string, -): GitHubSearchItem | undefined { +): ForgeSearchItem | undefined { return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId); } export function isAttachmentSelectedForGithubItem( current: readonly ComposerAttachment[], - item: GitHubSearchItem, + item: ForgeSearchItem, ): boolean { return userAttachmentsOnly(current).some( (attachment) => - isGithubAttachment(attachment) && + isForgeAttachment(attachment) && attachment.item.kind === item.kind && attachment.item.number === item.number, ); } + +export const toggleGithubAttachment = toggleForgeAttachment; diff --git a/packages/app/src/composer/attachments/submit.ts b/packages/app/src/composer/attachments/submit.ts index add962ec3..f814d06c0 100644 --- a/packages/app/src/composer/attachments/submit.ts +++ b/packages/app/src/composer/attachments/submit.ts @@ -5,14 +5,38 @@ import { workspaceAttachmentToSubmitAttachment, } from "@/attachments/workspace-attachment-utils"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; -import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments"; +import { + buildForgeAttachmentFromSearchItem, + buildLegacyGitHubAttachmentFromSearchItem, +} from "@/utils/review-attachments"; -export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachment[]): { +export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github"; + +interface SplitComposerAttachmentsOptions { + format?: ComposerAttachmentSubmitFormat; +} + +export function resolveComposerAttachmentSubmitFormat(input: { + supportsForgeAttachments?: boolean; +}): ComposerAttachmentSubmitFormat { + // COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28. + return input.supportsForgeAttachments === false ? "legacy-github" : "forge"; +} + +export function splitComposerAttachmentsForSubmit( + attachments: ComposerAttachment[], + options: SplitComposerAttachmentsOptions = {}, +): { images: ImageAttachment[]; attachments: AgentAttachment[]; } { const images: ImageAttachment[] = []; const agentAttachments: AgentAttachment[] = []; + // COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28. + const buildSearchAttachment = + options.format === "legacy-github" + ? buildLegacyGitHubAttachmentFromSearchItem + : buildForgeAttachmentFromSearchItem; for (const attachment of attachments) { if (attachment.kind === "image") { @@ -36,7 +60,7 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen continue; } - const reviewAttachment = buildGitHubAttachmentFromSearchItem(attachment.item); + const reviewAttachment = buildSearchAttachment(attachment.item); if (reviewAttachment) { agentAttachments.push(reviewAttachment); } diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index f44b8ef0a..d4fddc412 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -1,7 +1,10 @@ import { useCallback, useMemo, useReducer } from "react"; import { useTranslation } from "react-i18next"; import type { ComposerAttachment } from "@/attachments/types"; -import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit"; +import { + resolveComposerAttachmentSubmitFormat, + splitComposerAttachmentsForSubmit, +} from "@/composer/attachments/submit"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useSessionStore } from "@/stores/session-store"; import { @@ -236,10 +239,23 @@ export function useDraftAgentCreateFlow({ } dispatch({ type: "DRAFT_SET_ERROR", message: "" }); - const wirePayload = splitComposerAttachmentsForSubmit(attachments); + const trimmedPrompt = text.trim(); + const pendingServerId = getPendingServerId(); + if (!pendingServerId) { + const error = new Error(t("composer.errors.noHostSelected")); + dispatch({ type: "DRAFT_SET_ERROR", message: error.message }); + throw error; + } + const supportsForgeSearch = + useSessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch === + true; + const wirePayload = splitComposerAttachmentsForSubmit(attachments, { + format: resolveComposerAttachmentSubmitFormat({ + supportsForgeAttachments: supportsForgeSearch, + }), + }); const images = wirePayload.images; - const trimmedPrompt = text.trim(); const hasAttachmentContent = images.length > 0 || wirePayload.attachments.length > 0; if (!trimmedPrompt && !hasAttachmentContent && !allowEmptyText) { const error = new Error(t("composer.errors.initialPromptRequired")); @@ -258,13 +274,6 @@ export function useDraftAgentCreateFlow({ throw error; } - const pendingServerId = getPendingServerId(); - if (!pendingServerId) { - const error = new Error(t("composer.errors.noHostSelected")); - dispatch({ type: "DRAFT_SET_ERROR", message: error.message }); - throw error; - } - const attempt: CreateAttempt = { clientMessageId: generateMessageId(), text: trimmedPrompt, diff --git a/packages/app/src/composer/github/auto-attach.test.tsx b/packages/app/src/composer/github/auto-attach.test.tsx index 8d283ff05..4c196c5a3 100644 --- a/packages/app/src/composer/github/auto-attach.test.tsx +++ b/packages/app/src/composer/github/auto-attach.test.tsx @@ -7,17 +7,17 @@ import React, { type ReactNode } from "react"; import { useState } from "react"; import { describe, expect, it, vi } from "vitest"; import type { UserComposerAttachment } from "@/attachments/types"; -import type { GitHubSearchClient } from "@/git/use-github-search-query"; -import type { GitHubSearchItem, GitHubSearchResponse } from "@getpaseo/protocol/messages"; +import type { ForgeSearchClient } from "@/git/use-forge-search-query"; +import type { ForgeSearchItem, ForgeSearchResponse } from "@getpaseo/protocol/messages"; import { useComposerGithubAutoAttach } from "./auto-attach"; -type GitHubSearchPayload = GitHubSearchResponse["payload"]; +type ForgeSearchPayload = ForgeSearchResponse["payload"]; const remoteUrl = "git@github.com:acme/paseo.git"; const cwd = "/repo"; -const pr101: GitHubSearchItem = { - kind: "pr", +const pr101: ForgeSearchItem = { + kind: "change_request", number: 101, title: "Attach PR", url: "https://github.com/acme/paseo/pull/101", @@ -28,7 +28,7 @@ const pr101: GitHubSearchItem = { headRefName: "feature", }; -const issue202: GitHubSearchItem = { +const issue202: ForgeSearchItem = { kind: "issue", number: 202, title: "Attach issue", @@ -52,22 +52,20 @@ interface HarnessInput { remote?: string | null; } -function githubPayload(items: GitHubSearchItem[], requestId: string): GitHubSearchPayload { +function githubPayload(items: ForgeSearchItem[], requestId: string): ForgeSearchPayload { return { items, - githubFeaturesEnabled: true, + authState: "authenticated", error: null, requestId, }; } -function createSearchClient( - items: GitHubSearchItem[], -): GitHubSearchClient & { calls: SearchCall[] } { +function createSearchClient(items: ForgeSearchItem[]): ForgeSearchClient & { calls: SearchCall[] } { const calls: SearchCall[] = []; return { calls, - async searchGitHub(options) { + async searchForge(options) { calls.push(options); return githubPayload(items, `search-${options.query}`); }, @@ -87,7 +85,7 @@ function createWrapper() { }; } -function useHarness(client: GitHubSearchClient, input: HarnessInput = {}) { +function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) { const [text, setText] = useState(input.initialText ?? ""); const [attachments, setAttachments] = useState( input.initialAttachments ?? [], @@ -130,7 +128,7 @@ describe("useComposerGithubAutoAttach", () => { }); await flushDebounce(); - expect(result.current.attachments).toEqual([{ kind: "github_pr", item: pr101 }]); + expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]); expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]); vi.useRealTimers(); }); @@ -201,8 +199,8 @@ describe("useComposerGithubAutoAttach", () => { await flushDebounce(); expect(result.current.attachments).toEqual([ - { kind: "github_pr", item: pr101 }, - { kind: "github_issue", item: issue202 }, + { kind: "forge_change_request", item: pr101 }, + { kind: "forge_issue", item: issue202 }, ]); expect(client.calls).toEqual([ { cwd, query: "101", limit: 20 }, diff --git a/packages/app/src/composer/github/auto-attach.ts b/packages/app/src/composer/github/auto-attach.ts index c92f7eac3..5873b438d 100644 --- a/packages/app/src/composer/github/auto-attach.ts +++ b/packages/app/src/composer/github/auto-attach.ts @@ -9,12 +9,9 @@ import { type SetStateAction, } from "react"; import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; -import { - buildGithubSearchQueryOptions, - type GitHubSearchClient, -} from "@/git/use-github-search-query"; +import { buildForgeSearchQueryOptions, type ForgeSearchClient } from "@/git/use-forge-search-query"; import { extractGithubRefs, type GithubRef } from "@/utils/github-refs"; -import type { GitHubSearchItem } from "@getpaseo/protocol/messages"; +import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "../actions"; const AUTO_ATTACH_DEBOUNCE_MS = 300; @@ -23,10 +20,11 @@ interface ComposerGithubAutoAttachInput { text: string; remoteUrl: string | null | undefined; attachments: UserComposerAttachment[]; - client: GitHubSearchClient | null; + client: ForgeSearchClient | null; isConnected: boolean; serverId: string; cwd: string; + supportsForgeSearch?: boolean; setAttachments: Dispatch>; } @@ -75,6 +73,7 @@ export function useComposerGithubAutoAttach( params.isConnected, params.serverId, params.cwd, + params.supportsForgeSearch, queryClient, ]); @@ -193,11 +192,12 @@ async function fetchGithubRefSearch({ try { return await queryClient.fetchQuery( - buildGithubSearchQueryOptions({ + buildForgeSearchQueryOptions({ client: snapshot.client, serverId: snapshot.serverId, cwd: snapshot.cwd, query: String(ref.number), + supportsForgeSearch: snapshot.supportsForgeSearch, enabled: true, }), ); @@ -216,12 +216,12 @@ function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubR return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref)); } -function githubItemMatchesRef(item: GitHubSearchItem, ref: GithubRef): boolean { +function githubItemMatchesRef(item: ForgeSearchItem, ref: GithubRef): boolean { return item.kind === githubItemKind(ref) && item.number === ref.number; } -function githubItemKind(ref: GithubRef): GitHubSearchItem["kind"] { - return ref.kind === "pull" ? "pr" : "issue"; +function githubItemKind(ref: GithubRef): ForgeSearchItem["kind"] { + return ref.kind === "pull" ? "change_request" : "issue"; } function githubRefKey(ref: GithubRef): string { @@ -232,7 +232,10 @@ function attachmentKey(attachment: ComposerAttachment | undefined): string | nul if ( !attachment || attachment.kind === "image" || - (attachment.kind !== "github_pr" && attachment.kind !== "github_issue") + (attachment.kind !== "forge_change_request" && + attachment.kind !== "forge_issue" && + attachment.kind !== "github_pr" && + attachment.kind !== "github_issue") ) { return null; } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 3cd4760f7..d214d794b 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -28,7 +28,6 @@ import { CircleDot, FileText, GitPullRequest, - Github, Image as ImageIcon, Paperclip, } from "lucide-react-native"; @@ -94,7 +93,7 @@ import { submitAgentInput } from "@/composer/submit"; import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope"; import { useAppSettings } from "@/hooks/use-settings"; import { isWeb, isNative } from "@/constants/platform"; -import type { GitHubSearchItem } from "@getpaseo/protocol/messages"; +import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { AttachmentMetadata, ComposerAttachment, @@ -102,6 +101,7 @@ import type { WorkspaceComposerAttachment, } from "@/attachments/types"; import type { PickedFile } from "@/attachments/picked-file"; +import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit"; import { composerWorkspaceAttachment } from "@/composer/attachments/workspace"; import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store"; import { droppedItemsToPickedFiles } from "@/composer/attachments/drop"; @@ -111,8 +111,11 @@ import { AttachmentLabel, AttachmentPill, AttachmentThumbnail } from "@/componen import { AttachmentLightbox } from "@/components/attachment-lightbox"; import { openExternalUrl } from "@/utils/open-external-url"; import { useIsDictationReady } from "@/hooks/use-is-dictation-ready"; -import { useGithubSearchQuery } from "@/git/use-github-search-query"; +import { useForgeSearchQuery } from "@/git/use-forge-search-query"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; +import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; +import { getForgePresentation } from "@/git/forge"; +import { ForgeBrandIcon } from "@/git/forge-icon"; import { useComposerGithubAutoAttach } from "./github/auto-attach"; import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands"; @@ -281,8 +284,8 @@ interface RenderAttachmentTrayArgs { openImage: string; removeImage: string; removeFile: string; - openGithub: (kind: string, number: number) => string; - removeGithub: (kind: string, number: number) => string; + openGithub: (kind: string, numberLabel: string) => string; + removeGithub: (kind: string, numberLabel: string) => string; }; } @@ -616,13 +619,16 @@ function ImageAttachmentPill({ } interface GithubAttachmentPillProps { - attachment: Extract; + attachment: Extract< + ComposerAttachment, + { kind: "forge_change_request" | "forge_issue" | "github_pr" | "github_issue" } + >; index: number; disabled: boolean; onOpen: (attachment: ComposerAttachment) => void; onRemove: (index: number) => void; - openLabel: (kind: string, number: number) => string; - removeLabel: (kind: string, number: number) => string; + openLabel: (kind: string, numberLabel: string) => string; + removeLabel: (kind: string, numberLabel: string) => string; } function GithubAttachmentPill({ @@ -635,7 +641,11 @@ function GithubAttachmentPill({ removeLabel, }: GithubAttachmentPillProps) { const item = attachment.item; - const kindLabel = item.kind === "pr" ? "PR" : "issue"; + const presentation = getForgePresentation(item.forge ?? "github"); + const isChangeRequest = item.kind === "change_request"; + const kindLabel = isChangeRequest ? presentation.changeRequestAbbrev : "issue"; + const subtitleKind = isChangeRequest ? presentation.changeRequestAbbrev : "Issue"; + const numberPrefix = isChangeRequest ? presentation.numberPrefix : presentation.issueNumberPrefix; const handleOpen = useCallback(() => { onOpen(attachment); }, [onOpen, attachment]); @@ -647,14 +657,14 @@ function GithubAttachmentPill({ testID="composer-github-attachment-pill" onOpen={handleOpen} onRemove={handleRemove} - openAccessibilityLabel={openLabel(kindLabel, item.number)} - removeAccessibilityLabel={removeLabel(kindLabel, item.number)} + openAccessibilityLabel={openLabel(kindLabel, `${numberPrefix}${item.number}`)} + removeAccessibilityLabel={removeLabel(kindLabel, `${numberPrefix}${item.number}`)} disabled={disabled} > ); @@ -703,8 +713,8 @@ interface GithubPickerOptionProps { testID: string; active: boolean; selected: boolean; - item: GitHubSearchItem; - onToggle: (item: GitHubSearchItem) => void; + item: ForgeSearchItem; + onToggle: (item: ForgeSearchItem) => void; } function GithubPickerOption({ @@ -720,7 +730,7 @@ function GithubPickerOption({ }, [onToggle, item]); const leadingSlot = useMemo( () => - item.kind === "pr" ? ( + item.kind === "change_request" ? ( ) : ( @@ -1045,6 +1055,9 @@ export function Composer({ }); const setSelectedAttachments = onChangeAttachments; const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd }); + const supportsForgeSearch = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true, + ); const githubAutoAttach = useComposerGithubAutoAttach({ text: userInput, remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status), @@ -1053,6 +1066,7 @@ export function Composer({ isConnected, serverId, cwd, + supportsForgeSearch, setAttachments: setSelectedAttachments, }); const [cursorIndex, setCursorIndex] = useState(0); @@ -1212,12 +1226,23 @@ export function Composer({ agentId: targetAgentId, text, attachments: sendAttachments, + attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({ + supportsForgeAttachments: supportsForgeSearch, + }), encodeImages, stream, }); onAttentionPromptSend?.(); }; - }, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead, t]); + }, [ + client, + onAttentionPromptSend, + serverId, + setAgentStreamTail, + setAgentStreamHead, + supportsForgeSearch, + t, + ]); useEffect(() => { onSubmitMessageRef.current = onSubmitMessage; @@ -1711,12 +1736,33 @@ export function Composer({ [contextWindowMeter, isCompactLayout], ); + const hasGithubAttachment = useMemo( + () => + selectedAttachments.some( + (attachment) => + attachment.kind === "forge_change_request" || + attachment.kind === "forge_issue" || + attachment.kind === "github_pr" || + attachment.kind === "github_issue", + ), + [selectedAttachments], + ); + // Composer stays mounted for each focused agent, so avoid a forge CLI call + // until the forge-specific picker or attachment presentation is visible. + const { forge } = useCheckoutPrStatusQuery({ + serverId, + cwd, + enabled: isConnected && cwd.trim().length > 0 && (isGithubPickerOpen || hasGithubAttachment), + }); + const forgePresentation = useMemo(() => getForgePresentation(forge), [forge]); + const githubSearchQueryTrimmed = githubSearchQuery.trim(); - const githubSearchResultsQuery = useGithubSearchQuery({ + const githubSearchResultsQuery = useForgeSearchQuery({ client, serverId, cwd, query: githubSearchQueryTrimmed, + supportsForgeSearch, enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd), }); @@ -1724,11 +1770,18 @@ export function Composer({ const githubSearchItems = useMemo(() => githubSearchItemsRaw ?? [], [githubSearchItemsRaw]); const githubSearchOptions: ComboboxOption[] = useMemo( () => - githubSearchItems.map((item) => ({ - id: `${item.kind}:${item.number}`, - label: `#${item.number} ${item.title}`, - description: githubSearchQueryTrimmed, - })), + githubSearchItems.map((item) => { + const presentation = getForgePresentation(item.forge ?? "github"); + const numberPrefix = + item.kind === "change_request" + ? presentation.numberPrefix + : presentation.issueNumberPrefix; + return { + id: `${item.kind}:${item.number}`, + label: `${numberPrefix}${item.number} ${item.title}`, + description: githubSearchQueryTrimmed, + }; + }), [githubSearchItems, githubSearchQueryTrimmed], ); @@ -1744,8 +1797,10 @@ export function Composer({ }, { id: "github", - label: t("composer.attachments.addIssueOrPr"), - icon: , + label: t("composer.attachments.addIssueOrPr", { + context: forgePresentation.changeRequestContext, + }), + icon: renderForgeAttachmentIcon(forgePresentation.icon), onSelect: () => { setIsGithubPickerOpen(true); }, @@ -1759,11 +1814,11 @@ export function Composer({ }, }, ], - [handlePickImage, handlePickFile, t], + [handlePickImage, handlePickFile, t, forgePresentation], ); const handleToggleGithubItem = useCallback( - (item: GitHubSearchItem) => { + (item: ForgeSearchItem) => { const nextAttachments = toggleGithubAttachmentFromPicker({ current: attachments, item, @@ -1868,10 +1923,10 @@ export function Composer({ openImage: t("composer.attachments.openImage"), removeImage: t("composer.attachments.removeImage"), removeFile: t("composer.attachments.removeFile"), - openGithub: (kind: string, number: number) => - t("composer.attachments.openGithub", { kind, number }), - removeGithub: (kind: string, number: number) => - t("composer.attachments.removeGithub", { kind, number }), + openGithub: (kind: string, numberLabel: string) => + t("composer.attachments.openGithub", { kind, number: numberLabel }), + removeGithub: (kind: string, numberLabel: string) => + t("composer.attachments.removeGithub", { kind, number: numberLabel }), }, }), [handleOpenAttachment, handleRemoveAttachment, isComposerLocked, selectedAttachments, t], @@ -1983,8 +2038,12 @@ export function Composer({ onSelect={noop} keepOpenOnSelect searchable - searchPlaceholder={t("composer.github.searchPlaceholder")} - title={t("composer.github.title")} + searchPlaceholder={t("composer.github.searchPlaceholder", { + context: forgePresentation.changeRequestContext, + })} + title={t("composer.github.title", { + context: forgePresentation.changeRequestContext, + })} open={isGithubPickerOpen} onOpenChange={handleGithubPickerOpenChange} onSearchQueryChange={setGithubSearchQuery} @@ -2185,12 +2244,16 @@ const ThemedAudioLines = withUnistyles(AudioLines); const ThemedPaperclip = withUnistyles(Paperclip); const ThemedImageIcon = withUnistyles(ImageIcon); const ThemedFileText = withUnistyles(FileText); -const ThemedGithub = withUnistyles(Github); - const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground }); +function renderForgeAttachmentIcon(icon: string): ReactElement { + return ( + + ); +} + const githubPrPillIcon = ( ); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index f919e41da..b03702a81 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -62,7 +62,10 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen import { resolveProjectPlacement } from "@/utils/project-placement"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import type { AttachmentMetadata } from "@/attachments/types"; -import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit"; +import { + resolveComposerAttachmentSubmitFormat, + splitComposerAttachmentsForSubmit, +} from "@/composer/attachments/submit"; import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking"; import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts"; import { @@ -728,7 +731,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider if (queue && queue.length > 0) { const [next, ...rest] = queue; if (sendAgentMessageRef.current) { - const wirePayload = splitComposerAttachmentsForSubmit(next.attachments); + const supportsForgeSearch = + useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === + true; + const wirePayload = splitComposerAttachmentsForSubmit(next.attachments, { + format: resolveComposerAttachmentSubmitFormat({ + supportsForgeAttachments: supportsForgeSearch, + }), + }); void sendAgentMessageRef.current( agent.id, next.text, diff --git a/packages/app/src/git/actions-store.test.ts b/packages/app/src/git/actions-store.test.ts index 111547163..75a397775 100644 --- a/packages/app/src/git/actions-store.test.ts +++ b/packages/app/src/git/actions-store.test.ts @@ -183,66 +183,77 @@ describe("checkout-git-actions-store", () => { ).toBe("idle"); }); - it("enables PR auto-merge when the daemon advertises auto-merge actions", async () => { - const client = { - checkoutGithubSetAutoMerge: vi.fn(async () => ({ + for (const rpc of [ + { + label: "forge", + method: "checkoutForgeSetAutoMerge", + feature: "checkoutForgeSetAutoMerge", + }, + { + label: "legacy GitHub", + method: "checkoutGithubSetAutoMerge", + feature: "checkoutGithubSetAutoMerge", + }, + ] as const) { + it(`enables PR auto-merge through the ${rpc.label} RPC`, async () => { + const setAutoMerge = vi.fn(async () => ({ enabled: true, success: true, error: null, - })), - }; - useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); - useSessionStore.getState().updateSessionServerInfo(serverId, { - serverId, - hostname: null, - version: null, - features: { checkoutGithubSetAutoMerge: true }, - }); + })); + const client = { [rpc.method]: setAutoMerge }; + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); + useSessionStore.getState().updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: null, + features: { [rpc.feature]: true }, + }); - await useCheckoutGitActionsStore - .getState() - .enablePrAutoMerge({ serverId, cwd, method: "squash" }); - - expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, { - enabled: true, - method: "squash", - }); - expect( - useCheckoutGitActionsStore + await useCheckoutGitActionsStore .getState() - .getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }), - ).toBe("success"); - }); + .enablePrAutoMerge({ serverId, cwd, method: "squash" }); - it("disables PR auto-merge when the daemon advertises auto-merge actions", async () => { - const client = { - checkoutGithubSetAutoMerge: vi.fn(async () => ({ + expect(setAutoMerge).toHaveBeenCalledWith(cwd, { + enabled: true, + method: "squash", + }); + expect( + useCheckoutGitActionsStore + .getState() + .getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }), + ).toBe("success"); + }); + + it(`disables PR auto-merge through the ${rpc.label} RPC`, async () => { + const setAutoMerge = vi.fn(async () => ({ enabled: false, success: true, error: null, - })), - }; - useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); - useSessionStore.getState().updateSessionServerInfo(serverId, { - serverId, - hostname: null, - version: null, - features: { checkoutGithubSetAutoMerge: true }, + })); + const client = { [rpc.method]: setAutoMerge }; + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); + useSessionStore.getState().updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: null, + features: { [rpc.feature]: true }, + }); + + await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd }); + + expect(setAutoMerge).toHaveBeenCalledWith(cwd, { enabled: false }); + expect( + useCheckoutGitActionsStore + .getState() + .getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }), + ).toBe("success"); }); - - await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd }); - - expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, { enabled: false }); - expect( - useCheckoutGitActionsStore - .getState() - .getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }), - ).toBe("success"); - }); + } it("does not call PR auto-merge RPCs when the daemon lacks the feature flag", async () => { const client = { - checkoutGithubSetAutoMerge: vi.fn(async () => ({ + checkoutForgeSetAutoMerge: vi.fn(async () => ({ enabled: true, success: true, error: null, @@ -258,9 +269,9 @@ describe("checkout-git-actions-store", () => { await expect( useCheckoutGitActionsStore.getState().enablePrAutoMerge({ serverId, cwd, method: "merge" }), - ).rejects.toThrow("Update the host to use GitHub auto-merge actions."); + ).rejects.toThrow("Update the host to use auto-merge actions."); - expect(client.checkoutGithubSetAutoMerge).not.toHaveBeenCalled(); + expect(client.checkoutForgeSetAutoMerge).not.toHaveBeenCalled(); expect( useCheckoutGitActionsStore .getState() diff --git a/packages/app/src/git/actions-store.ts b/packages/app/src/git/actions-store.ts index bb735d954..34e7b5443 100644 --- a/packages/app/src/git/actions-store.ts +++ b/packages/app/src/git/actions-store.ts @@ -42,11 +42,19 @@ function resolveClient(serverId: string) { return client; } -function assertGitHubAutoMergeActionsSupported(serverId: string) { +type AutoMergeActionsRpc = "forge" | "github"; + +function resolveAutoMergeActionsRpc(serverId: string): AutoMergeActionsRpc { const session = useSessionStore.getState().sessions[serverId]; - if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge !== true) { - throw new Error("Update the host to use GitHub auto-merge actions."); + if (session?.serverInfo?.features?.checkoutForgeSetAutoMerge === true) { + return "forge"; } + // COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once + // all supported clients use checkout.forge.set_auto_merge.*. + if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge === true) { + return "github"; + } + throw new Error("Update the host to use auto-merge actions."); } function setStatus( @@ -281,14 +289,19 @@ export const useCheckoutGitActionsStore = create() }, enablePrAutoMerge: async ({ serverId, cwd, method }) => { - assertGitHubAutoMergeActionsSupported(serverId); + const rpc = resolveAutoMergeActionsRpc(serverId); await runCheckoutAction({ serverId, cwd, actionId: `enable-pr-auto-merge-${method}`, run: async () => { const client = resolveClient(serverId); - const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method }); + // COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once + // all supported clients use checkout.forge.set_auto_merge.*. + const payload = + rpc === "forge" + ? await client.checkoutForgeSetAutoMerge(cwd, { enabled: true, method }) + : await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method }); if (payload.error) { throw new Error(payload.error.message); } @@ -297,14 +310,19 @@ export const useCheckoutGitActionsStore = create() }, disablePrAutoMerge: async ({ serverId, cwd }) => { - assertGitHubAutoMergeActionsSupported(serverId); + const rpc = resolveAutoMergeActionsRpc(serverId); await runCheckoutAction({ serverId, cwd, actionId: "disable-pr-auto-merge", run: async () => { const client = resolveClient(serverId); - const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: false }); + // COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once + // all supported clients use checkout.forge.set_auto_merge.*. + const payload = + rpc === "forge" + ? await client.checkoutForgeSetAutoMerge(cwd, { enabled: false }) + : await client.checkoutGithubSetAutoMerge(cwd, { enabled: false }); if (payload.error) { throw new Error(payload.error.message); } diff --git a/packages/app/src/git/checkout-status-cache.test.ts b/packages/app/src/git/checkout-status-cache.test.ts index 788c6eb1b..5792a17fa 100644 --- a/packages/app/src/git/checkout-status-cache.test.ts +++ b/packages/app/src/git/checkout-status-cache.test.ts @@ -1,11 +1,12 @@ // @vitest-environment jsdom -// The review draft store persists through AsyncStorage's web shim, which needs window. -import "@/test/window-local-storage"; import { QueryClient } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages"; import { checkoutPrStatusQueryKey, checkoutStatusQueryKey } from "@/git/query-keys"; -import { prPaneTimelineQueryKey } from "@/git/pull-request-panel/query-keys"; +import { + prPanePipelineQueryKey, + prPaneTimelineQueryKey, +} from "@/git/pull-request-panel/query-keys"; import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store"; import { applyCheckoutStatusUpdateFromEvent, @@ -14,6 +15,14 @@ import { fetchCheckoutStatus, } from "./checkout-status-cache"; +vi.mock("@react-native-async-storage/async-storage", () => ({ + default: { + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => undefined), + removeItem: vi.fn(async () => undefined), + }, +})); + const serverId = "server-1"; const cwd = "/repo"; @@ -41,6 +50,7 @@ function prStatus(overrides: Partial = {}): CheckoutPrS return { cwd, status: { + forge: "github", url: "https://github.com/getpaseo/paseo/pull/42", title: "My PR", state: "open", @@ -54,6 +64,8 @@ function prStatus(overrides: Partial = {}): CheckoutPrS reviewDecision: null, }, githubFeaturesEnabled: true, + authState: "authenticated", + forge: "github", error: null, requestId: "pr-status-1", ...overrides, @@ -62,7 +74,7 @@ function prStatus(overrides: Partial = {}): CheckoutPrS function checkoutStatusUpdate( payload: CheckoutStatusPayload, - extraPrStatus?: CheckoutPrStatusPayload, + extraPrStatus?: NonNullable, ): CheckoutStatusUpdate { return { type: "checkout_status_update", @@ -140,6 +152,29 @@ describe("applyCheckoutStatusUpdateFromEvent", () => { expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, otherCwd))).toBeUndefined(); }); + it("normalizes legacy PR auth state at the pushed-cache boundary", () => { + const queryClient = createQueryClient(); + const { authState: _authState, ...legacyPrStatus } = prStatus({ + githubFeaturesEnabled: false, + }); + + applyCheckoutStatusUpdateFromEvent({ + queryClient, + serverId, + message: checkoutStatusUpdate(checkoutStatus(), legacyPrStatus), + }); + + expect( + queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, cwd)) + ?.authState, + ).toBe("unauthenticated"); + expect( + queryClient.getQueryData( + checkoutStatusQueryKey(serverId, cwd), + )?.prStatus?.authState, + ).toBe("unauthenticated"); + }); + it("expires a manual diff-mode override when the pushed dirty state flipped", () => { const queryClient = createQueryClient(); setDiffModeOverride(false); @@ -166,14 +201,21 @@ describe("applyCheckoutStatusUpdateFromEvent", () => { expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeDefined(); }); - it("invalidates the PR timeline when the prStatus changes, ignoring the volatile requestId", () => { + it("invalidates PR detail queries when the prStatus changes, ignoring the volatile requestId", () => { const queryClient = createQueryClient(); queryClient.setQueryData( checkoutPrStatusQueryKey(serverId, cwd), prStatus({ requestId: "pr-v1" }), ); const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 }); + const pipelineKey = prPanePipelineQueryKey({ + serverId, + cwd, + pipelineId: 9001, + changeRequestNumber: 1, + }); queryClient.setQueryData(timelineKey, { items: [] }); + queryClient.setQueryData(pipelineKey, { stages: [] }); applyCheckoutStatusUpdateFromEvent({ queryClient, @@ -181,6 +223,7 @@ describe("applyCheckoutStatusUpdateFromEvent", () => { message: checkoutStatusUpdate(checkoutStatus(), prStatus({ requestId: "pr-v2" })), }); expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(false); + expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(false); applyCheckoutStatusUpdateFromEvent({ queryClient, @@ -194,14 +237,29 @@ describe("applyCheckoutStatusUpdateFromEvent", () => { ), }); expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(true); }); - it("invalidates the PR timeline on the first prStatus emission, scoped to its cwd", () => { + it("invalidates PR detail queries on the first prStatus emission, scoped to its cwd", () => { const queryClient = createQueryClient(); const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 }); const otherTimelineKey = prPaneTimelineQueryKey({ serverId, cwd: "/repo2", prNumber: 42 }); + const pipelineKey = prPanePipelineQueryKey({ + serverId, + cwd, + pipelineId: 9001, + changeRequestNumber: 1, + }); + const otherPipelineKey = prPanePipelineQueryKey({ + serverId, + cwd: "/repo2", + pipelineId: 9001, + changeRequestNumber: 1, + }); queryClient.setQueryData(timelineKey, { items: [] }); queryClient.setQueryData(otherTimelineKey, { items: [] }); + queryClient.setQueryData(pipelineKey, { stages: [] }); + queryClient.setQueryData(otherPipelineKey, { stages: [] }); applyCheckoutStatusUpdateFromEvent({ queryClient, @@ -211,5 +269,7 @@ describe("applyCheckoutStatusUpdateFromEvent", () => { expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true); expect(queryClient.getQueryState(otherTimelineKey)?.isInvalidated).toBe(false); + expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(otherPipelineKey)?.isInvalidated).toBe(false); }); }); diff --git a/packages/app/src/git/checkout-status-cache.ts b/packages/app/src/git/checkout-status-cache.ts index 0efd2ccd9..73ef9bf1a 100644 --- a/packages/app/src/git/checkout-status-cache.ts +++ b/packages/app/src/git/checkout-status-cache.ts @@ -6,10 +6,11 @@ import { checkoutStatusQueryKey, invalidatePrPaneTimelineForCheckout, } from "@/git/query-keys"; +import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status"; import { expireStaleDiffModeOverrides } from "@/review/store"; export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; -export type CheckoutPrStatusPayload = NonNullable; +export type { CheckoutPrStatusPayload } from "@/git/pr-status"; export interface CheckoutStatusClient { getCheckoutStatus: (cwd: string) => Promise; @@ -43,14 +44,17 @@ export function applyCheckoutStatusUpdateFromEvent({ message: CheckoutStatusUpdate; }): void { const { payload } = message; - queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), payload); + const prStatus = payload.prStatus + ? normalizeCheckoutPrStatusPayload(payload.prStatus) + : undefined; + const cachePayload = prStatus ? { ...payload, prStatus } : payload; + queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload); expireStaleDiffModeOverrides({ serverId, cwd: payload.cwd, isDirty: payload.isGit && payload.isDirty, }); - const prStatus = payload.prStatus; if (!prStatus) { return; } diff --git a/packages/app/src/git/client-forge-module.ts b/packages/app/src/git/client-forge-module.ts new file mode 100644 index 000000000..cbc71da57 --- /dev/null +++ b/packages/app/src/git/client-forge-module.ts @@ -0,0 +1,199 @@ +import type { ComponentType } from "react"; +import type { ReactNode } from "react"; +import type { z } from "zod"; +import type { CheckoutPrMergeMethod, CheckoutPrStatusResponse } from "@getpaseo/protocol/messages"; +import type { Theme } from "@/styles/theme"; +import type { Forge } from "@/git/forge"; +import type { PrPaneCheck } from "@/git/pull-request-panel/data"; + +export interface ForgeIconProps { + size?: number; + color?: string; +} + +export type ForgeIconComponent = ComponentType; + +export interface ForgeBrandColor { + light: string; + dark: string; +} + +export type ForgeIconColorMapping = (theme: Theme) => { color: string }; + +export interface ForgeUrlGrammar { + /** Path infix before the branch in a tree URL, e.g. "/tree/", "/-/tree/". */ + treeInfix: string; + /** Path infix before the branch in a blob URL, e.g. "/blob/", "/-/blob/". */ + blobInfix: string; + /** Line/range anchor, e.g. github "#L1-L5" vs gitlab "#L1-5". */ + lineAnchor: (start: number, end?: number) => string; +} + +/** Line/range anchor shared by every GitHub-family forge (github, gitea, forgejo, codeberg). */ +export function GITHUB_LINE_ANCHOR(start: number, end?: number): string { + return end && end > start ? `#L${start}-L${end}` : `#L${start}`; +} + +/** URL grammar shared by the gitea-family forges (gitea, forgejo, codeberg). */ +export const GITEA_FAMILY_URL_GRAMMAR: ForgeUrlGrammar = { + treeInfix: "/src/branch/", + blobInfix: "/src/branch/", + lineAnchor: GITHUB_LINE_ANCHOR, +}; + +export type ForgeSpecificEnvelope = { forge: string } & Record; + +type CheckoutPrStatus = NonNullable; + +export type LegacyGithubMergeFacts = NonNullable; + +export interface MergeCapability { + /** The change request can be merged directly right now. */ + directMergeReady: boolean; + /** Auto-merge can be enabled right now. */ + canEnableAutoMerge: boolean; + /** Auto-merge is already enabled on the change request. */ + autoMergeEnabled: boolean; + /** The viewer is allowed to disable the active auto-merge. */ + canDisableAutoMerge: boolean; + /** A merge queue is blocking both direct merge and auto-merge. */ + mergeBlockedByQueue: boolean; + /** Merge methods the forge permits for this change request. */ + allowedMethods: CheckoutPrMergeMethod[]; + /** The forge's preferred/default merge method, if it reports one. */ + preferredMethod: CheckoutPrMergeMethod | null; +} + +export interface PaneChecksSlotContext { + serverId: string; + cwd: string; + /** Change request (PR/MR) number, so a section can address its head pipeline. */ + changeRequestNumber: number; + open: boolean; + onToggle: () => void; + /** Whether the daemon advertises pluggable forge support (gates rich sections). */ + enabled: boolean; + /** + * Whether the daemon can serve forge check/pipeline details over the + * forge-routed RPC. Single source of truth for gating on-demand detail + * fetches so a section never reaches an RPC the daemon lacks. + */ + canFetchCheckDetails: boolean; +} + +export interface PaneNativeContribution { + guard: (facts: unknown) => boolean; + renderHeaderMeta: (facts: unknown) => ReactNode; + renderChecksSection: (facts: unknown, ctx: PaneChecksSlotContext) => ReactNode; +} + +export interface NativeFallbackCheckEntry { + contribute: (facts: unknown, status: CheckoutPrStatus, forge: Forge) => PrPaneCheck | null; +} + +interface TypedPaneContribution { + renderHeaderMeta?: (facts: TFacts) => ReactNode; + renderChecksSection?: (facts: TFacts, ctx: PaneChecksSlotContext) => ReactNode; +} + +interface TypedNativeFallbackCheck { + contribute: (facts: TFacts, status: CheckoutPrStatus, forge: Forge) => PrPaneCheck | null; +} + +export interface ClientForgeFactsEntry { + readonly family: TFacts["forge"]; + parse: (facts: unknown) => TFacts | null; + deriveMergeCapability: (facts: unknown) => MergeCapability | null; + readonly nativeFallbackChecks: readonly NativeFallbackCheckEntry[]; +} + +export interface ClientForgeFactsRegistration { + readonly family: TFacts["forge"]; + readonly schema: z.ZodType; + readonly deriveMergeCapability?: (facts: TFacts) => MergeCapability; + readonly nativeFallbackChecks?: readonly NativeFallbackCheckEntry[]; +} + +function parseFacts( + schema: z.ZodType, + facts: unknown, +): TFacts | null { + if (!facts) { + return null; + } + const result = schema.safeParse(facts); + return result.success ? result.data : null; +} + +export function defineNativeFallbackCheck( + schema: z.ZodType, + contribution: TypedNativeFallbackCheck, +): NativeFallbackCheckEntry { + return { + contribute: (facts, status, forge) => { + const parsed = parseFacts(schema, facts); + return parsed ? contribution.contribute(parsed, status, forge) : null; + }, + }; +} + +export function definePaneContribution( + schema: z.ZodType, + contribution: TypedPaneContribution, +): PaneNativeContribution { + return { + guard: (facts) => parseFacts(schema, facts) !== null, + renderHeaderMeta: (facts) => { + const parsed = parseFacts(schema, facts); + return parsed && contribution.renderHeaderMeta ? contribution.renderHeaderMeta(parsed) : null; + }, + renderChecksSection: (facts, ctx) => { + const parsed = parseFacts(schema, facts); + return parsed && contribution.renderChecksSection + ? contribution.renderChecksSection(parsed, ctx) + : null; + }, + }; +} + +export function defineForgeFacts( + registration: ClientForgeFactsRegistration, +): ClientForgeFactsEntry { + return { + family: registration.family, + parse: (facts) => parseFacts(registration.schema, facts), + deriveMergeCapability: (facts) => { + if (!registration.deriveMergeCapability) { + return null; + } + const parsed = parseFacts(registration.schema, facts); + return parsed ? registration.deriveMergeCapability(parsed) : null; + }, + nativeFallbackChecks: registration.nativeFallbackChecks ?? [], + }; +} + +/** + * Pure logic half of a forge: URL grammar and runtime-facts derivations. Kept + * free of any React/React-Native imports so logic consumers (URL builders, + * merge-capability, native-check fallbacks) — and the Node-based e2e harness + * that transitively imports them — never pull the client's rendering stack. + */ +export interface ClientForgeLogicModule< + TFacts extends ForgeSpecificEnvelope = ForgeSpecificEnvelope, +> { + readonly id: string; + readonly urlGrammar?: ForgeUrlGrammar; + readonly facts?: ClientForgeFactsEntry; +} + +/** + * View half of a forge: the brand mark, brand color, and PR-pane render + * contributions. Imported only by rendering code (icon lookup, the PR pane). + */ +export interface ClientForgeViewModule { + readonly id: string; + readonly icon: ForgeIconComponent; + readonly brandColor?: ForgeBrandColor | null; + readonly paneContributions?: readonly PaneNativeContribution[]; +} diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 393fffc0f..27db92cd4 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -9,6 +9,7 @@ import { type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { DiffStat } from "@/components/diff-stat"; import { View, @@ -83,11 +84,13 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; -import { GitHubIcon } from "@/components/icons/github-icon"; import { lineNumberGutterWidth } from "@/components/code-insets"; import { GitActionsSplitButton } from "@/git/actions-split-button"; import { BranchSwitcher } from "@/components/branch-switcher"; import { useGitActions } from "@/git/use-actions"; +import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git/forge"; +import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; +import type { ForgeAuthState } from "@getpaseo/protocol/messages"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; import { useToast } from "@/contexts/toast-context"; import { useSessionStore } from "@/stores/session-store"; @@ -1256,7 +1259,6 @@ const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal); const ThemedDownload = withUnistyles(Download); const ThemedUpload = withUnistyles(Upload); const ThemedArrowDownUp = withUnistyles(ArrowDownUp); -const ThemedGitHubIcon = withUnistyles(GitHubIcon); const ThemedGitMerge = withUnistyles(GitMerge); const ThemedRefreshCcw = withUnistyles(RefreshCcw); const ThemedArchive = withUnistyles(Archive); @@ -1403,6 +1405,7 @@ function DiffFilesToolbar({ } interface DiffOptionsMenuProps { + brand: string; hideWhitespace: boolean; isMobile: boolean; isRefreshing: boolean; @@ -1415,6 +1418,7 @@ interface DiffOptionsMenuProps { } function DiffOptionsMenu({ + brand, hideWhitespace, isMobile, isRefreshing, @@ -1489,7 +1493,11 @@ function DiffOptionsMenu({ testID="changes-refresh" onSelect={onRefresh} > - {isRefreshing ? t("workspace.git.diff.refreshing") : t("workspace.git.diff.refresh")} + {isRefreshing + ? t("workspace.git.diff.refreshing") + : t("workspace.git.diff.refreshState", { + brand, + })} ) : null} @@ -2078,6 +2086,62 @@ function computePrErrorMessage( return prPayloadError?.message ?? null; } +// The precise setup step a workspace needs before its forge features work, or +// null when nothing is actionable (authenticated, or no forge remote at all). +type ForgeSetupAction = "install_cli" | "sign_in" | null; + +// Drive the onboarding callout from the forge's auth state so the message names +// the exact next step (install the CLI vs sign in) for whichever forge backs the +// workspace — GitHub included. GitLab additionally requires the host to advertise +// GitLab support, matching the rest of the GitLab UI. +function computeForgeSetupAction(input: { + forge: Forge; + forgeProvidersSupported: boolean; + authState: ForgeAuthState | undefined; +}): ForgeSetupAction { + // A daemon without pluggable forge support can't operate any non-GitHub forge, + // so don't offer a setup action for one it can't drive. + if (input.forge !== "github" && !input.forgeProvidersSupported) { + return null; + } + switch (input.authState) { + case "cli_missing": + return "install_cli"; + case "unauthenticated": + return "sign_in"; + case "authenticated": + case "no_remote": + case "error": + return null; + default: + return null; + } +} + +function parseForgeHost(url: string | null | undefined): string | null { + return url ? (parseGitRemoteLocation(url)?.host ?? null) : null; +} + +function buildForgeSetupMessage(input: { + action: Exclude; + forge: Forge; + host: string | null; + t: TFunction; +}): string { + const { brandLabel, signInCli } = getForgePresentation(input.forge); + // A forge with no known CLI (an unknown/third-party forge rendered neutrally) + // has no install/sign-in command to interpolate — show neutral guidance + // rather than the GitLab-specific callout or a null command. + if (signInCli === null) { + return input.t("workspace.git.forgeSetup.generic", { brand: brandLabel }); + } + if (input.action === "install_cli") { + return input.t("workspace.git.forgeSetup.installCli", { cli: signInCli, brand: brandLabel }); + } + const command = buildForgeSignInCommand(input.forge, input.host); + return input.t("workspace.git.forgeSetup.signIn", { command, brand: brandLabel }); +} + function buildDiffModeTriggerStyle(): PressableStyleFn { return ({ hovered, pressed, open }) => [ styles.diffModeTrigger, @@ -2285,11 +2349,36 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane setWorkspaceAttachments, workspaceAttachmentScopeKey, ]); - const { githubFeaturesEnabled, payloadError: prPayloadError } = useCheckoutPrStatusQuery({ + const { + githubFeaturesEnabled, + forge, + authState, + payloadError: prPayloadError, + } = useCheckoutPrStatusQuery({ serverId, cwd, enabled: isGit, }); + const forgeProvidersSupported = useSessionStore( + (s) => s.sessions[serverId]?.serverInfo?.features?.forgeProviders === true, + ); + const forgeSetupAction = computeForgeSetupAction({ + forge, + forgeProvidersSupported, + authState, + }); + const forgeSetupMessage = useMemo( + () => + forgeSetupAction + ? buildForgeSetupMessage({ + action: forgeSetupAction, + forge, + host: parseForgeHost(status?.remoteUrl), + t, + }) + : null, + [forgeSetupAction, forge, status?.remoteUrl, t], + ); const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]); const workspaceStateKey = useMemo( () => @@ -2401,11 +2490,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane pull: , push: , pullAndPush: , - viewPr: , - createPr: , - mergePrSquash: , - mergePrMerge: , - mergePrRebase: , merge: , mergeFromBase: , archive: , @@ -2531,6 +2615,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane /> ) : null} ) : null} + {forgeSetupMessage ? ( + + {forgeSetupMessage} + + ) : null} + {prErrorMessage ? {prErrorMessage} : null} {bodyContent} @@ -2653,6 +2744,20 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, color: theme.colors.destructive, }, + forgeSetupCallout: { + marginHorizontal: theme.spacing[3], + marginBottom: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface1, + }, + forgeSetupCalloutText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, diffContainer: { flex: 1, minHeight: 0, diff --git a/packages/app/src/git/forge-icon.test.ts b/packages/app/src/git/forge-icon.test.ts new file mode 100644 index 000000000..6473cc886 --- /dev/null +++ b/packages/app/src/git/forge-icon.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { GitPullRequest } from "lucide-react-native"; +import { getForgeBrandColorMapping, getForgeIconComponent } from "./forge-icon"; +import { CLIENT_FORGE_VIEW_MODULES } from "./forges/view"; + +describe("getForgeIconComponent", () => { + it("resolves a dedicated brand icon for every registered forge module", () => { + for (const module of CLIENT_FORGE_VIEW_MODULES) { + expect(getForgeIconComponent(module.id)).toBe(module.icon); + } + }); + + it("falls back to the generic pull-request glyph for unknown icon kinds", () => { + expect(getForgeIconComponent("some-unknown-forge")).toBe(GitPullRequest); + expect(getForgeIconComponent("")).toBe(GitPullRequest); + }); +}); + +describe("getForgeBrandColorMapping", () => { + it("returns a theme mapping only for forges that declare a brand color", () => { + for (const module of CLIENT_FORGE_VIEW_MODULES) { + const mapping = getForgeBrandColorMapping(module.id); + if (module.brandColor) { + expect(mapping).toBeTypeOf("function"); + } else { + expect(mapping).toBeNull(); + } + } + }); + + it("resolves the light or dark brand color from the theme color scheme", () => { + const branded = CLIENT_FORGE_VIEW_MODULES.find((module) => module.brandColor); + expect(branded).toBeDefined(); + if (!branded?.brandColor) { + return; + } + const mapping = getForgeBrandColorMapping(branded.id); + expect(mapping?.({ colorScheme: "light" } as never)).toEqual({ + color: branded.brandColor.light, + }); + expect(mapping?.({ colorScheme: "dark" } as never)).toEqual({ + color: branded.brandColor.dark, + }); + }); + + it("returns null for unknown icon kinds", () => { + expect(getForgeBrandColorMapping("some-unknown-forge")).toBeNull(); + }); +}); diff --git a/packages/app/src/git/forge-icon.tsx b/packages/app/src/git/forge-icon.tsx new file mode 100644 index 000000000..f8f6099c4 --- /dev/null +++ b/packages/app/src/git/forge-icon.tsx @@ -0,0 +1,72 @@ +/** + * Brand marks are SVG React components, so they cannot travel over the wire as + * a string. This file derives icon and brand-color lookup tables from + * CLIENT_FORGE_VIEW_MODULES; a new forge ships an icon component in the client + * bundle and references it from its view module. Unknown icon kinds fall back to + * a generic pull-request glyph. + */ +import { withUnistyles } from "react-native-unistyles"; +import { GitPullRequest } from "lucide-react-native"; +import { + type ForgeBrandColor, + type ForgeIconColorMapping, + type ForgeIconComponent, +} from "@/git/client-forge-module"; +import { CLIENT_FORGE_VIEW_MODULES } from "@/git/forges/view"; + +const FORGE_ICON_BY_KIND = new Map( + CLIENT_FORGE_VIEW_MODULES.map((module) => [module.id, module.icon]), +); + +/** + * Raw brand icon component for an `iconKind`, for call sites that style with a + * plain `color` prop. Falls back to a generic pull-request glyph. + */ +export function getForgeIconComponent(iconKind: string): ForgeIconComponent { + return FORGE_ICON_BY_KIND.get(iconKind) ?? GitPullRequest; +} + +const THEMED_ICON_BY_KIND = Object.fromEntries( + CLIENT_FORGE_VIEW_MODULES.map((module) => [module.id, withUnistyles(module.icon)]), +); +const ThemedGitPullRequest = withUnistyles(GitPullRequest); + +/** Theme-driven color mapping, e.g. `(theme) => ({ color: theme.colors.foregroundMuted })`. */ +export type { ForgeIconColorMapping }; + +function brandColorMapping(colors: ForgeBrandColor): ForgeIconColorMapping { + return (theme) => ({ color: theme.colorScheme === "light" ? colors.light : colors.dark }); +} + +const BRAND_COLOR_MAPPING_BY_KIND = new Map( + CLIENT_FORGE_VIEW_MODULES.flatMap((module) => + module.brandColor ? [[module.id, brandColorMapping(module.brandColor)] as const] : [], + ), +); + +/** + * The forge's brand color mapping, or null for forges with no dedicated brand + * color (github renders in a neutral tone; an unknown forge has none). Call + * sites pair this with {@link ForgeBrandIcon}: use it to tint the icon, or to + * decide whether to show a brand badge at all. + */ +export function getForgeBrandColorMapping(iconKind: string): ForgeIconColorMapping | null { + return BRAND_COLOR_MAPPING_BY_KIND.get(iconKind) ?? null; +} + +/** + * Themed brand icon for an `iconKind`, for call sites that color via a unistyles + * mapping. Falls back to a generic pull-request glyph for unknown forges. + */ +export function ForgeBrandIcon({ + iconKind, + size, + uniProps, +}: { + iconKind: string; + size: number; + uniProps: ForgeIconColorMapping; +}) { + const ThemedIcon = THEMED_ICON_BY_KIND[iconKind] ?? ThemedGitPullRequest; + return ; +} diff --git a/packages/app/src/git/forge-url.test.ts b/packages/app/src/git/forge-url.test.ts new file mode 100644 index 000000000..aa647998a --- /dev/null +++ b/packages/app/src/git/forge-url.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; +import { buildForgeBlobUrl, buildForgeBranchTreeUrl, hasForgeWebUrls } from "./forge-url"; + +describe("buildForgeBranchTreeUrl", () => { + it("builds a branch-specific GitHub tree URL", () => { + expect( + buildForgeBranchTreeUrl("github", { + remoteUrl: "git@github.com:acme/repo.git", + branch: "feature/workspace-button", + }), + ).toBe("https://github.com/acme/repo/tree/feature/workspace-button"); + }); + + it("encodes reserved branch characters while preserving slash-separated branch names", () => { + expect( + buildForgeBranchTreeUrl("github", { + remoteUrl: "https://github.com/acme/repo.git", + branch: "feature/ship #42", + }), + ).toBe("https://github.com/acme/repo/tree/feature/ship%20%2342"); + }); + + it("uses the GitLab /-/tree/ infix and supports subgroups", () => { + expect( + buildForgeBranchTreeUrl("gitlab", { + remoteUrl: "https://gitlab.com/group/sub/repo.git", + branch: "main", + }), + ).toBe("https://gitlab.com/group/sub/repo/-/tree/main"); + }); + + it("uses the Gitea-family /src/branch/ infix", () => { + expect( + buildForgeBranchTreeUrl("gitea", { + remoteUrl: "https://gitea.com/acme/repo.git", + branch: "main", + }), + ).toBe("https://gitea.com/acme/repo/src/branch/main"); + expect( + buildForgeBranchTreeUrl("codeberg", { + remoteUrl: "https://codeberg.org/acme/repo.git", + branch: "main", + }), + ).toBe("https://codeberg.org/acme/repo/src/branch/main"); + }); + + it("returns null when the current branch is unavailable", () => { + expect( + buildForgeBranchTreeUrl("github", { + remoteUrl: "https://github.com/acme/repo.git", + branch: "HEAD", + }), + ).toBeNull(); + }); + + it("returns null for a forge with no known URL grammar", () => { + expect( + buildForgeBranchTreeUrl("bitbucket", { + remoteUrl: "https://bitbucket.org/acme/repo.git", + branch: "main", + }), + ).toBeNull(); + }); +}); + +describe("buildForgeBlobUrl", () => { + it("builds a blob URL for a file path", () => { + expect( + buildForgeBlobUrl("github", { + remoteUrl: "git@github.com:acme/repo.git", + branch: "main", + path: "src/index.ts", + }), + ).toBe("https://github.com/acme/repo/blob/main/src/index.ts"); + }); + + it("appends a single-line anchor", () => { + expect( + buildForgeBlobUrl("github", { + remoteUrl: "https://github.com/acme/repo.git", + branch: "main", + path: "src/index.ts", + lineStart: 12, + }), + ).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12"); + }); + + it("appends a GitHub line range anchor (#L12-L20)", () => { + expect( + buildForgeBlobUrl("github", { + remoteUrl: "https://github.com/acme/repo.git", + branch: "main", + path: "src/index.ts", + lineStart: 12, + lineEnd: 20, + }), + ).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12-L20"); + }); + + it("uses the GitLab /-/blob/ infix and #L12-20 range anchor", () => { + expect( + buildForgeBlobUrl("gitlab", { + remoteUrl: "https://gitlab.com/group/sub/repo.git", + branch: "main", + path: "src/index.ts", + lineStart: 12, + lineEnd: 20, + }), + ).toBe("https://gitlab.com/group/sub/repo/-/blob/main/src/index.ts#L12-20"); + }); + + it("uses the Gitea-family /src/branch/ blob path with a #L12-L20 anchor", () => { + expect( + buildForgeBlobUrl("forgejo", { + remoteUrl: "https://codeberg.org/acme/repo.git", + branch: "main", + path: "src/index.ts", + lineStart: 12, + lineEnd: 20, + }), + ).toBe("https://codeberg.org/acme/repo/src/branch/main/src/index.ts#L12-L20"); + }); + + it("derives the web host from a self-hosted remote (GitHub Enterprise)", () => { + expect( + buildForgeBlobUrl("github", { + remoteUrl: "git@github.acme.internal:team/repo.git", + branch: "main", + path: "src/index.ts", + }), + ).toBe("https://github.acme.internal/team/repo/blob/main/src/index.ts"); + }); + + it("canonicalizes the github.com SSH-alias host to the web host", () => { + expect( + buildForgeBlobUrl("github", { + remoteUrl: "ssh://git@ssh.github.com/acme/repo.git", + branch: "main", + path: "src/index.ts", + }), + ).toBe("https://github.com/acme/repo/blob/main/src/index.ts"); + }); + + it("strips leading slashes and encodes path segments", () => { + expect( + buildForgeBlobUrl("github", { + remoteUrl: "https://github.com/acme/repo.git", + branch: "main", + path: "/src/a b/c#d.ts", + }), + ).toBe("https://github.com/acme/repo/blob/main/src/a%20b/c%23d.ts"); + }); + + it("normalizes harmless dot segments in the blob path", () => { + expect( + buildForgeBlobUrl("github", { + remoteUrl: "https://github.com/acme/repo.git", + branch: "main", + path: "./src/../index.ts", + }), + ).toBe("https://github.com/acme/repo/blob/main/index.ts"); + }); + + it("returns null for blob paths that escape above the repo root", () => { + expect( + buildForgeBlobUrl("github", { + remoteUrl: "https://github.com/acme/repo.git", + branch: "main", + path: "../outside.ts", + }), + ).toBeNull(); + }); + + it("returns null when the path is missing", () => { + expect( + buildForgeBlobUrl("github", { + remoteUrl: "https://github.com/acme/repo.git", + branch: "main", + path: "", + }), + ).toBeNull(); + }); + + it("returns null for a forge with no known URL grammar", () => { + expect( + buildForgeBlobUrl("bitbucket", { + remoteUrl: "https://bitbucket.org/acme/repo.git", + branch: "main", + path: "src/index.ts", + }), + ).toBeNull(); + }); +}); + +describe("hasForgeWebUrls", () => { + it("is true for forges with a known URL grammar", () => { + for (const forge of ["github", "gitlab", "gitea", "forgejo", "codeberg"]) { + expect(hasForgeWebUrls(forge)).toBe(true); + } + }); + + it("is false for an unknown forge", () => { + expect(hasForgeWebUrls("bitbucket")).toBe(false); + }); +}); diff --git a/packages/app/src/git/forge-url.ts b/packages/app/src/git/forge-url.ts new file mode 100644 index 000000000..79090d923 --- /dev/null +++ b/packages/app/src/git/forge-url.ts @@ -0,0 +1,127 @@ +/** + * Forge-neutral web URL builders for "Open on " actions (a file blob or a + * branch tree). The host comes from the workspace remote — not a hardcoded + * cloud host — so self-hosted and Enterprise instances link correctly. Each + * forge contributes a small URL grammar (the path infixes and line-anchor + * format); an unknown forge has no grammar and yields null, so the action is + * simply absent rather than wrong. + * + * URL grammar lives on each client forge module. The repo identity and host + * both ride the manifest's `cloudHosts` only to canonicalize a cloud SSH alias + * (e.g. ssh.github.com -> github.com); a self-hosted host is used as-is. + */ +import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest"; +import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; +import { getClientForgeLogicModule } from "@/git/forges"; + +export interface ForgeBlobUrlInput { + remoteUrl: string | null | undefined; + branch: string | null | undefined; + path: string | null | undefined; + lineStart?: number; + lineEnd?: number; +} + +export interface ForgeBranchTreeUrlInput { + remoteUrl: string | null | undefined; + branch: string | null | undefined; +} + +interface ForgeWebLocation { + host: string; + repo: string; +} + +/** + * Web host + repo path from a remote. The host is the remote's host, except a + * cloud SSH alias (a non-first entry in the forge's `cloudHosts`, e.g. + * ssh.github.com) is canonicalized to the forge's web host (`cloudHosts[0]`). + * Self-hosted hosts are returned untouched. The repo path supports nested + * groups (e.g. GitLab subgroups) since it is the full remote path. + */ +function isValidRepoPath(path: string): boolean { + const segments = path.split("/").filter((segment) => segment.length > 0); + if (segments.length === 0) { + return false; + } + return !segments.includes(".."); +} + +function resolveForgeWebLocation( + forge: string, + remoteUrl: string | null | undefined, +): ForgeWebLocation | null { + if (!remoteUrl) { + return null; + } + const location = parseGitRemoteLocation(remoteUrl); + if (!location || !isValidRepoPath(location.path)) { + return null; + } + const cloudHosts = getForgeDefinition(forge)?.cloudHosts; + const webHost = + cloudHosts && cloudHosts.length > 0 && cloudHosts.map(normalizeHost).includes(location.host) + ? normalizeHost(cloudHosts[0]) + : location.host; + return { host: webHost, repo: location.path }; +} + +function encodeBranch(branch: string): string { + return branch.split("/").map(encodeURIComponent).join("/"); +} + +function normalizeBlobPath(path: string | null | undefined): string | null { + const segments: string[] = []; + const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, ""); + if (!trimmed) { + return null; + } + for (const segment of trimmed.split("/")) { + if (!segment || segment === ".") { + continue; + } + if (segment === "..") { + if (segments.length === 0) { + return null; + } + segments.pop(); + continue; + } + segments.push(segment); + } + return segments.join("/") || null; +} + +export function buildForgeBranchTreeUrl( + forge: string, + input: ForgeBranchTreeUrlInput, +): string | null { + const grammar = getClientForgeLogicModule(forge)?.urlGrammar; + const location = resolveForgeWebLocation(forge, input.remoteUrl); + const branch = input.branch?.trim(); + if (!grammar || !location || !branch || branch === "HEAD") { + return null; + } + return `https://${location.host}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`; +} + +export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null { + const grammar = getClientForgeLogicModule(forge)?.urlGrammar; + const location = resolveForgeWebLocation(forge, input.remoteUrl); + const branch = input.branch?.trim(); + const filePath = normalizeBlobPath(input.path); + if (!grammar || !location || !branch || branch === "HEAD" || !filePath) { + return null; + } + const encodedPath = filePath.split("/").map(encodeURIComponent).join("/"); + let url = `https://${location.host}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`; + if (input.lineStart && input.lineStart > 0) { + url += grammar.lineAnchor(input.lineStart, input.lineEnd); + } + return url; +} + +/** Whether the forge has web URL builders (i.e. a known URL grammar). */ +export function hasForgeWebUrls(forge: string): boolean { + return getClientForgeLogicModule(forge)?.urlGrammar !== undefined; +} diff --git a/packages/app/src/git/forge.test.ts b/packages/app/src/git/forge.test.ts new file mode 100644 index 000000000..8607fb794 --- /dev/null +++ b/packages/app/src/git/forge.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { + buildForgeSignInCommand, + forgeFromRemoteUrl, + getForgePresentation, + normalizeForge, +} from "./forge"; + +describe("normalizeForge", () => { + it("maps the gitlab discriminant to gitlab", () => { + expect(normalizeForge("gitlab")).toBe("gitlab"); + }); + + it("keeps any non-empty forge id and defaults only absent values to github", () => { + expect(normalizeForge("github")).toBe("github"); + expect(normalizeForge("gitea")).toBe("gitea"); + expect(normalizeForge("forgejo")).toBe("forgejo"); + expect(normalizeForge(undefined)).toBe("github"); + expect(normalizeForge(null)).toBe("github"); + // An unknown forge id is preserved (rendered neutrally), not collapsed to + // GitHub, so a newer daemon's forge degrades gracefully on an older client. + expect(normalizeForge("bitbucket")).toBe("bitbucket"); + }); +}); + +describe("getForgePresentation", () => { + it("keeps GitHub on the pull-request noun and the # prefix", () => { + const github = getForgePresentation("github"); + expect(github.brandLabel).toBe("GitHub"); + expect(github.changeRequestAbbrev).toBe("PR"); + expect(github.numberPrefix).toBe("#"); + expect(github.issueNumberPrefix).toBe("#"); + expect(github.changeRequestContext).toBeUndefined(); + }); + + it("relabels GitLab to the merge-request noun and the ! prefix", () => { + const gitlab = getForgePresentation("gitlab"); + expect(gitlab.brandLabel).toBe("GitLab"); + expect(gitlab.changeRequestAbbrev).toBe("MR"); + expect(gitlab.numberPrefix).toBe("!"); + expect(gitlab.issueNumberPrefix).toBe("#"); + expect(gitlab.changeRequestContext).toBe("mr"); + }); + + it("presents Gitea and Forgejo with GitHub nouns and the tea CLI", () => { + expect(getForgePresentation("gitea")).toMatchObject({ + forge: "gitea", + icon: "gitea", + brandLabel: "Gitea", + changeRequestAbbrev: "PR", + numberPrefix: "#", + issueNumberPrefix: "#", + signInCli: "tea", + }); + expect(getForgePresentation("forgejo")).toMatchObject({ + forge: "forgejo", + icon: "forgejo", + brandLabel: "Forgejo", + changeRequestAbbrev: "PR", + signInCli: "tea", + }); + expect(getForgePresentation("codeberg")).toMatchObject({ + forge: "codeberg", + icon: "codeberg", + brandLabel: "Codeberg", + changeRequestAbbrev: "PR", + signInCli: "tea", + }); + }); +}); + +describe("forgeFromRemoteUrl", () => { + it("detects only public forge hosts that are safe without daemon probing", () => { + expect(forgeFromRemoteUrl("https://codeberg.org/example/repo.git")).toBe("codeberg"); + expect(forgeFromRemoteUrl("https://gitlab.com/example/repo.git")).toBe("gitlab"); + expect(forgeFromRemoteUrl("https://gitea.com/example/repo.git")).toBe("gitea"); + }); + + it("does not classify self-managed hosts by substring", () => { + expect(forgeFromRemoteUrl("git@gitlab.example.org:example/repo.git")).toBeNull(); + expect(forgeFromRemoteUrl("git@forgejo.example.org:example/repo.git")).toBeNull(); + expect(forgeFromRemoteUrl("https://notgitlab.example.org/example/repo.git")).toBeNull(); + }); +}); + +describe("buildForgeSignInCommand", () => { + it("uses tea login add for both Gitea-family presentations", () => { + expect(buildForgeSignInCommand("gitea", "gitea.com")).toBe("tea login add"); + expect(buildForgeSignInCommand("forgejo", "forgejo.example.org")).toBe("tea login add"); + expect(buildForgeSignInCommand("codeberg", "codeberg.org")).toBe("tea login add"); + }); + + it("uses plain gh auth login for GitHub (incl. the ssh.github.com endpoint)", () => { + expect(buildForgeSignInCommand("github", "github.com")).toBe("gh auth login"); + expect(buildForgeSignInCommand("github", "ssh.github.com")).toBe("gh auth login"); + }); + + it("targets the workspace host for self-hosted GitLab", () => { + expect(buildForgeSignInCommand("gitlab", "gitlab.acme.com")).toBe( + "glab auth login --hostname gitlab.acme.com", + ); + }); + + it("returns no sign-in command for an unknown forge with no known CLI", () => { + expect(buildForgeSignInCommand("bitbucket", "bitbucket.org")).toBeNull(); + }); +}); diff --git a/packages/app/src/git/forge.ts b/packages/app/src/git/forge.ts new file mode 100644 index 000000000..2bab50e85 --- /dev/null +++ b/packages/app/src/git/forge.ts @@ -0,0 +1,126 @@ +/** + * Forge-neutral presentation layer for the git-hosting UI, derived from the + * shared forge manifest. + * + * The daemon resolves which forge backs a workspace and reports it on the wire + * (`CheckoutPrStatusResponse.payload.forge`). The model keeps the `PullRequest` + * noun in code and types; the PR↔MR relabel, the number prefix, the brand mark, + * and the icon are a UI concern driven by the manifest entry for that id. An + * unknown/absent forge (e.g. a self-hosted forge a newer daemon reports to an + * older client) renders neutrally — never GitHub-branded. A null/empty forge + * still maps to GitHub so old daemons (which never send a forge) render exactly + * as before. + */ +import { FORGE_DEFINITIONS, getForgeDefinitionOrNeutral } from "@getpaseo/protocol/forge-manifest"; +import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; +import type { ForgeAuthState } from "@getpaseo/protocol/messages"; +import { + buildForgeBlobUrl, + buildForgeBranchTreeUrl, + hasForgeWebUrls, + type ForgeBlobUrlInput, + type ForgeBranchTreeUrlInput, +} from "@/git/forge-url"; + +/** + * A forge id. Open by design: any id the daemon reports is valid, and the + * manifest drives presentation with a neutral fallback for unknown ids. Kept as + * a named alias to document intent at call sites. + */ +export type Forge = string; + +export function normalizeForge(raw: string | null | undefined): string { + return raw && raw.length > 0 ? raw : "github"; +} + +export function parseForgeAuthState(value: unknown): ForgeAuthState | undefined { + switch (value) { + case "authenticated": + case "unauthenticated": + case "cli_missing": + case "no_remote": + case "error": + return value; + default: + return undefined; + } +} + +export function forgeFromRemoteUrl(remoteUrl: string | null | undefined): string | null { + if (!remoteUrl) { + return null; + } + const host = parseGitRemoteLocation(remoteUrl)?.host; + if (!host) { + return null; + } + const normalized = normalizeHost(host); + for (const definition of FORGE_DEFINITIONS) { + if (definition.cloudHosts?.some((cloudHost) => normalizeHost(cloudHost) === normalized)) { + return definition.id; + } + } + return null; +} + +export interface ForgePresentation { + forge: string; + /** Icon key; render sites fall back to a generic git icon for unknown values. */ + icon: string; + /** Human brand name, e.g. for "Open on GitLab". */ + brandLabel: string; + /** Short change-request noun: "PR" for GitHub, "MR" for GitLab. */ + changeRequestAbbrev: string; + /** Full change-request noun: "pull request" for GitHub, "merge request" for GitLab. */ + changeRequestNoun: string; + /** Prefix the forge puts before a change-request number: "#" vs "!". */ + numberPrefix: string; + /** Prefix the forge puts before an issue number ("#" on every forge so far). */ + issueNumberPrefix: string; + /** Auth CLI binary for the install hint, or null for a forge with no Paseo-driven sign-in. */ + signInCli: string | null; + /** + * i18next context selecting the change-request vocabulary family for any key + * that carries an `_mr` variant: `t(key, { context: changeRequestContext })` + * resolves `key_mr` for the merge-request family and falls back to the base + * (pull-request) string for undefined or unknown families. + */ + changeRequestContext: "mr" | undefined; + buildBlobUrl: ((input: ForgeBlobUrlInput) => string | null) | null; + buildBranchTreeUrl: ((input: ForgeBranchTreeUrlInput) => string | null) | null; +} + +export function getForgePresentation(forge: string): ForgePresentation { + const definition = getForgeDefinitionOrNeutral(forge); + const isMergeRequest = definition.changeRequestAbbrev === "MR"; + const hasWebUrls = hasForgeWebUrls(definition.id); + return { + forge: definition.id, + icon: definition.iconKind, + brandLabel: definition.displayName, + changeRequestAbbrev: definition.changeRequestAbbrev, + changeRequestNoun: definition.changeRequestNoun, + numberPrefix: definition.changeRequestNumberPrefix, + issueNumberPrefix: definition.issueNumberPrefix, + signInCli: definition.signIn?.cli ?? null, + changeRequestContext: isMergeRequest ? "mr" : undefined, + buildBlobUrl: hasWebUrls ? (input) => buildForgeBlobUrl(definition.id, input) : null, + buildBranchTreeUrl: hasWebUrls + ? (input) => buildForgeBranchTreeUrl(definition.id, input) + : null, + }; +} + +export function buildForgeSignInCommand(forge: string, host: string | null): string | null { + const signIn = getForgeDefinitionOrNeutral(forge).signIn; + if (!signIn) { + return null; + } + // A forge that needs a --hostname only gets it when a host is known; forges + // whose command already targets the right API host (e.g. github's plain + // `gh auth login`, which must NOT receive ssh.github.com) omit hostnameFlag. + if (signIn.hostnameFlag && host) { + return `${signIn.command} ${signIn.hostnameFlag} ${host}`; + } + return signIn.command; +} diff --git a/packages/app/src/git/forges/codeberg.ts b/packages/app/src/git/forges/codeberg.ts new file mode 100644 index 000000000..00072a09b --- /dev/null +++ b/packages/app/src/git/forges/codeberg.ts @@ -0,0 +1,6 @@ +import { GITEA_FAMILY_URL_GRAMMAR, type ClientForgeLogicModule } from "@/git/client-forge-module"; + +export const codebergForgeLogic = { + id: "codeberg", + urlGrammar: GITEA_FAMILY_URL_GRAMMAR, +} satisfies ClientForgeLogicModule; diff --git a/packages/app/src/git/forges/codeberg.view.tsx b/packages/app/src/git/forges/codeberg.view.tsx new file mode 100644 index 000000000..82cb227a9 --- /dev/null +++ b/packages/app/src/git/forges/codeberg.view.tsx @@ -0,0 +1,11 @@ +import { CodebergIcon } from "@/components/icons/codeberg-icon"; +import type { ClientForgeViewModule } from "@/git/client-forge-module"; + +export const codebergForgeView = { + id: "codeberg", + icon: CodebergIcon, + brandColor: { + light: "#2185D0", + dark: "#2185D0", + }, +} satisfies ClientForgeViewModule; diff --git a/packages/app/src/git/forges/forgejo.ts b/packages/app/src/git/forges/forgejo.ts new file mode 100644 index 000000000..005421ff4 --- /dev/null +++ b/packages/app/src/git/forges/forgejo.ts @@ -0,0 +1,6 @@ +import { GITEA_FAMILY_URL_GRAMMAR, type ClientForgeLogicModule } from "@/git/client-forge-module"; + +export const forgejoForgeLogic = { + id: "forgejo", + urlGrammar: GITEA_FAMILY_URL_GRAMMAR, +} satisfies ClientForgeLogicModule; diff --git a/packages/app/src/git/forges/forgejo.view.tsx b/packages/app/src/git/forges/forgejo.view.tsx new file mode 100644 index 000000000..6c1b9c937 --- /dev/null +++ b/packages/app/src/git/forges/forgejo.view.tsx @@ -0,0 +1,11 @@ +import { ForgejoIcon } from "@/components/icons/forgejo-icon"; +import type { ClientForgeViewModule } from "@/git/client-forge-module"; + +export const forgejoForgeView = { + id: "forgejo", + icon: ForgejoIcon, + brandColor: { + light: "#FB923C", + dark: "#FB923C", + }, +} satisfies ClientForgeViewModule; diff --git a/packages/app/src/git/forges/gitea.ts b/packages/app/src/git/forges/gitea.ts new file mode 100644 index 000000000..c49af40dc --- /dev/null +++ b/packages/app/src/git/forges/gitea.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; +import { + defineForgeFacts, + defineNativeFallbackCheck, + GITEA_FAMILY_URL_GRAMMAR, + type ClientForgeLogicModule, + type MergeCapability, +} from "@/git/client-forge-module"; +import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages"; +import { mapCheckStatus, type CheckStatus } from "@/git/pull-request-panel/check-status"; + +const GiteaMergeFactsSchema = z + .object({ + forge: z.literal("gitea"), + mergeable: z.boolean().optional().default(false), + hasMerged: z.boolean().optional().default(false), + ciStatus: z.string().nullable().optional().default(null), + }) + .passthrough(); + +type GiteaMergeFacts = z.infer; + +// forgeSpecific.ciStatus carries Gitea's raw aggregate CI string. Server twin: +// packages/server/src/services/gitea-service.ts (mapGiteaCommitStatus) — "warning" +// and "error" are terminal, non-passing states, but the generic mapCheckStatus +// would show them as pending, so interpret them here where the module owns Gitea +// facts. +function mapGiteaCiStatus(ciStatus: string): CheckStatus { + if (ciStatus === "warning" || ciStatus === "error") { + return "failure"; + } + return mapCheckStatus(ciStatus); +} + +const GITEA_MERGE_METHODS: CheckoutPrMergeMethod[] = ["merge", "squash", "rebase"]; + +function deriveGiteaMergeCapability(gitea: GiteaMergeFacts): MergeCapability { + return { + directMergeReady: gitea.mergeable && !gitea.hasMerged, + canEnableAutoMerge: false, + autoMergeEnabled: false, + canDisableAutoMerge: false, + mergeBlockedByQueue: false, + allowedMethods: GITEA_MERGE_METHODS, + preferredMethod: null, + }; +} + +export const giteaForgeLogic = { + id: "gitea", + urlGrammar: GITEA_FAMILY_URL_GRAMMAR, + facts: defineForgeFacts({ + family: "gitea", + schema: GiteaMergeFactsSchema, + deriveMergeCapability: deriveGiteaMergeCapability, + nativeFallbackChecks: [ + defineNativeFallbackCheck(GiteaMergeFactsSchema, { + contribute: (facts, status, forge) => { + if (!facts.ciStatus) { + return null; + } + return { + provider: forge, + name: "CI", + status: mapGiteaCiStatus(facts.ciStatus), + url: status.url, + }; + }, + }), + ], + }), +} satisfies ClientForgeLogicModule; diff --git a/packages/app/src/git/forges/gitea.view.tsx b/packages/app/src/git/forges/gitea.view.tsx new file mode 100644 index 000000000..698deb0eb --- /dev/null +++ b/packages/app/src/git/forges/gitea.view.tsx @@ -0,0 +1,11 @@ +import { GiteaIcon } from "@/components/icons/gitea-icon"; +import type { ClientForgeViewModule } from "@/git/client-forge-module"; + +export const giteaForgeView = { + id: "gitea", + icon: GiteaIcon, + brandColor: { + light: "#609926", + dark: "#609926", + }, +} satisfies ClientForgeViewModule; diff --git a/packages/app/src/git/forges/github.ts b/packages/app/src/git/forges/github.ts new file mode 100644 index 000000000..1cae04dd9 --- /dev/null +++ b/packages/app/src/git/forges/github.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; +import { + defineForgeFacts, + GITHUB_LINE_ANCHOR, + type ClientForgeLogicModule, + type MergeCapability, +} from "@/git/client-forge-module"; +import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages"; + +const GithubAutoMergeRequestSchema = z + .object({ + enabledAt: z.string().nullable().optional().default(null), + mergeMethod: z.string().nullable().optional().default(null), + enabledBy: z.string().nullable().optional().default(null), + }) + .nullable() + .optional() + .default(null); + +const GithubRepositoryPolicySchema = z + .object({ + autoMergeAllowed: z.boolean().optional().default(false), + mergeCommitAllowed: z.boolean().optional().default(false), + squashMergeAllowed: z.boolean().optional().default(false), + rebaseMergeAllowed: z.boolean().optional().default(false), + viewerDefaultMergeMethod: z.string().nullable().optional().default(null), + }) + .optional() + .default({ + autoMergeAllowed: false, + mergeCommitAllowed: false, + squashMergeAllowed: false, + rebaseMergeAllowed: false, + viewerDefaultMergeMethod: null, + }); + +const GithubMergeFactsSchema = z + .object({ + forge: z.literal("github"), + mergeStateStatus: z.string().nullable().optional().default(null), + autoMergeRequest: GithubAutoMergeRequestSchema, + viewerCanEnableAutoMerge: z.boolean().optional().default(false), + viewerCanDisableAutoMerge: z.boolean().optional().default(false), + viewerCanMergeAsAdmin: z.boolean().optional().default(false), + viewerCanUpdateBranch: z.boolean().optional().default(false), + repository: GithubRepositoryPolicySchema, + isMergeQueueEnabled: z.boolean().optional().default(false), + isInMergeQueue: z.boolean().optional().default(false), + }) + .passthrough(); + +type GithubMergeFacts = z.infer; + +const GITHUB_DIRECT_MERGE_STATE_ALLOWLIST = new Set(["CLEAN", "HAS_HOOKS"]); + +function normalizeGithubMergeMethod(value: string | null): CheckoutPrMergeMethod | null { + if (value === "SQUASH") return "squash"; + if (value === "MERGE") return "merge"; + if (value === "REBASE") return "rebase"; + return null; +} + +function deriveGithubMergeCapability(github: GithubMergeFacts): MergeCapability { + const repository = github.repository; + const allowedMethods: CheckoutPrMergeMethod[] = []; + if (repository.mergeCommitAllowed) allowedMethods.push("merge"); + if (repository.squashMergeAllowed) allowedMethods.push("squash"); + if (repository.rebaseMergeAllowed) allowedMethods.push("rebase"); + return { + directMergeReady: GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(github.mergeStateStatus ?? ""), + canEnableAutoMerge: + github.mergeStateStatus === "BLOCKED" && + repository.autoMergeAllowed && + github.viewerCanEnableAutoMerge, + autoMergeEnabled: github.autoMergeRequest !== null, + canDisableAutoMerge: github.viewerCanDisableAutoMerge === true, + mergeBlockedByQueue: github.isMergeQueueEnabled || github.isInMergeQueue, + allowedMethods, + preferredMethod: normalizeGithubMergeMethod(repository.viewerDefaultMergeMethod ?? null), + }; +} + +export const githubForgeLogic = { + id: "github", + urlGrammar: { + treeInfix: "/tree/", + blobInfix: "/blob/", + lineAnchor: GITHUB_LINE_ANCHOR, + }, + facts: defineForgeFacts({ + family: "github", + schema: GithubMergeFactsSchema, + deriveMergeCapability: deriveGithubMergeCapability, + }), +} satisfies ClientForgeLogicModule; diff --git a/packages/app/src/git/forges/github.view.tsx b/packages/app/src/git/forges/github.view.tsx new file mode 100644 index 000000000..3f22db1e7 --- /dev/null +++ b/packages/app/src/git/forges/github.view.tsx @@ -0,0 +1,8 @@ +import { GitHubIcon } from "@/components/icons/github-icon"; +import type { ClientForgeViewModule } from "@/git/client-forge-module"; + +export const githubForgeView = { + id: "github", + icon: GitHubIcon, + brandColor: null, +} satisfies ClientForgeViewModule; diff --git a/packages/app/src/git/forges/gitlab.ts b/packages/app/src/git/forges/gitlab.ts new file mode 100644 index 000000000..7758d2c0a --- /dev/null +++ b/packages/app/src/git/forges/gitlab.ts @@ -0,0 +1,155 @@ +import { z } from "zod"; +import { + defineForgeFacts, + type ClientForgeLogicModule, + type MergeCapability, +} from "@/git/client-forge-module"; +import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages"; +import type { CheckStatus } from "@/git/pull-request-panel/check-status"; + +const gitlabLineAnchor = (start: number, end?: number): string => + end && end > start ? `#L${start}-${end}` : `#L${start}`; + +/** + * Canonical set of GitLab pipeline statuses that count as "still active" (a + * pipeline that has not reached a terminal state). Server-side twin: + * packages/server/src/services/gitlab-facts.ts (GITLAB_ACTIVE_PIPELINE_STATUSES), + * duplicated because the app can't depend on the server package. + */ +const GITLAB_ACTIVE_PIPELINE_STATUSES = [ + "created", + "waiting_for_resource", + "preparing", + "pending", + "running", + "scheduled", +] as const; + +const GITLAB_ACTIVE_PIPELINE_STATUS_SET = new Set(GITLAB_ACTIVE_PIPELINE_STATUSES); + +export function isPipelineActiveStatus(status: string): boolean { + return GITLAB_ACTIVE_PIPELINE_STATUS_SET.has(status); +} + +export function mapPipelineStatus(status: string): CheckStatus { + switch (status) { + case "success": + case "passed": + return "success"; + case "failed": + return "failure"; + case "running": + case "pending": + case "created": + case "waiting_for_resource": + case "preparing": + case "scheduled": + case "manual": + return "pending"; + case "canceled": + case "cancelled": + case "skipped": + return "skipped"; + default: + return "pending"; + } +} + +const GitlabMergeFactsSchema = z + .object({ + forge: z.literal("gitlab"), + detailedMergeStatus: z.string().nullable().optional().default(null), + mergeStatus: z.string().nullable().optional().default(null), + hasConflicts: z.boolean().optional().default(false), + blockingDiscussionsResolved: z.boolean().optional().default(true), + approvalsRequired: z.number().optional().default(0), + approvalsGiven: z.number().optional().default(0), + pipelineStatus: z.string().nullable().optional().default(null), + pipelineId: z.number().nullable().optional().default(null), + pipelineUrl: z.string().nullable().optional().default(null), + mergeWhenPipelineSucceeds: z.boolean().optional().default(false), + }) + .passthrough(); + +export type GitlabMergeFacts = z.infer; + +export { GitlabMergeFactsSchema }; + +const GITLAB_MERGEABLE_STATUS = "mergeable"; +const GITLAB_LEGACY_MERGEABLE_STATUS = "can_be_merged"; +const GITLAB_MERGE_METHODS: CheckoutPrMergeMethod[] = ["merge", "squash", "rebase"]; + +/** + * Direct-merge readiness from GitLab's merge signals. `detailedMergeStatus` is + * GitLab 15.6+ only; when it is absent (older self-managed instances) fall back + * to the legacy `mergeStatus === "can_be_merged"` with no conflicts so merge is + * not silently refused everywhere. + */ +function isGitlabDirectMergeReady(gitlab: GitlabMergeFacts): boolean { + if (gitlab.detailedMergeStatus != null) { + return gitlab.detailedMergeStatus === GITLAB_MERGEABLE_STATUS; + } + return gitlab.mergeStatus === GITLAB_LEGACY_MERGEABLE_STATUS && gitlab.hasConflicts !== true; +} + +function deriveGitlabMergeCapability(gitlab: GitlabMergeFacts): MergeCapability { + const autoMergeEnabled = gitlab.mergeWhenPipelineSucceeds === true; + const hasActivePipeline = + gitlab.pipelineStatus !== null && isPipelineActiveStatus(gitlab.pipelineStatus); + return { + directMergeReady: isGitlabDirectMergeReady(gitlab), + canEnableAutoMerge: !autoMergeEnabled && hasActivePipeline, + autoMergeEnabled, + canDisableAutoMerge: autoMergeEnabled, + mergeBlockedByQueue: false, + allowedMethods: GITLAB_MERGE_METHODS, + preferredMethod: null, + }; +} + +export interface GitlabPipelineSummary { + id: number; + status: CheckStatus; + rawStatus: string; + url: string | null; +} + +export interface GitlabApprovals { + given: number; + required: number; +} + +export function deriveGitlabPipelineSummary(facts: GitlabMergeFacts): GitlabPipelineSummary | null { + if (facts.pipelineId == null) { + return null; + } + const rawStatus = facts.pipelineStatus ?? ""; + return { + id: facts.pipelineId, + status: mapPipelineStatus(rawStatus), + rawStatus, + url: facts.pipelineUrl ?? null, + }; +} + +export function deriveGitlabApprovals(facts: GitlabMergeFacts): GitlabApprovals | null { + const required = facts.approvalsRequired ?? 0; + if (required <= 0) { + return null; + } + return { given: facts.approvalsGiven ?? 0, required }; +} + +export const gitlabForgeLogic = { + id: "gitlab", + urlGrammar: { + treeInfix: "/-/tree/", + blobInfix: "/-/blob/", + lineAnchor: gitlabLineAnchor, + }, + facts: defineForgeFacts({ + family: "gitlab", + schema: GitlabMergeFactsSchema, + deriveMergeCapability: deriveGitlabMergeCapability, + }), +} satisfies ClientForgeLogicModule; diff --git a/packages/app/src/git/forges/gitlab.view.tsx b/packages/app/src/git/forges/gitlab.view.tsx new file mode 100644 index 000000000..d88ade1f9 --- /dev/null +++ b/packages/app/src/git/forges/gitlab.view.tsx @@ -0,0 +1,328 @@ +import React, { useCallback, useMemo, type ReactNode } from "react"; +import { Pressable, Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { CircleCheck, ExternalLink } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; +import type { CheckoutPipelineJob, CheckoutPipelineStage } from "@getpaseo/protocol/messages"; +import { GitLabIcon } from "@/components/icons/gitlab-icon"; +import { + definePaneContribution, + type ClientForgeViewModule, + type PaneChecksSlotContext, +} from "@/git/client-forge-module"; +import { openExternalUrl } from "@/utils/open-external-url"; +import { formatDuration } from "@/utils/time"; +import { + CheckStatusIcon, + Section, + SUMMARY_DANGER_ICON, + SUMMARY_SUCCESS_ICON, + SUMMARY_WARNING_ICON, + SummaryPill, + foregroundMutedColorMapping, + sectionKitStyles, + successColorMapping, +} from "@/git/pull-request-panel/section-kit"; +import { useGitLabPipeline } from "@/git/pull-request-panel/use-pipeline"; +import { + deriveGitlabApprovals, + deriveGitlabPipelineSummary, + GitlabMergeFactsSchema, + isPipelineActiveStatus, + mapPipelineStatus, + type GitlabApprovals, + type GitlabMergeFacts, + type GitlabPipelineSummary, +} from "./gitlab"; + +function renderGitlabHeaderMeta(facts: GitlabMergeFacts): ReactNode { + const approvals = deriveGitlabApprovals(facts); + if (!approvals) { + return null; + } + return ; +} + +function renderGitlabChecksSection(facts: GitlabMergeFacts, ctx: PaneChecksSlotContext): ReactNode { + if (!ctx.enabled) { + return null; + } + const summary = deriveGitlabPipelineSummary(facts); + if (!summary) { + return null; + } + return ( + + ); +} + +const ThemedCircleCheck = withUnistyles(CircleCheck); +const ThemedExternalLink = withUnistyles(ExternalLink); + +function GitlabApprovalsBadge({ approvals }: { approvals: GitlabApprovals }) { + const { t } = useTranslation(); + return ( + + = approvals.required ? successColorMapping : foregroundMutedColorMapping + } + /> + + {t("workspace.git.pr.approvals", { given: approvals.given, required: approvals.required })} + + + ); +} + +function rowPressableStyle({ hovered }: { hovered?: boolean }) { + return [sectionKitStyles.checkRow, Boolean(hovered) && styles.hoverable]; +} + +function jobRowPressableStyle({ hovered }: { hovered?: boolean }) { + return [styles.pipelineJobRow, Boolean(hovered) && styles.hoverable]; +} + +function formatPipelineDuration(seconds: number | null): string { + if (seconds === null || seconds <= 0) { + return ""; + } + return formatDuration(seconds * 1000); +} + +interface PipelineJobCounts { + passed: number; + failed: number; + pending: number; +} + +function countPipelineJobs(jobs: CheckoutPipelineJob[]): PipelineJobCounts { + const counts: PipelineJobCounts = { passed: 0, failed: 0, pending: 0 }; + for (const job of jobs) { + const status = mapPipelineStatus(job.status); + if (status === "success") counts.passed += 1; + else if (status === "failure") counts.failed += 1; + else if (status === "pending") counts.pending += 1; + } + return counts; +} + +function GitLabPipelineSection({ + serverId, + cwd, + changeRequestNumber, + summary, + open, + onToggle, + canFetchCheckDetails, +}: { + serverId: string; + cwd: string; + changeRequestNumber: number; + summary: GitlabPipelineSummary; + open: boolean; + onToggle: () => void; + canFetchCheckDetails: boolean; +}) { + const { t } = useTranslation(); + const { pipeline, isLoading, isPlaceholderData, error } = useGitLabPipeline({ + serverId, + cwd, + pipelineId: summary.id, + changeRequestNumber, + enabled: open && canFetchCheckDetails, + live: isPipelineActiveStatus(summary.rawStatus), + }); + + const counts = useMemo( + () => countPipelineJobs((pipeline?.stages ?? []).flatMap((stage) => stage.jobs)), + [pipeline], + ); + + const totalCounted = counts.passed + counts.failed + counts.pending; + const showBreakdown = !isPlaceholderData && totalCounted > 0; + const displayCounts = showBreakdown ? counts : { passed: 0, failed: 0, pending: 0 }; + + const handleOpenPipeline = useCallback(() => { + if (summary.url) { + void openExternalUrl(summary.url); + } + }, [summary.url]); + + const sectionSummary = ( + <> + + + + {showBreakdown ? null : } + + ); + + return ( +
+ + + + {`Pipeline #${summary.id}`} + + {summary.rawStatus ? ( + + {summary.rawStatus} + + ) : null} + {summary.url ? ( + + + + ) : null} + + {isLoading ? ( + + {t("workspace.git.pr.empty.loadingPipeline")} + + ) : null} + {!isLoading && pipeline && pipeline.stages.length === 0 ? ( + {t("workspace.git.pr.empty.noJobs")} + ) : null} + {!isLoading && pipeline && pipeline.stages.length > 0 + ? pipeline.stages.map((stage) => ) + : null} + {!isLoading && !pipeline && error ? ( + + {t("workspace.git.pr.empty.pipelineJobsLoadFailed")} + + ) : null} +
+ ); +} + +function PipelineStageGroup({ stage }: { stage: CheckoutPipelineStage }) { + return ( + + + + + {stage.name} + + + {stage.jobs.map((job) => ( + + ))} + + ); +} + +function PipelineJobRow({ job }: { job: CheckoutPipelineJob }) { + const { t } = useTranslation(); + const handlePress = useCallback(() => { + if (job.url) { + void openExternalUrl(job.url); + } + }, [job.url]); + const duration = formatPipelineDuration(job.durationSeconds); + return ( + + + + {job.name} + + {job.allowFailure ? ( + + {t("workspace.git.pr.empty.allowedToFail")} + + ) : null} + + {duration ? {duration} : null} + + + ); +} + +export const gitlabForgeView = { + id: "gitlab", + icon: GitLabIcon, + brandColor: { + light: "#FC6D26", + dark: "#FC6D26", + }, + paneContributions: [ + definePaneContribution(GitlabMergeFactsSchema, { + renderHeaderMeta: renderGitlabHeaderMeta, + renderChecksSection: renderGitlabChecksSection, + }), + ], +} satisfies ClientForgeViewModule; + +const styles = StyleSheet.create((theme) => ({ + approvalsBadge: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + approvalsText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + hoverable: { + backgroundColor: theme.colors.surface1, + }, + pipelineStageHeader: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[1], + }, + pipelineStageName: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + textTransform: "uppercase", + letterSpacing: 0.5, + flexShrink: 1, + }, + pipelineJobRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingVertical: theme.spacing[2], + paddingRight: theme.spacing[3], + paddingLeft: theme.spacing[6], + minHeight: 32, + }, +})); diff --git a/packages/app/src/git/forges/index.test.ts b/packages/app/src/git/forges/index.test.ts new file mode 100644 index 000000000..a0bacafce --- /dev/null +++ b/packages/app/src/git/forges/index.test.ts @@ -0,0 +1,59 @@ +import { readdirSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { CLIENT_FORGE_LOGIC_MODULES } from "./index"; +import { CLIENT_FORGE_VIEW_MODULES } from "./view"; + +/** + * Adding a forge is "create forges/.ts (logic) + forges/.view.tsx (view) + * + register one line in each registry". This test removes the only failure mode + * of the explicit registries — forgetting a line — without resorting to + * bundler-specific auto-discovery that Metro, Vite, and tsgo would each need to + * understand differently. The logic/view split keeps logic consumers (URL + * builders, merge-capability, native checks, and the Node e2e harness) free of + * the client rendering stack. + */ +const forgesDir = path.dirname(new URL(import.meta.url).pathname); +const dirEntries = readdirSync(forgesDir); + +const logicModuleIds = dirEntries + .filter((file) => /^[a-z0-9-]+\.ts$/.test(file) && file !== "index.ts" && file !== "view.ts") + .map((file) => file.replace(/\.ts$/, "")) + .sort(); + +const viewModuleIds = dirEntries + .filter((file) => /^[a-z0-9-]+\.view\.tsx$/.test(file)) + .map((file) => file.replace(/\.view\.tsx$/, "")) + .sort(); + +describe("CLIENT_FORGE_LOGIC_MODULES completeness", () => { + it("registers every forge logic module file in the directory", () => { + const registeredIds = CLIENT_FORGE_LOGIC_MODULES.map((module) => module.id).sort(); + expect(registeredIds).toEqual(logicModuleIds); + }); + + it("has no duplicate forge ids", () => { + const ids = CLIENT_FORGE_LOGIC_MODULES.map((module) => module.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); + +describe("CLIENT_FORGE_VIEW_MODULES completeness", () => { + it("registers every forge view module file in the directory", () => { + const registeredIds = CLIENT_FORGE_VIEW_MODULES.map((module) => module.id).sort(); + expect(registeredIds).toEqual(viewModuleIds); + }); + + it("has no duplicate forge ids", () => { + const ids = CLIENT_FORGE_VIEW_MODULES.map((module) => module.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); + +describe("forge logic and view registries stay paired", () => { + it("has a view module for every logic module and vice versa", () => { + const logicIds = CLIENT_FORGE_LOGIC_MODULES.map((module) => module.id).sort(); + const viewIds = CLIENT_FORGE_VIEW_MODULES.map((module) => module.id).sort(); + expect(logicIds).toEqual(viewIds); + }); +}); diff --git a/packages/app/src/git/forges/index.ts b/packages/app/src/git/forges/index.ts new file mode 100644 index 000000000..9e30e2832 --- /dev/null +++ b/packages/app/src/git/forges/index.ts @@ -0,0 +1,34 @@ +import type { ClientForgeLogicModule, ForgeSpecificEnvelope } from "@/git/client-forge-module"; +import { codebergForgeLogic } from "./codeberg"; +import { forgejoForgeLogic } from "./forgejo"; +import { giteaForgeLogic } from "./gitea"; +import { githubForgeLogic } from "./github"; +import { gitlabForgeLogic } from "./gitlab"; + +/** + * Pure logic registry. Import this (never the view registry) from URL builders, + * merge-capability, and native-check derivations so those paths — and the + * Node-based e2e harness that transitively imports them — stay free of the + * client rendering stack (react-native, react-native-svg, unistyles). + */ +export const CLIENT_FORGE_LOGIC_MODULES: readonly ClientForgeLogicModule[] = [ + githubForgeLogic, + gitlabForgeLogic, + giteaForgeLogic, + forgejoForgeLogic, + codebergForgeLogic, +]; + +export function getClientForgeLogicModule(id: string): ClientForgeLogicModule | null { + return CLIENT_FORGE_LOGIC_MODULES.find((module) => module.id === id) ?? null; +} + +export function parseClientForgeFacts(facts: unknown): ForgeSpecificEnvelope | null { + for (const module of CLIENT_FORGE_LOGIC_MODULES) { + const parsed = module.facts?.parse(facts); + if (parsed) { + return parsed; + } + } + return null; +} diff --git a/packages/app/src/git/forges/view.ts b/packages/app/src/git/forges/view.ts new file mode 100644 index 000000000..c27c4eae2 --- /dev/null +++ b/packages/app/src/git/forges/view.ts @@ -0,0 +1,23 @@ +import type { ClientForgeViewModule } from "@/git/client-forge-module"; +import { codebergForgeView } from "./codeberg.view"; +import { forgejoForgeView } from "./forgejo.view"; +import { giteaForgeView } from "./gitea.view"; +import { githubForgeView } from "./github.view"; +import { gitlabForgeView } from "./gitlab.view"; + +/** + * View registry: brand marks, brand colors, and PR-pane render contributions. + * Import this only from rendering code (icon lookup, the PR pane); it pulls the + * client rendering stack, so logic paths must use the logic registry instead. + */ +export const CLIENT_FORGE_VIEW_MODULES: readonly ClientForgeViewModule[] = [ + githubForgeView, + gitlabForgeView, + giteaForgeView, + forgejoForgeView, + codebergForgeView, +]; + +export function getClientForgeViewModule(id: string): ClientForgeViewModule | null { + return CLIENT_FORGE_VIEW_MODULES.find((module) => module.id === id) ?? null; +} diff --git a/packages/app/src/git/github-url.test.ts b/packages/app/src/git/github-url.test.ts deleted file mode 100644 index 9b6329caf..000000000 --- a/packages/app/src/git/github-url.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildGitHubBlobUrl, - buildGitHubBranchTreeUrl, - parseGitHubRepoFromRemote, -} from "./github-url"; - -describe("parseGitHubRepoFromRemote", () => { - it.each([ - ["https://github.com/acme/repo.git", "acme/repo"], - ["https://github.com/acme/repo", "acme/repo"], - ["http://github.com/acme/repo.git", "acme/repo"], - ["git@github.com:acme/repo.git", "acme/repo"], - ["ssh://git@github.com/acme/repo.git", "acme/repo"], - ["ssh://git@ssh.github.com/acme/repo.git", "acme/repo"], - ["https://github.com/acme/repo/", "acme/repo"], - ])("extracts the repo from %s", (remoteUrl, expected) => { - expect(parseGitHubRepoFromRemote(remoteUrl)).toBe(expected); - }); - - it("returns null for non-GitHub remotes", () => { - expect(parseGitHubRepoFromRemote("git@gitlab.com:acme/repo.git")).toBeNull(); - }); - - it("returns null for invalid URLs", () => { - expect(parseGitHubRepoFromRemote("not a url")).toBeNull(); - }); -}); - -describe("buildGitHubBranchTreeUrl", () => { - it("builds a branch-specific GitHub tree URL", () => { - expect( - buildGitHubBranchTreeUrl({ - remoteUrl: "git@github.com:acme/repo.git", - branch: "feature/workspace-button", - }), - ).toBe("https://github.com/acme/repo/tree/feature/workspace-button"); - }); - - it("encodes reserved branch characters while preserving slash-separated branch names", () => { - expect( - buildGitHubBranchTreeUrl({ - remoteUrl: "https://github.com/acme/repo.git", - branch: "feature/ship #42", - }), - ).toBe("https://github.com/acme/repo/tree/feature/ship%20%2342"); - }); - - it("returns null when the current branch is unavailable", () => { - expect( - buildGitHubBranchTreeUrl({ - remoteUrl: "https://github.com/acme/repo.git", - branch: "HEAD", - }), - ).toBeNull(); - }); -}); - -describe("buildGitHubBlobUrl", () => { - it("builds a blob URL for a file path", () => { - expect( - buildGitHubBlobUrl({ - remoteUrl: "git@github.com:acme/repo.git", - branch: "main", - path: "src/index.ts", - }), - ).toBe("https://github.com/acme/repo/blob/main/src/index.ts"); - }); - - it("appends a single-line anchor", () => { - expect( - buildGitHubBlobUrl({ - remoteUrl: "https://github.com/acme/repo.git", - branch: "main", - path: "src/index.ts", - lineStart: 12, - }), - ).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12"); - }); - - it("appends a line range anchor", () => { - expect( - buildGitHubBlobUrl({ - remoteUrl: "https://github.com/acme/repo.git", - branch: "main", - path: "src/index.ts", - lineStart: 12, - lineEnd: 20, - }), - ).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12-L20"); - }); - - it("strips leading slashes and encodes path segments", () => { - expect( - buildGitHubBlobUrl({ - remoteUrl: "https://github.com/acme/repo.git", - branch: "main", - path: "/src/a b/c#d.ts", - }), - ).toBe("https://github.com/acme/repo/blob/main/src/a%20b/c%23d.ts"); - }); - - it("normalizes harmless dot segments in the blob path", () => { - expect( - buildGitHubBlobUrl({ - remoteUrl: "https://github.com/acme/repo.git", - branch: "main", - path: "./src/../index.ts", - }), - ).toBe("https://github.com/acme/repo/blob/main/index.ts"); - }); - - it("returns null for blob paths that escape above the repo root", () => { - expect( - buildGitHubBlobUrl({ - remoteUrl: "https://github.com/acme/repo.git", - branch: "main", - path: "../outside.ts", - }), - ).toBeNull(); - }); - - it("returns null when the path is missing", () => { - expect( - buildGitHubBlobUrl({ - remoteUrl: "https://github.com/acme/repo.git", - branch: "main", - path: "", - }), - ).toBeNull(); - }); -}); diff --git a/packages/app/src/git/github-url.ts b/packages/app/src/git/github-url.ts deleted file mode 100644 index 875da903a..000000000 --- a/packages/app/src/git/github-url.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { parseGitHubRemoteUrl } from "@getpaseo/protocol/git-remote"; - -export function parseGitHubRepoFromRemote(remoteUrl: string | null | undefined): string | null { - const trimmed = remoteUrl?.trim(); - if (!trimmed) { - return null; - } - return parseGitHubRemoteUrl(trimmed)?.repo ?? null; -} - -export function buildGitHubBranchTreeUrl(input: { - remoteUrl: string | null | undefined; - branch: string | null | undefined; -}): string | null { - const repo = parseGitHubRepoFromRemote(input.remoteUrl); - const branch = input.branch?.trim(); - if (!repo || !branch || branch === "HEAD") { - return null; - } - const encodedBranch = branch.split("/").map(encodeURIComponent).join("/"); - return `https://github.com/${repo}/tree/${encodedBranch}`; -} - -function normalizeGitHubBlobPath(path: string | null | undefined): string | null { - const segments: string[] = []; - const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, ""); - if (!trimmed) { - return null; - } - for (const segment of trimmed.split("/")) { - if (!segment || segment === ".") { - continue; - } - if (segment === "..") { - if (segments.length === 0) { - return null; - } - segments.pop(); - continue; - } - segments.push(segment); - } - return segments.join("/") || null; -} - -export function buildGitHubBlobUrl(input: { - remoteUrl: string | null | undefined; - branch: string | null | undefined; - path: string | null | undefined; - lineStart?: number; - lineEnd?: number; -}): string | null { - const repo = parseGitHubRepoFromRemote(input.remoteUrl); - const branch = input.branch?.trim(); - const filePath = normalizeGitHubBlobPath(input.path); - if (!repo || !branch || branch === "HEAD" || !filePath) { - return null; - } - const encodedBranch = branch.split("/").map(encodeURIComponent).join("/"); - const encodedPath = filePath.split("/").map(encodeURIComponent).join("/"); - let url = `https://github.com/${repo}/blob/${encodedBranch}/${encodedPath}`; - if (input.lineStart && input.lineStart > 0) { - url += `#L${input.lineStart}`; - if (input.lineEnd && input.lineEnd > input.lineStart) { - url += `-L${input.lineEnd}`; - } - } - return url; -} diff --git a/packages/app/src/git/merge-capability.test.ts b/packages/app/src/git/merge-capability.test.ts new file mode 100644 index 000000000..129da37fe --- /dev/null +++ b/packages/app/src/git/merge-capability.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it } from "vitest"; + +import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability"; + +type GithubMergeFactsFixture = ForgeSpecificStatusFacts & { + forge: "github"; + mergeStateStatus: string | null; + autoMergeRequest: { + enabledAt: string | null; + mergeMethod: string | null; + enabledBy: string | null; + } | null; + viewerCanEnableAutoMerge: boolean; + viewerCanDisableAutoMerge: boolean; + viewerCanMergeAsAdmin: boolean; + viewerCanUpdateBranch: boolean; + repository: { + autoMergeAllowed: boolean; + mergeCommitAllowed: boolean; + squashMergeAllowed: boolean; + rebaseMergeAllowed: boolean; + viewerDefaultMergeMethod: string | null; + }; + isMergeQueueEnabled: boolean; + isInMergeQueue: boolean; +}; + +type GitlabMergeFactsFixture = ForgeSpecificStatusFacts & { + forge: "gitlab"; + detailedMergeStatus: string | null; + hasConflicts: boolean; + blockingDiscussionsResolved: boolean; + approvalsRequired: number; + approvalsGiven: number; + pipelineStatus: string | null; + pipelineId: number | null; + pipelineUrl: string | null; + mergeWhenPipelineSucceeds: boolean; +}; + +type GiteaMergeFactsFixture = ForgeSpecificStatusFacts & { + forge: "gitea"; + mergeable: boolean; + hasMerged: boolean; + ciStatus: string | null; +}; + +function facts(overrides: Partial = {}): GithubMergeFactsFixture { + return { + forge: "github", + mergeStateStatus: "CLEAN", + autoMergeRequest: null, + viewerCanEnableAutoMerge: false, + viewerCanDisableAutoMerge: false, + viewerCanMergeAsAdmin: false, + viewerCanUpdateBranch: false, + repository: { + autoMergeAllowed: false, + mergeCommitAllowed: true, + squashMergeAllowed: true, + rebaseMergeAllowed: true, + viewerDefaultMergeMethod: "SQUASH", + }, + isMergeQueueEnabled: false, + isInMergeQueue: false, + ...overrides, + }; +} + +function gitlabFacts(overrides: Partial = {}): GitlabMergeFactsFixture { + return { + forge: "gitlab", + detailedMergeStatus: "mergeable", + hasConflicts: false, + blockingDiscussionsResolved: true, + approvalsRequired: 0, + approvalsGiven: 0, + pipelineStatus: "success", + pipelineId: null, + pipelineUrl: null, + mergeWhenPipelineSucceeds: false, + ...overrides, + }; +} + +function giteaFacts(overrides: Partial = {}): GiteaMergeFactsFixture { + return { + forge: "gitea", + mergeable: true, + hasMerged: false, + ciStatus: "success", + ...overrides, + }; +} + +describe("deriveMergeCapability", () => { + it("returns null when the forge supplied no merge facts", () => { + expect(deriveMergeCapability(null)).toBeNull(); + expect(deriveMergeCapability(undefined)).toBeNull(); + }); + + it("rejects untagged and schema-mismatched forge facts at the registry boundary", () => { + expect(deriveMergeCapability({ approvalsRequired: 2 })).toBeNull(); + expect( + deriveMergeCapability({ + ...gitlabFacts(), + approvalsRequired: "two", + }), + ).toBeNull(); + }); + + it("marks direct merge ready only for the GitHub clean states", () => { + expect(deriveMergeCapability(facts({ mergeStateStatus: "CLEAN" }))?.directMergeReady).toBe( + true, + ); + expect(deriveMergeCapability(facts({ mergeStateStatus: "HAS_HOOKS" }))?.directMergeReady).toBe( + true, + ); + expect(deriveMergeCapability(facts({ mergeStateStatus: "BLOCKED" }))?.directMergeReady).toBe( + false, + ); + expect(deriveMergeCapability(facts({ mergeStateStatus: null }))?.directMergeReady).toBe(false); + }); + + it("can enable auto-merge only when blocked, allowed, and the viewer may enable it", () => { + const ready = facts({ + mergeStateStatus: "BLOCKED", + viewerCanEnableAutoMerge: true, + repository: { + autoMergeAllowed: true, + mergeCommitAllowed: true, + squashMergeAllowed: true, + rebaseMergeAllowed: true, + viewerDefaultMergeMethod: "SQUASH", + }, + }); + expect(deriveMergeCapability(ready)?.canEnableAutoMerge).toBe(true); + + expect( + deriveMergeCapability(facts({ ...ready, viewerCanEnableAutoMerge: false })) + ?.canEnableAutoMerge, + ).toBe(false); + expect( + deriveMergeCapability(facts({ ...ready, mergeStateStatus: "CLEAN" }))?.canEnableAutoMerge, + ).toBe(false); + }); + + it("reports whether auto-merge is already enabled and can be disabled", () => { + const cap = deriveMergeCapability( + facts({ + autoMergeRequest: { enabledAt: "now", mergeMethod: "SQUASH", enabledBy: "octocat" }, + viewerCanDisableAutoMerge: true, + }), + ); + expect(cap?.autoMergeEnabled).toBe(true); + expect(cap?.canDisableAutoMerge).toBe(true); + expect(deriveMergeCapability(facts())?.autoMergeEnabled).toBe(false); + }); + + it("treats an enabled or in-progress merge queue as blocking", () => { + expect(deriveMergeCapability(facts({ isMergeQueueEnabled: true }))?.mergeBlockedByQueue).toBe( + true, + ); + expect(deriveMergeCapability(facts({ isInMergeQueue: true }))?.mergeBlockedByQueue).toBe(true); + expect(deriveMergeCapability(facts())?.mergeBlockedByQueue).toBe(false); + }); + + it("derives allowed methods and the preferred method from the repository policy", () => { + const cap = deriveMergeCapability( + facts({ + repository: { + autoMergeAllowed: false, + mergeCommitAllowed: false, + squashMergeAllowed: true, + rebaseMergeAllowed: true, + viewerDefaultMergeMethod: "REBASE", + }, + }), + ); + expect(cap?.allowedMethods).toEqual(["squash", "rebase"]); + expect(cap?.preferredMethod).toBe("rebase"); + }); + + it("returns a null preferred method when the forge reports an unknown default", () => { + expect( + deriveMergeCapability( + facts({ repository: { ...facts().repository, viewerDefaultMergeMethod: null } }), + )?.preferredMethod, + ).toBeNull(); + }); +}); + +describe("deriveMergeCapability (legacy github fallback)", () => { + it("synthesizes full GitHub capability from legacy status.github when forgeSpecific is absent", () => { + const { forge: _forge, ...legacy } = facts({ + mergeStateStatus: "CLEAN", + repository: { + autoMergeAllowed: false, + mergeCommitAllowed: false, + squashMergeAllowed: true, + rebaseMergeAllowed: false, + viewerDefaultMergeMethod: "SQUASH", + }, + }); + const cap = deriveMergeCapability(undefined, legacy); + expect(cap).not.toBeNull(); + expect(cap?.directMergeReady).toBe(true); + expect(cap?.allowedMethods).toEqual(["squash"]); + expect(cap?.preferredMethod).toBe("squash"); + }); + + it("returns null when both forgeSpecific and legacy github facts are absent", () => { + expect(deriveMergeCapability(undefined, null)).toBeNull(); + expect(deriveMergeCapability(undefined, undefined)).toBeNull(); + }); +}); + +describe("deriveMergeCapability (gitlab)", () => { + it("produces a non-null capability for the gitlab arm", () => { + expect(deriveMergeCapability(gitlabFacts())).not.toBeNull(); + }); + + it("marks direct merge ready only when GitLab reports the mergeable status", () => { + expect( + deriveMergeCapability(gitlabFacts({ detailedMergeStatus: "mergeable" }))?.directMergeReady, + ).toBe(true); + expect( + deriveMergeCapability(gitlabFacts({ detailedMergeStatus: "ci_still_running" })) + ?.directMergeReady, + ).toBe(false); + expect( + deriveMergeCapability(gitlabFacts({ detailedMergeStatus: "discussions_not_resolved" })) + ?.directMergeReady, + ).toBe(false); + expect( + deriveMergeCapability(gitlabFacts({ detailedMergeStatus: null }))?.directMergeReady, + ).toBe(false); + }); + + it("reflects merge-when-pipeline-succeeds as an enabled auto-merge", () => { + const enabled = deriveMergeCapability( + gitlabFacts({ mergeWhenPipelineSucceeds: true, pipelineStatus: "running" }), + ); + expect(enabled?.autoMergeEnabled).toBe(true); + expect(enabled?.canDisableAutoMerge).toBe(true); + expect(enabled?.canEnableAutoMerge).toBe(false); + expect(deriveMergeCapability(gitlabFacts())?.autoMergeEnabled).toBe(false); + }); + + it("can enable auto-merge only while a pipeline is still in flight", () => { + expect( + deriveMergeCapability(gitlabFacts({ pipelineStatus: "created" }))?.canEnableAutoMerge, + ).toBe(true); + expect( + deriveMergeCapability(gitlabFacts({ pipelineStatus: "waiting_for_resource" })) + ?.canEnableAutoMerge, + ).toBe(true); + expect( + deriveMergeCapability(gitlabFacts({ pipelineStatus: "preparing" }))?.canEnableAutoMerge, + ).toBe(true); + expect( + deriveMergeCapability(gitlabFacts({ pipelineStatus: "pending" }))?.canEnableAutoMerge, + ).toBe(true); + expect( + deriveMergeCapability(gitlabFacts({ pipelineStatus: "running" }))?.canEnableAutoMerge, + ).toBe(true); + expect( + deriveMergeCapability(gitlabFacts({ pipelineStatus: "scheduled" }))?.canEnableAutoMerge, + ).toBe(true); + expect( + deriveMergeCapability(gitlabFacts({ pipelineStatus: "success" }))?.canEnableAutoMerge, + ).toBe(false); + expect(deriveMergeCapability(gitlabFacts({ pipelineStatus: null }))?.canEnableAutoMerge).toBe( + false, + ); + }); + + it("offers GitLab merge methods and never reports a merge queue", () => { + const cap = deriveMergeCapability(gitlabFacts()); + expect(cap?.allowedMethods).toEqual(["merge", "squash", "rebase"]); + expect(cap?.mergeBlockedByQueue).toBe(false); + expect(cap?.canEnableAutoMerge).toBe(false); + }); +}); + +describe("deriveMergeCapability (gitea)", () => { + it("allows direct merge only when the pull request is mergeable and unmerged", () => { + expect(deriveMergeCapability(giteaFacts())?.directMergeReady).toBe(true); + expect(deriveMergeCapability(giteaFacts({ mergeable: false }))?.directMergeReady).toBe(false); + expect(deriveMergeCapability(giteaFacts({ hasMerged: true }))?.directMergeReady).toBe(false); + }); + + it("offers direct merge styles without auto-merge", () => { + const capability = deriveMergeCapability(giteaFacts()); + expect(capability?.allowedMethods).toEqual(["merge", "squash", "rebase"]); + expect(capability?.canEnableAutoMerge).toBe(false); + expect(capability?.autoMergeEnabled).toBe(false); + expect(capability?.canDisableAutoMerge).toBe(false); + }); +}); diff --git a/packages/app/src/git/merge-capability.ts b/packages/app/src/git/merge-capability.ts new file mode 100644 index 000000000..ec294ac51 --- /dev/null +++ b/packages/app/src/git/merge-capability.ts @@ -0,0 +1,51 @@ +import { + type ForgeSpecificEnvelope, + type LegacyGithubMergeFacts, + type MergeCapability, +} from "@/git/client-forge-module"; +import { CLIENT_FORGE_LOGIC_MODULES } from "@/git/forges"; + +export type ForgeSpecificStatusFacts = ForgeSpecificEnvelope; + +export type { LegacyGithubMergeFacts, MergeCapability }; + +/** + * Resolve the neutral merge capability from a registered forge's runtime facts. + * Returns null for an unregistered, unknown, or schema-mismatched facts family; + * callers then fall back to raw git state. + */ +function deriveForgeMergeCapability(facts: unknown): MergeCapability | null { + for (const module of CLIENT_FORGE_LOGIC_MODULES) { + const capability = module.facts?.deriveMergeCapability(facts); + if (capability) { + return capability; + } + } + return null; +} + +/** + * Build the neutral merge capability from a forge's PR status facts. Returns + * null when the forge supplied no merge facts (e.g. a host that exposes none, or + * an unknown forge), in which case the caller falls back to raw git state. + * Per-forge derivation lives on client forge modules; this stays the single + * neutral entry point the action policy reads. + * + * COMPAT(forgeSpecific): added in v0.1.106, remove after 2026-12-27. A daemon + * predating forgeSpecific emits only the legacy `status.github` field. When + * forgeSpecific is absent we synthesize the github arm from those legacy facts + * so GitHub merge-method restrictions and auto-merge state are not lost against + * an old daemon. + */ +export function deriveMergeCapability( + forgeSpecific: unknown, + legacyGithubFacts?: LegacyGithubMergeFacts | null, +): MergeCapability | null { + if (forgeSpecific === null || forgeSpecific === undefined) { + if (legacyGithubFacts) { + return deriveForgeMergeCapability({ forge: "github", ...legacyGithubFacts }); + } + return null; + } + return deriveForgeMergeCapability(forgeSpecific); +} diff --git a/packages/app/src/git/policy.test.ts b/packages/app/src/git/policy.test.ts index 6b96cf98a..561bbc75e 100644 --- a/packages/app/src/git/policy.test.ts +++ b/packages/app/src/git/policy.test.ts @@ -3,11 +3,34 @@ import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages"; import { i18n } from "@/i18n/i18next"; import { buildGitActions, type BuildGitActionsInput } from "./policy"; +import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability"; -function githubStatus( - overrides: Partial> = {}, -): NonNullable { +type GithubMergeFactsFixture = ForgeSpecificStatusFacts & { + forge: "github"; + mergeStateStatus: string | null; + autoMergeRequest: { + enabledAt: string | null; + mergeMethod: string | null; + enabledBy: string | null; + } | null; + viewerCanEnableAutoMerge: boolean; + viewerCanDisableAutoMerge: boolean; + viewerCanMergeAsAdmin: boolean; + viewerCanUpdateBranch: boolean; + repository: { + autoMergeAllowed: boolean; + mergeCommitAllowed: boolean; + squashMergeAllowed: boolean; + rebaseMergeAllowed: boolean; + viewerDefaultMergeMethod: string | null; + }; + isMergeQueueEnabled: boolean; + isInMergeQueue: boolean; +}; + +function githubStatus(overrides: Partial = {}): GithubMergeFactsFixture { return { + forge: "github", mergeStateStatus: "CLEAN", autoMergeRequest: null, viewerCanEnableAutoMerge: false, @@ -27,10 +50,17 @@ function githubStatus( }; } -function createInput(overrides: Partial = {}): BuildGitActionsInput { +function createInput( + overrides: Partial> & { + pullRequestGithub?: unknown; + } = {}, +): BuildGitActionsInput { + const { pullRequestGithub = null, ...rest } = overrides; return { isGit: true, githubFeaturesEnabled: true, + forgeBrandLabel: "GitHub", + forgeChangeRequestNoun: "PR", githubAutoMergeActionsEnabled: true, hasPullRequest: false, pullRequestUrl: null, @@ -38,7 +68,7 @@ function createInput(overrides: Partial = {}): BuildGitAct pullRequestIsDraft: false, pullRequestIsMerged: false, pullRequestMergeable: "UNKNOWN", - pullRequestGithub: null, + mergeCapability: deriveMergeCapability(pullRequestGithub), hasRemote: false, isPaseoOwnedWorktree: false, isOnBaseBranch: true, @@ -128,7 +158,7 @@ function createInput(overrides: Partial = {}): BuildGitAct handler: () => undefined, }, }, - ...overrides, + ...rest, }; } @@ -344,6 +374,28 @@ describe("git-actions-policy", () => { }); }); + it("explains why pull-and-push is unavailable when there is nothing to pull first", () => { + const actions = buildGitActions( + createInput({ hasRemote: true, aheadOfOrigin: 1, behindOfOrigin: 0 }), + ); + const action = actions.secondary.find((entry) => entry.id === "pull-and-push"); + + expect(action?.unavailableMessage).toBe( + "Pull and push isn't available because there are no incoming changes to pull first", + ); + }); + + it("explains why pull-and-push is unavailable when there is nothing to push after pulling", () => { + const actions = buildGitActions( + createInput({ hasRemote: true, aheadOfOrigin: 0, behindOfOrigin: 1 }), + ); + const action = actions.secondary.find((entry) => entry.id === "pull-and-push"); + + expect(action?.unavailableMessage).toBe( + "Pull and push isn't available because there is nothing new to send after pulling", + ); + }); + it("explains why pull-and-push is unavailable when there are uncommitted changes", () => { const actions = buildGitActions( createInput({ @@ -507,6 +559,44 @@ describe("git-actions-policy", () => { }); }); + it("uses the forge change-request noun in unavailable no-forge copy", () => { + const createActions = buildGitActions( + createInput({ + githubFeaturesEnabled: false, + forgeBrandLabel: "GitLab", + forgeChangeRequestNoun: "MR", + hasRemote: true, + isOnBaseBranch: false, + aheadCount: 1, + }), + ); + const viewActions = buildGitActions( + createInput({ + githubFeaturesEnabled: false, + forgeBrandLabel: "GitLab", + forgeChangeRequestNoun: "MR", + hasRemote: true, + isOnBaseBranch: false, + hasPullRequest: true, + pullRequestUrl: "https://gitlab.com/example/repo/-/merge_requests/1", + }), + ); + + const createPrAction = [...createActions.secondary, ...createActions.menu].find( + (action) => action.id === "pr", + ); + const viewPrAction = [viewActions.primary, ...viewActions.secondary, ...viewActions.menu].find( + (action) => action?.id === "pr", + ); + + expect(createPrAction?.unavailableMessage).toBe( + "Create MR isn't available right now because GitLab isn't connected", + ); + expect(viewPrAction?.unavailableMessage).toBe( + "View MR isn't available right now because GitLab isn't connected", + ); + }); + it("uses local merge when merge is the stored ship default", () => { const actions = buildGitActions( createInput({ @@ -726,12 +816,12 @@ describe("git-actions-policy", () => { pullRequestIsDraft: oldDaemonStatus.isDraft, pullRequestIsMerged: oldDaemonStatus.isMerged, pullRequestMergeable: oldDaemonStatus.mergeable, - pullRequestGithub: oldDaemonStatus.github, + pullRequestGithub: oldDaemonStatus.forgeSpecific, shipDefault: "pr", }), ); - expect(oldDaemonStatus.github).toBeUndefined(); + expect(oldDaemonStatus.forgeSpecific).toBeUndefined(); expect(actions.primary).toMatchObject({ id: "merge-pr-squash", label: "Merge PR (squash)", diff --git a/packages/app/src/git/policy.ts b/packages/app/src/git/policy.ts index a2cec4c1f..eb6e28399 100644 --- a/packages/app/src/git/policy.ts +++ b/packages/app/src/git/policy.ts @@ -2,11 +2,9 @@ import type { ReactElement } from "react"; import type { ActionStatus } from "@/components/ui/dropdown-menu"; import { i18n } from "@/i18n/i18next"; -import type { - CheckoutPrMergeMethod, - CheckoutPrStatusResponse, - PullRequestMergeable, -} from "@getpaseo/protocol/messages"; +import type { CheckoutPrMergeMethod, PullRequestMergeable } from "@getpaseo/protocol/messages"; + +import type { MergeCapability } from "./merge-capability"; export type GitActionId = | "commit" @@ -55,6 +53,10 @@ interface GitActionRuntimeState { export interface BuildGitActionsInput { isGit: boolean; githubFeaturesEnabled: boolean; + /** Forge brand label (e.g. "GitHub", "GitLab") for forge-neutral unavailable copy. */ + forgeBrandLabel: string; + /** Short change-request noun label (e.g. "PR", "MR") for forge-neutral unavailable copy. */ + forgeChangeRequestNoun: string; githubAutoMergeActionsEnabled: boolean; hasPullRequest: boolean; pullRequestUrl: string | null; @@ -62,7 +64,7 @@ export interface BuildGitActionsInput { pullRequestIsDraft: boolean; pullRequestIsMerged: boolean; pullRequestMergeable: PullRequestMergeable; - pullRequestGithub: PullRequestGithubStatus | null; + mergeCapability: MergeCapability | null; hasRemote: boolean; isPaseoOwnedWorktree: boolean; isOnBaseBranch: boolean; @@ -98,7 +100,6 @@ type PullRequestAutoMergeEnableActionId = Extract< "enable-pr-auto-merge-squash" | "enable-pr-auto-merge-merge" | "enable-pr-auto-merge-rebase" >; type PullRequestActionRole = "status" | "direct" | "auto"; -type PullRequestGithubStatus = NonNullable["github"]; interface PullRequestActionModel { readonly id: PullRequestActionId; @@ -180,7 +181,6 @@ const PULL_REQUEST_ACTION_MODELS: readonly PullRequestActionModel[] = [ ]; const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"]; -const GITHUB_DIRECT_MERGE_STATE_ALLOWLIST = new Set(["CLEAN", "HAS_HOOKS"]); export function narrowPullRequestState(state: string | null | undefined): "open" | "closed" | null { if (state === "open") return "open"; @@ -403,7 +403,10 @@ function buildPrAction(input: BuildGitActionsInput): GitAction { unavailableMessage: input.runtime.pr.disabled || input.githubFeaturesEnabled ? undefined - : i18n.t("workspace.git.actions.unavailable.viewPrNoGithub"), + : i18n.t("workspace.git.actions.unavailable.viewPrNoForge", { + brand: input.forgeBrandLabel, + noun: input.forgeChangeRequestNoun, + }), icon: input.runtime.pr.icon, startsGroup: false, handler: input.runtime.pr.handler, @@ -467,7 +470,7 @@ function buildEnablePullRequestAutoMergeAction( function buildDisablePullRequestAutoMergeAction(input: BuildGitActionsInput): GitAction { const runtime = input.runtime["disable-pr-auto-merge"]; const unavailableMessage = - input.pullRequestGithub?.viewerCanDisableAutoMerge === true + input.mergeCapability?.canDisableAutoMerge === true ? undefined : i18n.t("workspace.git.actions.unavailable.autoMergeCannotDisable"); return { @@ -475,7 +478,7 @@ function buildDisablePullRequestAutoMergeAction(input: BuildGitActionsInput): Gi label: i18n.t("workspace.git.actions.autoMerge.enabled"), pendingLabel: i18n.t("workspace.git.actions.autoMerge.disabling"), successLabel: i18n.t("workspace.git.actions.autoMerge.disabled"), - disabled: runtime.disabled || input.pullRequestGithub?.viewerCanDisableAutoMerge !== true, + disabled: runtime.disabled || input.mergeCapability?.canDisableAutoMerge !== true, status: runtime.status, unavailableMessage: runtime.disabled ? undefined : unavailableMessage, icon: runtime.icon, @@ -543,7 +546,7 @@ function canUsePullRequestActionAsShipDefault(input: BuildGitActionsInput): bool } function canMergePr(input: BuildGitActionsInput): boolean { - const github = input.pullRequestGithub; + const capability = input.mergeCapability; const canMergeFromPullRequestStatus = input.githubFeaturesEnabled && input.hasPullRequest && @@ -558,7 +561,7 @@ function canMergePr(input: BuildGitActionsInput): boolean { return false; } - if (!hasPullRequestGithubFacts(github)) { + if (capability === null) { return ( input.pullRequestMergeable === "MERGEABLE" && input.behindOfOrigin === 0 && @@ -568,16 +571,15 @@ function canMergePr(input: BuildGitActionsInput): boolean { } return ( - GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(github.mergeStateStatus ?? "") && - github.autoMergeRequest === null && - !github.isMergeQueueEnabled && - !github.isInMergeQueue && + capability.directMergeReady && + !capability.autoMergeEnabled && + !capability.mergeBlockedByQueue && getAllowedDirectPullRequestMergeActionModels(input).length > 0 ); } function canEnablePrAutoMerge(input: BuildGitActionsInput): boolean { - const github = input.pullRequestGithub; + const capability = input.mergeCapability; return ( input.githubFeaturesEnabled && input.githubAutoMergeActionsEnabled && @@ -586,13 +588,10 @@ function canEnablePrAutoMerge(input: BuildGitActionsInput): boolean { !input.pullRequestIsDraft && !input.pullRequestIsMerged && input.pullRequestMergeable !== "CONFLICTING" && - hasPullRequestGithubFacts(github) && - github.autoMergeRequest === null && - github.mergeStateStatus === "BLOCKED" && - github.repository.autoMergeAllowed && - github.viewerCanEnableAutoMerge && - !github.isMergeQueueEnabled && - !github.isInMergeQueue && + capability !== null && + !capability.autoMergeEnabled && + capability.canEnableAutoMerge && + !capability.mergeBlockedByQueue && getAllowedAutoMergeEnableActionModels(input).length > 0 ); } @@ -602,8 +601,7 @@ function hasEnabledPrAutoMerge(input: BuildGitActionsInput): boolean { input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl !== null && - hasPullRequestGithubFacts(input.pullRequestGithub) && - input.pullRequestGithub.autoMergeRequest !== null + input.mergeCapability?.autoMergeEnabled === true ); } @@ -615,7 +613,7 @@ function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefi return i18n.t("workspace.git.actions.unavailable.pullDirty"); } if (input.behindOfOrigin === null) { - return "Pull isn't available here because this branch is not connected to a remote yet"; + return i18n.t("workspace.git.actions.unavailable.pullNoRemote"); } if (input.behindOfOrigin === 0) { return i18n.t("workspace.git.actions.unavailable.pullUpToDate"); @@ -644,23 +642,26 @@ function getPullAndPushUnavailableMessage(input: BuildGitActionsInput): string | return i18n.t("workspace.git.actions.unavailable.pullAndPushDirty"); } if (input.behindOfOrigin === null) { - return "Pull and push isn't available because there are no incoming changes to pull first"; + return i18n.t("workspace.git.actions.unavailable.pullAndPushNoIncoming"); } if (input.behindOfOrigin === 0 && input.aheadOfOrigin === 0) { return i18n.t("workspace.git.actions.unavailable.pullAndPushInSync"); } if (input.behindOfOrigin === 0) { - return "Pull and push isn't available because there are no incoming changes to pull first"; + return i18n.t("workspace.git.actions.unavailable.pullAndPushNoIncoming"); } if ((input.aheadOfOrigin ?? 0) === 0) { - return "Pull and push isn't available because there is nothing new to send after pulling"; + return i18n.t("workspace.git.actions.unavailable.pullAndPushNothingToPush"); } return undefined; } function getCreatePrUnavailableMessage(input: BuildGitActionsInput): string | undefined { if (!input.githubFeaturesEnabled) { - return i18n.t("workspace.git.actions.unavailable.createPrNoGithub"); + return i18n.t("workspace.git.actions.unavailable.createPrNoForge", { + brand: input.forgeBrandLabel, + noun: input.forgeChangeRequestNoun, + }); } if (input.aheadCount === 0) { return i18n.t("workspace.git.actions.unavailable.createPrNoCommits"); @@ -698,7 +699,10 @@ function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string function getMergePrUnavailableMessage(input: BuildGitActionsInput): string | undefined { if (!input.githubFeaturesEnabled) { - return i18n.t("workspace.git.actions.unavailable.mergePrNoGithub"); + return i18n.t("workspace.git.actions.unavailable.mergePrNoForge", { + brand: input.forgeBrandLabel, + noun: input.forgeChangeRequestNoun, + }); } if (!input.hasPullRequest) { return i18n.t("workspace.git.actions.unavailable.mergePrMissing"); @@ -715,14 +719,17 @@ function getMergePrUnavailableMessage(input: BuildGitActionsInput): string | und if (input.pullRequestMergeable === "CONFLICTING") { return i18n.t("workspace.git.actions.unavailable.mergePrConflicts"); } - if (!hasPullRequestGithubFacts(input.pullRequestGithub)) { + if (input.mergeCapability === null) { return undefined; } - if (input.pullRequestGithub?.isMergeQueueEnabled || input.pullRequestGithub?.isInMergeQueue) { + if (input.mergeCapability.mergeBlockedByQueue) { return i18n.t("workspace.git.actions.unavailable.mergePrQueue"); } - if (!GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(input.pullRequestGithub?.mergeStateStatus ?? "")) { - return i18n.t("workspace.git.actions.unavailable.mergePrNotReady"); + if (!input.mergeCapability.directMergeReady) { + return i18n.t("workspace.git.actions.unavailable.mergePrNotReady", { + brand: input.forgeBrandLabel, + noun: input.forgeChangeRequestNoun, + }); } return undefined; } @@ -739,11 +746,7 @@ function shouldShowPullRequestAction( return true; } if (id === "disable-pr-auto-merge") { - return ( - input.githubAutoMergeActionsEnabled && - hasPullRequestGithubFacts(input.pullRequestGithub) && - input.pullRequestGithub.autoMergeRequest !== null - ); + return input.githubAutoMergeActionsEnabled && input.mergeCapability?.autoMergeEnabled === true; } if (isDirectPullRequestMergeActionId(id)) { return canMergePr(input) && getAllowedDirectPullRequestMergeActionIds(input).includes(id); @@ -798,9 +801,7 @@ function getPreferredDirectPullRequestMergeActionModel( input: BuildGitActionsInput, ): PullRequestDirectMergeActionModel | null { const allowed = getAllowedDirectPullRequestMergeActionModels(input); - const preferred = normalizeGithubMergeMethod( - input.pullRequestGithub?.repository.viewerDefaultMergeMethod ?? null, - ); + const preferred = input.mergeCapability?.preferredMethod ?? null; return allowed.find((model) => model.method === preferred) ?? allowed[0] ?? null; } @@ -808,9 +809,7 @@ function getPreferredEnablePullRequestAutoMergeActionModel( input: BuildGitActionsInput, ): PullRequestAutoMergeEnableActionModel | null { const allowed = getAllowedAutoMergeEnableActionModels(input); - const preferred = normalizeGithubMergeMethod( - input.pullRequestGithub?.repository.viewerDefaultMergeMethod ?? null, - ); + const preferred = input.mergeCapability?.preferredMethod ?? null; return allowed.find((model) => model.method === preferred) ?? allowed[0] ?? null; } @@ -818,28 +817,9 @@ function isPullRequestMergeMethodAllowed( input: BuildGitActionsInput, method: CheckoutPrMergeMethod, ): boolean { - const repository = input.pullRequestGithub?.repository; - if (!repository) { + const capability = input.mergeCapability; + if (capability === null) { return true; } - if (method === "squash") { - return repository.squashMergeAllowed; - } - if (method === "merge") { - return repository.mergeCommitAllowed; - } - return repository.rebaseMergeAllowed; -} - -function hasPullRequestGithubFacts( - github: PullRequestGithubStatus | null, -): github is NonNullable { - return github !== null && github !== undefined; -} - -function normalizeGithubMergeMethod(value: string | null): CheckoutPrMergeMethod | null { - if (value === "SQUASH") return "squash"; - if (value === "MERGE") return "merge"; - if (value === "REBASE") return "rebase"; - return null; + return capability.allowedMethods.includes(method); } diff --git a/packages/app/src/git/pr-hint.test.ts b/packages/app/src/git/pr-hint.test.ts new file mode 100644 index 000000000..96e4a0e97 --- /dev/null +++ b/packages/app/src/git/pr-hint.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { selectPrHintFromStatus } from "./pr-hint"; + +const githubStatus = { + url: "https://github.com/acme/repo/pull/42", + state: "open", + isMerged: false, +}; + +const gitlabStatus = { + url: "https://gitlab.com/group/proj/-/merge_requests/7", + state: "open", + isMerged: false, +}; + +describe("selectPrHintFromStatus", () => { + it("defaults the forge to github when none is supplied (old daemon)", () => { + const hint = selectPrHintFromStatus(githubStatus); + expect(hint).toMatchObject({ number: 42, forge: "github" }); + }); + + it("carries the resolved forge onto the hint", () => { + const hint = selectPrHintFromStatus(githubStatus, "github"); + expect(hint?.forge).toBe("github"); + }); + + it("parses a GitLab merge-request URL and carries the gitlab forge", () => { + const hint = selectPrHintFromStatus(gitlabStatus, "gitlab"); + expect(hint).toMatchObject({ number: 7, forge: "gitlab" }); + }); + + it("passes an unknown forge id through untouched", () => { + const hint = selectPrHintFromStatus(githubStatus, "bitbucket"); + expect(hint?.forge).toBe("bitbucket"); + }); + + it("returns null when the url has no parseable change-request number", () => { + expect( + selectPrHintFromStatus({ url: "https://example.com/x", state: "open", isMerged: false }), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/git/pr-hint.ts b/packages/app/src/git/pr-hint.ts index 8ffd9e658..9fbd1949a 100644 --- a/packages/app/src/git/pr-hint.ts +++ b/packages/app/src/git/pr-hint.ts @@ -1,7 +1,11 @@ +import { normalizeForge, type Forge } from "@/git/forge"; + export interface PrHint { url: string; number: number; state: "open" | "merged" | "closed"; + /** Forge backing this change request, so badges render the right brand mark. */ + forge: Forge; checks?: Array<{ name: string; status: string; url: string | null }>; checksStatus?: "none" | "pending" | "success" | "failure"; reviewDecision?: "approved" | "changes_requested" | "pending" | null; @@ -14,12 +18,15 @@ interface PrStatusLike { checks?: Array<{ name: string; status: string; url: string | null }>; checksStatus?: string; reviewDecision?: string | null; + forge?: string; } function parsePullRequestNumber(url: string): number | null { try { const pathname = new URL(url).pathname; - const match = pathname.match(/\/pull\/(\d+)(?:\/|$)/); + // GitHub uses /pull/N, Gitea/Forgejo /pulls/N, GitLab /-/merge_requests/N. + // Match any so a non-GitHub change-request summary yields a hint (and brand mark). + const match = pathname.match(/\/(?:pull|pulls|merge_requests)\/(\d+)(?:\/|$)/); if (!match) { return null; } @@ -31,7 +38,10 @@ function parsePullRequestNumber(url: string): number | null { } } -export function selectPrHintFromStatus(status: PrStatusLike | null | undefined): PrHint | null { +export function selectPrHintFromStatus( + status: PrStatusLike | null | undefined, + forge?: string | null, +): PrHint | null { if (!status?.url) { return null; } @@ -50,6 +60,7 @@ export function selectPrHintFromStatus(status: PrStatusLike | null | undefined): url: status.url, number, state, + forge: normalizeForge(forge ?? status.forge), checks: status.checks, checksStatus: status.checksStatus as PrHint["checksStatus"], reviewDecision: status.reviewDecision as PrHint["reviewDecision"], diff --git a/packages/app/src/git/pr-status.test.ts b/packages/app/src/git/pr-status.test.ts new file mode 100644 index 000000000..88060e0cd --- /dev/null +++ b/packages/app/src/git/pr-status.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages"; +import { normalizeCheckoutPrStatusPayload } from "./pr-status"; + +function payload( + overrides: Partial = {}, +): CheckoutPrStatusResponse["payload"] { + return { + cwd: "/repo", + status: null, + githubFeaturesEnabled: true, + forge: "github", + error: null, + requestId: "pr-status-1", + ...overrides, + }; +} + +describe("normalizeCheckoutPrStatusPayload", () => { + it("preserves known auth states", () => { + expect(normalizeCheckoutPrStatusPayload(payload({ authState: "cli_missing" })).authState).toBe( + "cli_missing", + ); + }); + + it("derives auth from the legacy feature flag when authState is absent", () => { + expect(normalizeCheckoutPrStatusPayload(payload()).authState).toBe("authenticated"); + expect( + normalizeCheckoutPrStatusPayload(payload({ githubFeaturesEnabled: false })).authState, + ).toBe("unauthenticated"); + }); + + it("does not expose an unknown wire auth state to feature code", () => { + expect( + normalizeCheckoutPrStatusPayload( + payload({ authState: "future_auth_state", githubFeaturesEnabled: false }), + ).authState, + ).toBe("unauthenticated"); + }); +}); diff --git a/packages/app/src/git/pr-status.ts b/packages/app/src/git/pr-status.ts new file mode 100644 index 000000000..e296d5820 --- /dev/null +++ b/packages/app/src/git/pr-status.ts @@ -0,0 +1,21 @@ +import type { CheckoutPrStatusResponse, ForgeAuthState } from "@getpaseo/protocol/messages"; +import { parseForgeAuthState } from "@/git/forge"; + +type WireCheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; + +export type CheckoutPrStatusPayload = Omit & { + authState: ForgeAuthState; +}; + +export function normalizeCheckoutPrStatusPayload( + payload: WireCheckoutPrStatusPayload, +): CheckoutPrStatusPayload { + return { + ...payload, + // COMPAT(forgeAuthState): added in v0.1.106, remove after 2026-12-27 once + // all supported daemons send authState. + authState: + parseForgeAuthState(payload.authState) ?? + (payload.githubFeaturesEnabled ? "authenticated" : "unauthenticated"), + }; +} diff --git a/packages/app/src/git/pull-request-panel/activity-state.test.ts b/packages/app/src/git/pull-request-panel/activity-state.test.ts index ac79680f5..d8e216958 100644 --- a/packages/app/src/git/pull-request-panel/activity-state.test.ts +++ b/packages/app/src/git/pull-request-panel/activity-state.test.ts @@ -41,6 +41,7 @@ function threadEntry(id: string, resolved = false, outdated = false): PrTimeline kind: "thread", id: `thread:${id}`, location: { path: "a.ts", line: 1, isResolved: resolved, isOutdated: outdated }, + isResolved: resolved, comments: [activity(id)], }; } @@ -90,6 +91,18 @@ describe("pull request activity state", () => { ]); }); + it("collapses a resolved general thread that carries no location", () => { + const entries: PrTimelineEntry[] = [ + { kind: "thread", id: "thread:general", isResolved: true, comments: [activity("g1")] }, + ]; + + const visible = getVisibleEntries(getActivityState(), { prNumber: 42, entries }); + + expect(visible.map((v) => ({ id: v.entry.id, collapsed: v.collapsed }))).toEqual([ + { id: "thread:general", collapsed: true }, + ]); + }); + it("lets user expand a default-collapsed entry", () => { const entries = [threadEntry("resolved", true)]; const expanded = expandActivity(getActivityState(), { diff --git a/packages/app/src/git/pull-request-panel/activity-state.ts b/packages/app/src/git/pull-request-panel/activity-state.ts index edb2c5c4c..98f937418 100644 --- a/packages/app/src/git/pull-request-panel/activity-state.ts +++ b/packages/app/src/git/pull-request-panel/activity-state.ts @@ -25,7 +25,7 @@ export function getActivityStateKey(identity: PullRequestActivityIdentity): stri function shouldCollapseByDefault(entry: PrTimelineEntry): boolean { if (entry.kind === "thread") { - return entry.location.isResolved === true || entry.location.isOutdated === true; + return entry.isResolved === true || entry.location?.isOutdated === true; } if (entry.kind === "single") { return ( diff --git a/packages/app/src/git/pull-request-panel/check-status.ts b/packages/app/src/git/pull-request-panel/check-status.ts new file mode 100644 index 000000000..055d1693c --- /dev/null +++ b/packages/app/src/git/pull-request-panel/check-status.ts @@ -0,0 +1,22 @@ +/** + * Neutral check status mapping shared by the PR-pane data builder, the forge + * native-data contributions, and the pane render. Kept forge-agnostic: a forge + * maps its raw CI strings onto this frozen union. Forge-specific vocabularies + * (e.g. GitLab pipeline statuses) live in the owning forge module. + */ +export type CheckStatus = "success" | "failure" | "pending" | "skipped"; + +export function mapCheckStatus(status: string): CheckStatus { + if ( + status === "success" || + status === "failure" || + status === "pending" || + status === "skipped" + ) { + return status; + } + if (status === "cancelled") { + return "skipped"; + } + return "pending"; +} diff --git a/packages/app/src/git/pull-request-panel/context-attachment.test.ts b/packages/app/src/git/pull-request-panel/context-attachment.test.ts index 941f280c5..ed8366e86 100644 --- a/packages/app/src/git/pull-request-panel/context-attachment.test.ts +++ b/packages/app/src/git/pull-request-panel/context-attachment.test.ts @@ -11,14 +11,15 @@ import { import type { PrPaneActivity, PrPaneCheck } from "./data"; import type { PrThreadEntry } from "./timeline"; -const baseInput: Omit = { +const baseInput = { provider: { id: "github", label: "GitHub" }, + forge: "github", pullRequest: { number: 42, title: "Fix flaky build", url: "https://github.com/getpaseo/paseo/pull/42", }, -}; +} satisfies Omit; function comment(overrides: Partial = {}): PrPaneActivity { return { @@ -55,7 +56,7 @@ function check(overrides: Partial = {}): PrPaneCheck { provider: "github", status: "failure", url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789", - github: { checkRunId: 12345, workflowRunId: 456 }, + detailRef: { checkRunId: 12345, workflowRunId: 456 }, ...overrides, }; } @@ -76,7 +77,7 @@ describe("pull request context attachments", () => { }), }), ).toEqual({ - kind: "github.pull_request_comment", + kind: "forge.change_request_comment", id: "42:comment-1", title: "octocat", subtitle: "#42 Fix flaky build", @@ -97,7 +98,7 @@ describe("pull request context attachments", () => { it("formats a review with state as a workspace context attachment", () => { expect(buildPullRequestReviewContextAttachment({ ...baseInput, activity: review() })).toEqual({ - kind: "github.pull_request_review", + kind: "forge.change_request_review", id: "42:review-1", title: "reviewer", subtitle: "#42 Fix flaky build", @@ -116,6 +117,41 @@ describe("pull request context attachments", () => { }); }); + it("labels a GitLab comment as a merge request with the ! prefix", () => { + expect( + buildPullRequestCommentContextAttachment({ + ...baseInput, + provider: { id: "gitlab", label: "GitLab" }, + forge: "gitlab", + pullRequest: { + number: 14, + title: "Wire up the timeline", + url: "https://gitlab.com/acme/app/-/merge_requests/14", + }, + activity: comment({ + provider: "gitlab", + url: "https://gitlab.com/acme/app/-/merge_requests/14#note_401", + }), + }), + ).toEqual({ + kind: "forge.change_request_comment", + id: "14:comment-1", + title: "octocat", + subtitle: "!14 Wire up the timeline", + url: "https://gitlab.com/acme/app/-/merge_requests/14#note_401", + text: [ + "GitLab merge request comment", + "Merge request: !14 Wire up the timeline", + "Merge request URL: https://gitlab.com/acme/app/-/merge_requests/14", + "URL: https://gitlab.com/acme/app/-/merge_requests/14#note_401", + "Author: octocat", + "Created: 3d ago", + "", + "Looks good.", + ].join("\n"), + }); + }); + it("does not add bodyless approvals to chat", () => { const activity = review({ reviewState: "approved", body: "" }); @@ -185,7 +221,7 @@ describe("pull request context attachments", () => { }, }), ).toEqual({ - kind: "github.pull_request_check", + kind: "forge.change_request_check", id: "42:check-run:12345", title: "server-tests", subtitle: "#42 Fix flaky build", @@ -222,12 +258,12 @@ describe("pull request context attachments", () => { it("keeps same-named GitHub checks distinct by check run", () => { const ubuntu = buildPullRequestCheckContextAttachment({ ...baseInput, - check: check({ github: { checkRunId: 12345, workflowRunId: 456 } }), + check: check({ detailRef: { checkRunId: 12345, workflowRunId: 456 } }), githubDetails: null, }); const windows = buildPullRequestCheckContextAttachment({ ...baseInput, - check: check({ github: { checkRunId: 67890, workflowRunId: 456 } }), + check: check({ detailRef: { checkRunId: 67890, workflowRunId: 456 } }), githubDetails: null, }); @@ -242,12 +278,12 @@ describe("pull request context attachments", () => { check: check({ name: "status/context", url: "https://github.com/getpaseo/paseo/status/context", - github: undefined, + detailRef: undefined, }), githubDetails: null, }), ).toEqual({ - kind: "github.pull_request_check", + kind: "forge.change_request_check", id: "42:check:status/context", title: "status/context", subtitle: "#42 Fix flaky build", @@ -262,6 +298,42 @@ describe("pull request context attachments", () => { ].join("\n"), }); }); + + it("formats GitLab failed check context as a merge request", () => { + expect( + buildPullRequestCheckContextAttachment({ + ...baseInput, + provider: { id: "gitlab", label: "GitLab" }, + forge: "gitlab", + pullRequest: { + number: 14, + title: "Wire up pipelines", + url: "https://gitlab.com/acme/app/-/merge_requests/14", + }, + check: check({ + name: "pipeline", + provider: "gitlab", + url: "https://gitlab.com/acme/app/-/pipelines/99", + detailRef: undefined, + }), + githubDetails: null, + }), + ).toEqual({ + kind: "forge.change_request_check", + id: "14:check:pipeline", + title: "pipeline", + subtitle: "!14 Wire up pipelines", + url: "https://gitlab.com/acme/app/-/pipelines/99", + text: [ + "GitLab merge request check", + "Merge request: !14 Wire up pipelines", + "Merge request URL: https://gitlab.com/acme/app/-/merge_requests/14", + "Check: pipeline", + "Status: failure", + "Check URL: https://gitlab.com/acme/app/-/pipelines/99", + ].join("\n"), + }); + }); }); describe("buildPullRequestThreadContextAttachment", () => { @@ -284,7 +356,7 @@ describe("buildPullRequestThreadContextAttachment", () => { it("bundles the whole thread conversation into one attachment", () => { expect(buildPullRequestThreadContextAttachment({ ...baseInput, thread: thread() })).toEqual({ - kind: "github.pull_request_comment", + kind: "forge.change_request_comment", id: "42:thread:PRRT_1", title: "src/a.ts:12", subtitle: "#42 Fix flaky build", diff --git a/packages/app/src/git/pull-request-panel/context-attachment.ts b/packages/app/src/git/pull-request-panel/context-attachment.ts index 6f83b8d5e..9b825f713 100644 --- a/packages/app/src/git/pull-request-panel/context-attachment.ts +++ b/packages/app/src/git/pull-request-panel/context-attachment.ts @@ -1,5 +1,6 @@ -import type { CheckoutGithubCheckDetails } from "@getpaseo/protocol/messages"; +import type { CheckoutCheckDetails } from "@getpaseo/protocol/messages"; import type { PullRequestContextAttachment } from "@/attachments/types"; +import { type Forge, getForgePresentation } from "@/git/forge"; import { formatPullRequestActivityLocation, formatPullRequestThreadPath, @@ -15,21 +16,24 @@ export interface PullRequestContextMetadata { export interface PullRequestContextBuilderInput { provider: PullRequestProviderMetadata; + forge: Forge; pullRequest: PullRequestContextMetadata; activity: PrPaneActivity; } export interface PullRequestThreadContextBuilderInput { provider: PullRequestProviderMetadata; + forge: Forge; pullRequest: PullRequestContextMetadata; thread: PrThreadEntry; } export interface PullRequestGithubCheckContextBuilderInput { - provider: PullRequestProviderMetadata & { id: "github" }; + provider: PullRequestProviderMetadata; + forge: Forge; pullRequest: PullRequestContextMetadata; - check: PrPaneCheck & { provider: "github" }; - githubDetails?: CheckoutGithubCheckDetails | null; + check: PrPaneCheck; + githubDetails?: CheckoutCheckDetails | null; } export function canAddPullRequestActivityToChat(activity: PrPaneActivity): boolean { @@ -46,14 +50,15 @@ export function canAddPullRequestCheckLogsToChat(check: PrPaneCheck): boolean { export function buildPullRequestCommentContextAttachment( input: PullRequestContextBuilderInput, ): PullRequestContextAttachment { + const presentation = getForgePresentation(input.forge); return { - kind: "github.pull_request_comment", + kind: "forge.change_request_comment", id: `${input.pullRequest.number}:${input.activity.id}`, title: input.activity.author, - subtitle: formatPullRequestSubtitle(input.pullRequest), + subtitle: formatPullRequestSubtitle(input.pullRequest, input.forge), text: formatActivityContextText({ ...input, - heading: `${input.provider.label} pull request comment`, + heading: `${presentation.brandLabel} ${presentation.changeRequestNoun} comment`, }), url: input.activity.url, }; @@ -66,14 +71,15 @@ export function buildPullRequestReviewContextAttachment( return null; } + const presentation = getForgePresentation(input.forge); return { - kind: "github.pull_request_review", + kind: "forge.change_request_review", id: `${input.pullRequest.number}:${input.activity.id}`, title: input.activity.author, - subtitle: formatPullRequestSubtitle(input.pullRequest), + subtitle: formatPullRequestSubtitle(input.pullRequest, input.forge), text: formatActivityContextText({ ...input, - heading: `${input.provider.label} pull request review`, + heading: `${presentation.brandLabel} ${presentation.changeRequestNoun} review`, reviewState: input.activity.reviewState, }), url: input.activity.url, @@ -93,17 +99,23 @@ export function buildPullRequestThreadContextAttachment( return null; } + const presentation = getForgePresentation(input.forge); + const noun = capitalizeFirst(presentation.changeRequestNoun); + const location = input.thread.location; + const threadTitle = location ? formatPullRequestThreadPath(location) : "Discussion thread"; const lines = [ - `${input.provider.label} pull request review thread`, - `Pull request: #${input.pullRequest.number} ${input.pullRequest.title}`, - `Pull request URL: ${input.pullRequest.url}`, + `${presentation.brandLabel} ${presentation.changeRequestNoun} review thread`, + `${noun}: ${presentation.numberPrefix}${input.pullRequest.number} ${input.pullRequest.title}`, + `${noun} URL: ${input.pullRequest.url}`, `URL: ${root.url}`, - `Location: ${formatPullRequestThreadPath(input.thread.location)}`, ]; - if (input.thread.location.isResolved !== undefined) { - lines.push(`Thread state: ${input.thread.location.isResolved ? "resolved" : "unresolved"}`); + if (location) { + lines.push(`Location: ${formatPullRequestThreadPath(location)}`); } - if (input.thread.location.isOutdated) { + if (location?.isResolved !== undefined) { + lines.push(`Thread state: ${location.isResolved ? "resolved" : "unresolved"}`); + } + if (location?.isOutdated) { lines.push("Note: this thread is outdated (the code it refers to has changed)"); } @@ -112,10 +124,10 @@ export function buildPullRequestThreadContextAttachment( ); return { - kind: "github.pull_request_comment", + kind: "forge.change_request_comment", id: `${input.pullRequest.number}:${input.thread.id}`, - title: formatPullRequestThreadPath(input.thread.location), - subtitle: formatPullRequestSubtitle(input.pullRequest), + title: threadTitle, + subtitle: formatPullRequestSubtitle(input.pullRequest, input.forge), text: [...lines, "", conversation.join("\n\n---\n\n")].join("\n"), url: root.url, }; @@ -125,11 +137,11 @@ export function buildPullRequestCheckContextAttachment( input: PullRequestGithubCheckContextBuilderInput, ): PullRequestContextAttachment { return { - kind: "github.pull_request_check", + kind: "forge.change_request_check", id: formatPullRequestCheckContextId(input.pullRequest, input.check), title: input.check.name, - subtitle: formatPullRequestSubtitle(input.pullRequest), - text: formatGitHubCheckContextText(input), + subtitle: formatPullRequestSubtitle(input.pullRequest, input.forge), + text: formatCheckContextText(input), url: input.githubDetails?.detailsUrl ?? input.githubDetails?.url ?? input.check.url, }; } @@ -138,22 +150,25 @@ function formatPullRequestCheckContextId( pullRequest: PullRequestContextMetadata, check: PrPaneCheck, ): string { - if (check.github?.checkRunId !== undefined) { - return `${pullRequest.number}:check-run:${check.github.checkRunId}`; + if (check.detailRef?.checkRunId !== undefined) { + return `${pullRequest.number}:check-run:${check.detailRef.checkRunId}`; } return `${pullRequest.number}:check:${check.name}`; } -function formatGitHubCheckContextText({ +function formatCheckContextText({ provider, + forge, pullRequest, check, githubDetails, }: PullRequestGithubCheckContextBuilderInput): string { + const presentation = getForgePresentation(forge); + const noun = capitalizeFirst(presentation.changeRequestNoun); const lines = [ - `${provider.label} pull request check`, - `Pull request: #${pullRequest.number} ${pullRequest.title}`, - `Pull request URL: ${pullRequest.url}`, + `${provider.label} ${presentation.changeRequestNoun} check`, + `${noun}: ${presentation.numberPrefix}${pullRequest.number} ${pullRequest.title}`, + `${noun} URL: ${pullRequest.url}`, `Check: ${check.name}`, `Status: ${check.status}`, ]; @@ -170,7 +185,7 @@ function formatGitHubCheckContextText({ appendGitHubCheckAnnotations(lines, githubDetails); appendGitHubFailedJobs(lines, githubDetails); if (githubDetails?.truncated) { - lines.push("", "Note: Check details were truncated by GitHub/API or local caps."); + lines.push("", `Note: Check details were truncated by ${provider.label}/API or local caps.`); } return lines.join("\n"); @@ -178,7 +193,7 @@ function formatGitHubCheckContextText({ function appendGitHubCheckOutput( lines: string[], - details: CheckoutGithubCheckDetails | null | undefined, + details: CheckoutCheckDetails | null | undefined, ) { if (details?.output?.title) { lines.push(`Output title: ${details.output.title}`); @@ -193,7 +208,7 @@ function appendGitHubCheckOutput( function appendGitHubCheckAnnotations( lines: string[], - details: CheckoutGithubCheckDetails | null | undefined, + details: CheckoutCheckDetails | null | undefined, ) { if (!details?.annotations?.length) { return; @@ -204,10 +219,7 @@ function appendGitHubCheckAnnotations( } } -function appendGitHubFailedJobs( - lines: string[], - details: CheckoutGithubCheckDetails | null | undefined, -) { +function appendGitHubFailedJobs(lines: string[], details: CheckoutCheckDetails | null | undefined) { if (!details?.failedJobs?.length) { return; } @@ -226,7 +238,7 @@ function appendGitHubFailedJobs( } } -function formatAnnotation(annotation: CheckoutGithubCheckDetails["annotations"][number]): string { +function formatAnnotation(annotation: CheckoutCheckDetails["annotations"][number]): string { const location = annotation.path ? `${annotation.path}${formatAnnotationLines(annotation)}` : "unknown location"; @@ -235,9 +247,7 @@ function formatAnnotation(annotation: CheckoutGithubCheckDetails["annotations"][ return `${location}${level}${message}`; } -function formatAnnotationLines( - annotation: CheckoutGithubCheckDetails["annotations"][number], -): string { +function formatAnnotationLines(annotation: CheckoutCheckDetails["annotations"][number]): string { if (annotation.startLine !== undefined && annotation.endLine !== undefined) { return `:${annotation.startLine}-${annotation.endLine}`; } @@ -249,14 +259,17 @@ function formatAnnotationLines( function formatActivityContextText({ heading, + forge, pullRequest, activity, reviewState, }: PullRequestContextBuilderInput & { heading: string; reviewState?: ReviewState }): string { + const presentation = getForgePresentation(forge); + const noun = capitalizeFirst(presentation.changeRequestNoun); const lines = [ heading, - `Pull request: #${pullRequest.number} ${pullRequest.title}`, - `Pull request URL: ${pullRequest.url}`, + `${noun}: ${presentation.numberPrefix}${pullRequest.number} ${pullRequest.title}`, + `${noun} URL: ${pullRequest.url}`, `URL: ${activity.url}`, `Author: ${activity.author}`, ]; @@ -279,6 +292,10 @@ function formatActivityContextText({ return [...lines, "", body].join("\n"); } -function formatPullRequestSubtitle(pullRequest: PullRequestContextMetadata): string { - return `#${pullRequest.number} ${pullRequest.title}`; +function formatPullRequestSubtitle(pullRequest: PullRequestContextMetadata, forge: Forge): string { + return `${getForgePresentation(forge).numberPrefix}${pullRequest.number} ${pullRequest.title}`; +} + +function capitalizeFirst(value: string): string { + return value.length === 0 ? value : `${value[0].toUpperCase()}${value.slice(1)}`; } diff --git a/packages/app/src/git/pull-request-panel/data.test.ts b/packages/app/src/git/pull-request-panel/data.test.ts index fd30ef065..e299c37f5 100644 --- a/packages/app/src/git/pull-request-panel/data.test.ts +++ b/packages/app/src/git/pull-request-panel/data.test.ts @@ -3,6 +3,7 @@ import type { CheckoutPrStatusResponse, PullRequestTimelineResponse, } from "@getpaseo/protocol/messages"; +import { isPipelineActiveStatus, mapPipelineStatus } from "@/git/forges/gitlab"; import { deriveAvatarColor, formatAge, @@ -33,6 +34,7 @@ const githubStatus: CheckoutPrStatus["github"] = { }; const baseStatus: CheckoutPrStatus = { + forge: "github", number: 42, url: "https://github.com/getpaseo/paseo/pull/42", title: "Wire PR pane data", @@ -184,11 +186,30 @@ describe("mapPrPaneData", () => { name: "server-tests", status: "failure", url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789", - github: { checkRunId: 12345, workflowRunId: 456 }, + detailRef: { checkRunId: 12345, workflowRunId: 456 }, }, ]); }); + it("keeps a workflowRunId-only detail ref so Gitea Actions rows stay fetchable", () => { + const data = mapPrPaneData( + status({ + forge: "gitea", + checks: [ + { + name: "e2e", + status: "failure", + url: "https://gitea.com/acme/repo/actions/runs/7001", + workflowRunId: 7001, + }, + ], + }), + baseTimeline, + ); + + expect(data?.checks[0]?.detailRef).toEqual({ workflowRunId: 7001 }); + }); + it("preserves timeline item order while mapping mixed reviews and comments", () => { const data = mapPrPaneData( baseStatus, @@ -272,6 +293,56 @@ describe("mapPrPaneData", () => { ]); }); + it("maps a top-level threadId onto general (non-file) discussion comments", () => { + const data = mapPrPaneData( + baseStatus, + timeline({ + items: [ + { + id: "note-1", + kind: "comment", + author: "reviewer-a", + body: "Can you clarify the rollout?", + createdAt: Date.UTC(2026, 0, 1, 11, 0, 0), + url: "https://gitlab.example.com/group/project/-/merge_requests/42#note_1", + threadId: "disc-1", + }, + ], + }), + Date.UTC(2026, 0, 1, 12, 0, 0), + "gitlab", + ); + + expect(data?.activity).toHaveLength(1); + expect(data?.activity[0]).toMatchObject({ id: "note-1", threadId: "disc-1" }); + expect(data?.activity[0].location).toBeUndefined(); + }); + + it("maps thread-level resolution onto general (non-file) discussion comments", () => { + const data = mapPrPaneData( + baseStatus, + timeline({ + items: [ + { + id: "note-2", + kind: "comment", + author: "reviewer-a", + body: "Resolved general discussion.", + createdAt: Date.UTC(2026, 0, 1, 11, 0, 0), + url: "https://gitlab.example.com/group/project/-/merge_requests/42#note_2", + threadId: "disc-2", + threadIsResolved: true, + }, + ], + }), + Date.UTC(2026, 0, 1, 12, 0, 0), + "gitlab", + ); + + expect(data?.activity[0]).toMatchObject({ id: "note-2", threadIsResolved: true }); + expect(data?.activity[0].location).toBeUndefined(); + }); + it("filters empty commented reviews but keeps blocking review states", () => { const data = mapPrPaneData( baseStatus, @@ -388,6 +459,23 @@ describe("mapPrPaneData", () => { expect(mapPrPaneData(baseStatus, baseTimeline)?.awaitingReviewers).toEqual([]); }); + it("defaults the forge to github and omits the project path when neither is supplied", () => { + const data = mapPrPaneData(baseStatus, baseTimeline); + expect(data?.forge).toBe("github"); + expect(data?.projectPath).toBeUndefined(); + }); + + it("carries the resolved forge and the nested project path for GitLab", () => { + const data = mapPrPaneData( + status({ projectPath: "group/subgroup/repo" }), + baseTimeline, + undefined, + "gitlab", + ); + expect(data?.forge).toBe("gitlab"); + expect(data?.projectPath).toBe("group/subgroup/repo"); + }); + it("rejects stale timeline activity when the timeline PR number differs from status", () => { const data = mapPrPaneData( baseStatus, @@ -409,6 +497,169 @@ describe("mapPrPaneData", () => { expect(data?.activity).toEqual([]); }); + + it("passes the forge native facts through for pane contributions to derive surfaces", () => { + const gitlabFacts = { + forge: "gitlab" as const, + detailedMergeStatus: "mergeable", + hasConflicts: false, + blockingDiscussionsResolved: true, + approvalsRequired: 2, + approvalsGiven: 1, + pipelineStatus: "running", + pipelineId: 306, + pipelineUrl: "https://gitlab.com/group/repo/-/pipelines/306", + mergeWhenPipelineSucceeds: false, + }; + const data = mapPrPaneData( + status({ + url: "https://gitlab.com/group/repo/-/merge_requests/7", + github: undefined, + forgeSpecific: gitlabFacts, + }), + baseTimeline, + undefined, + "gitlab", + ); + + expect(data?.forgeSpecific).toEqual({ ...gitlabFacts, mergeStatus: null }); + }); + + it("omits forgeSpecific when the status carries no native facts", () => { + expect(mapPrPaneData(baseStatus, baseTimeline)?.forgeSpecific).toBeUndefined(); + }); + + it("omits forgeSpecific when no registered forge schema accepts it", () => { + const data = mapPrPaneData( + status({ forgeSpecific: { forge: "gitlab", approvalsRequired: "two" } }), + baseTimeline, + ); + + expect(data?.forgeSpecific).toBeUndefined(); + }); + + it("surfaces Gitea aggregate CI status as a check row", () => { + const data = mapPrPaneData( + status({ + forge: "gitea", + url: "https://gitea.com/group/repo/pulls/7", + github: undefined, + forgeSpecific: { + forge: "gitea", + mergeable: true, + hasMerged: false, + ciStatus: "success", + }, + }), + baseTimeline, + undefined, + "gitea", + ); + + expect(data?.checks).toEqual([ + { + provider: "gitea", + name: "CI", + status: "success", + url: "https://gitea.com/group/repo/pulls/7", + }, + ]); + }); + + it("keeps Forgejo branding for aggregate Gitea-family CI status", () => { + const data = mapPrPaneData( + status({ + forge: "forgejo", + url: "https://forgejo.example.com/group/repo/pulls/7", + github: undefined, + forgeSpecific: { + forge: "gitea", + mergeable: true, + hasMerged: false, + ciStatus: "failure", + }, + }), + baseTimeline, + undefined, + "forgejo", + ); + + expect(data?.checks).toEqual([ + { + provider: "forgejo", + name: "CI", + status: "failure", + url: "https://forgejo.example.com/group/repo/pulls/7", + }, + ]); + }); + + it("carries the resolved forge brand and activity provider for GitLab", () => { + const data = mapPrPaneData( + status({ + url: "https://gitlab.com/group/repo/-/merge_requests/7", + github: undefined, + forgeSpecific: { + forge: "gitlab", + detailedMergeStatus: "mergeable", + hasConflicts: false, + blockingDiscussionsResolved: true, + approvalsRequired: 2, + approvalsGiven: 1, + pipelineStatus: null, + pipelineId: null, + pipelineUrl: null, + mergeWhenPipelineSucceeds: false, + }, + }), + timeline({ + items: [ + { + id: "note-1", + kind: "comment", + author: "reviewer", + body: "Looks good", + createdAt: 1000, + url: "https://gitlab.com/group/repo/-/merge_requests/7#note_1", + }, + ], + }), + 2000, + "gitlab", + ); + expect(data?.provider).toEqual({ id: "gitlab", label: "GitLab" }); + expect(data?.activity[0]?.provider).toBe("gitlab"); + }); +}); + +describe("mapPipelineStatus", () => { + it("maps GitLab and neutral pipeline statuses onto check statuses", () => { + expect(mapPipelineStatus("success")).toBe("success"); + expect(mapPipelineStatus("passed")).toBe("success"); + expect(mapPipelineStatus("failed")).toBe("failure"); + expect(mapPipelineStatus("canceled")).toBe("skipped"); + expect(mapPipelineStatus("skipped")).toBe("skipped"); + expect(mapPipelineStatus("manual")).toBe("pending"); + expect(mapPipelineStatus("running")).toBe("pending"); + expect(mapPipelineStatus("pending")).toBe("pending"); + expect(mapPipelineStatus("created")).toBe("pending"); + expect(mapPipelineStatus("waiting_for_resource")).toBe("pending"); + expect(mapPipelineStatus("preparing")).toBe("pending"); + expect(mapPipelineStatus("scheduled")).toBe("pending"); + expect(mapPipelineStatus("anything-else")).toBe("pending"); + }); + + it("marks running and queued pipeline statuses as live for polling", () => { + expect(isPipelineActiveStatus("running")).toBe(true); + expect(isPipelineActiveStatus("pending")).toBe(true); + expect(isPipelineActiveStatus("created")).toBe(true); + expect(isPipelineActiveStatus("waiting_for_resource")).toBe(true); + expect(isPipelineActiveStatus("preparing")).toBe(true); + expect(isPipelineActiveStatus("scheduled")).toBe(true); + expect(isPipelineActiveStatus("success")).toBe(false); + expect(isPipelineActiveStatus("failed")).toBe(false); + expect(isPipelineActiveStatus("canceled")).toBe(false); + }); }); describe("deriveAvatarColor", () => { diff --git a/packages/app/src/git/pull-request-panel/data.ts b/packages/app/src/git/pull-request-panel/data.ts index c73734e3b..3e11f8458 100644 --- a/packages/app/src/git/pull-request-panel/data.ts +++ b/packages/app/src/git/pull-request-panel/data.ts @@ -2,12 +2,18 @@ import type { CheckoutPrStatusResponse, PullRequestTimelineResponse, } from "@getpaseo/protocol/messages"; +import { type Forge, getForgePresentation } from "@/git/forge"; +import { parseClientForgeFacts } from "@/git/forges"; +import type { ForgeSpecificStatusFacts } from "@/git/merge-capability"; +import { type CheckStatus, mapCheckStatus } from "./check-status"; +import { getNativeFallbackChecks } from "./native-data"; + +export type { CheckStatus } from "./check-status"; export type PrState = "open" | "draft" | "merged" | "closed"; -export type CheckStatus = "success" | "failure" | "pending" | "skipped"; export type ReviewState = "approved" | "changes_requested" | "commented"; export type ActivityKind = "review" | "comment"; -export type PullRequestProvider = "github"; +export type PullRequestProvider = Forge; export interface PullRequestProviderMetadata { id: PullRequestProvider; @@ -15,8 +21,6 @@ export interface PullRequestProviderMetadata { url?: string | null; } -const GITHUB_PROVIDER: PullRequestProviderMetadata = { id: "github", label: "GitHub" }; - export interface PrPaneCheck { provider: PullRequestProvider; name: string; @@ -24,7 +28,12 @@ export interface PrPaneCheck { status: CheckStatus; duration?: string; url: string; - github?: { + /** + * Forge-neutral reference for fetching this check's detail/logs on demand. Any + * forge that exposes a check-run id populates it; the daemon resolves the logs + * through the neutral check-details RPC. + */ + detailRef?: { checkRunId?: number; workflowRunId?: number; }; @@ -44,6 +53,16 @@ export interface PrPaneActivity { url: string; /** For inline review comments: the review this comment was submitted with. */ reviewId?: string; + /** + * Forge-neutral discussion id, independent of a file position. Groups general + * (non-file) reply chains into one thread; file threads also carry it. + */ + threadId?: string; + /** + * Resolution state for a thread with no file position (e.g. a GitLab general + * discussion). File threads carry resolution under `location.isResolved`. + */ + threadIsResolved?: boolean; location?: { path: string; line?: number; @@ -56,15 +75,25 @@ export interface PrPaneActivity { export interface PrPaneData { provider: PullRequestProviderMetadata; + /** The forge hosting this change request. */ + forge: Forge; number: number; repoOwner?: string; repoName?: string; + /** Neutral project identity (GitLab namespaces nest beyond owner/name). */ + projectPath?: string; title: string; state: PrState; url: string; reviewDecision: "approved" | "changes_requested" | "pending"; awaitingReviewers: string[]; checks: PrPaneCheck[]; + /** + * The forge's already-validated native facts, passed through so pane native + * contributions (e.g. GitLab pipeline/approvals) derive their surfaces without + * the neutral data type carrying forge-specific fields. + */ + forgeSpecific?: ForgeSpecificStatusFacts; activity: PrPaneActivity[]; } @@ -87,6 +116,7 @@ export function mapPrPaneData( status: CheckoutPrStatus, timeline: PullRequestTimeline | null | undefined, nowMs = Date.now(), + forge: Forge = "github", ): PrPaneData | null { if (!status) { return null; @@ -98,25 +128,34 @@ export function mapPrPaneData( } const timelineMatchesStatus = timeline?.prNumber === number; + const provider = toProviderMetadata(forge); + const forgeSpecific = parseClientForgeFacts(status.forgeSpecific); return { - provider: GITHUB_PROVIDER, + provider, + forge, number, repoOwner: status.repoOwner, repoName: status.repoName, + projectPath: status.projectPath, title: status.title, state: derivePrState(status), url: status.url, reviewDecision: mapReviewDecision(status.reviewDecision), // Requested reviewers are intentionally unwired until the server exposes them. awaitingReviewers: [], - checks: (status.checks ?? []).flatMap(mapCheck), + checks: mapChecks(status, forge), + ...(forgeSpecific ? { forgeSpecific } : {}), activity: timelineMatchesStatus - ? timeline.items.flatMap((item) => mapActivity(item, nowMs)) + ? timeline.items.flatMap((item) => mapActivity(item, nowMs, forge)) : [], }; } +function toProviderMetadata(forge: Forge): PullRequestProviderMetadata { + return { id: forge, label: getForgePresentation(forge).brandLabel }; +} + export function deriveAvatarColor(login: string): string { return AVATAR_COLORS[hashLogin(login) % AVATAR_COLORS.length]; } @@ -164,14 +203,25 @@ function derivePrState(status: NonNullable): PrState { return "open"; } -function mapCheck(check: NonNullable["checks"][number]): PrPaneCheck[] { +function mapChecks(status: NonNullable, forge: Forge): PrPaneCheck[] { + const checks = (status.checks ?? []).flatMap((check) => mapCheck(check, forge)); + if (checks.length > 0) { + return checks; + } + return getNativeFallbackChecks(status, forge); +} + +function mapCheck( + check: NonNullable["checks"][number], + forge: Forge, +): PrPaneCheck[] { if (check.url === null) { return []; } return [ { - provider: "github", + provider: forge, name: check.name, status: mapCheckStatus(check.status), url: check.url, @@ -179,7 +229,7 @@ function mapCheck(check: NonNullable["checks"][number]): PrPan ...(check.duration ? { duration: check.duration } : {}), ...(check.checkRunId !== undefined || check.workflowRunId !== undefined ? { - github: { + detailRef: { ...(check.checkRunId !== undefined ? { checkRunId: check.checkRunId } : {}), ...(check.workflowRunId !== undefined ? { workflowRunId: check.workflowRunId } : {}), }, @@ -189,22 +239,7 @@ function mapCheck(check: NonNullable["checks"][number]): PrPan ]; } -function mapCheckStatus(status: string): CheckStatus { - if ( - status === "success" || - status === "failure" || - status === "pending" || - status === "skipped" - ) { - return status; - } - if (status === "cancelled") { - return "skipped"; - } - return "pending"; -} - -function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivity[] { +function mapActivity(item: PullRequestTimelineItem, nowMs: number, forge: Forge): PrPaneActivity[] { if (item.kind === "comment") { if (item.body.trim() === "") { return []; @@ -212,7 +247,7 @@ function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivi return [ { id: item.id, - provider: "github", + provider: forge, kind: "comment", author: item.author, authorUrl: item.authorUrl, @@ -222,6 +257,8 @@ function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivi age: formatAge(item.createdAt, nowMs), url: item.url, reviewId: item.reviewId, + threadId: item.threadId, + threadIsResolved: item.threadIsResolved, location: item.location, }, ]; @@ -234,7 +271,7 @@ function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivi return [ { id: item.id, - provider: "github", + provider: forge, kind: "review", author: item.author, authorUrl: item.authorUrl, diff --git a/packages/app/src/git/pull-request-panel/native-data.test.ts b/packages/app/src/git/pull-request-panel/native-data.test.ts new file mode 100644 index 000000000..7c5d2b252 --- /dev/null +++ b/packages/app/src/git/pull-request-panel/native-data.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; +import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages"; +import { + deriveGitlabApprovals, + deriveGitlabPipelineSummary, + type GitlabMergeFacts, +} from "@/git/forges/gitlab"; +import { getNativeFallbackChecks } from "./native-data"; + +type CheckoutPrStatus = NonNullable; + +function gitlabFacts(overrides: Partial = {}): GitlabMergeFacts { + return { + forge: "gitlab", + detailedMergeStatus: "mergeable", + mergeStatus: null, + hasConflicts: false, + blockingDiscussionsResolved: true, + approvalsRequired: 0, + approvalsGiven: 0, + pipelineStatus: null, + pipelineId: null, + pipelineUrl: null, + mergeWhenPipelineSucceeds: false, + ...overrides, + }; +} + +function status(forgeSpecific: CheckoutPrStatus["forgeSpecific"], url: string): CheckoutPrStatus { + return { + forge: "gitea", + number: 7, + url, + title: "Native data", + state: "open", + baseRefName: "main", + headRefName: "feature", + isMerged: false, + isDraft: false, + mergeable: "UNKNOWN", + checks: [], + reviewDecision: null, + ...(forgeSpecific ? { forgeSpecific } : {}), + }; +} + +describe("deriveGitlabPipelineSummary", () => { + it("summarizes a head pipeline, mapping the raw status onto a check status", () => { + expect( + deriveGitlabPipelineSummary( + gitlabFacts({ + pipelineStatus: "running", + pipelineId: 306, + pipelineUrl: "https://gitlab.com/group/repo/-/pipelines/306", + }), + ), + ).toEqual({ + id: 306, + status: "pending", + rawStatus: "running", + url: "https://gitlab.com/group/repo/-/pipelines/306", + }); + }); + + it("returns null when the MR has no head pipeline", () => { + expect(deriveGitlabPipelineSummary(gitlabFacts({ pipelineId: null }))).toBeNull(); + }); +}); + +describe("deriveGitlabApprovals", () => { + it("surfaces N of M approvals", () => { + expect(deriveGitlabApprovals(gitlabFacts({ approvalsRequired: 2, approvalsGiven: 1 }))).toEqual( + { + given: 1, + required: 2, + }, + ); + }); + + it("returns null when no approvals are required", () => { + expect(deriveGitlabApprovals(gitlabFacts({ approvalsRequired: 0 }))).toBeNull(); + }); +}); + +describe("getNativeFallbackChecks", () => { + it("surfaces the Gitea aggregate CI status branded with the top-level forge", () => { + expect( + getNativeFallbackChecks( + status( + { forge: "gitea", mergeable: true, hasMerged: false, ciStatus: "success" }, + "https://gitea.com/group/repo/pulls/7", + ), + "gitea", + ), + ).toEqual([ + { + provider: "gitea", + name: "CI", + status: "success", + url: "https://gitea.com/group/repo/pulls/7", + }, + ]); + }); + + it("keeps Forgejo branding for the Gitea-family aggregate check", () => { + expect( + getNativeFallbackChecks( + status( + { forge: "gitea", mergeable: true, hasMerged: false, ciStatus: "failure" }, + "https://forgejo.example.com/group/repo/pulls/7", + ), + "forgejo", + ), + ).toEqual([ + { + provider: "forgejo", + name: "CI", + status: "failure", + url: "https://forgejo.example.com/group/repo/pulls/7", + }, + ]); + }); + + it("maps a Gitea 'warning' aggregate to a failing fallback check, not pending", () => { + expect( + getNativeFallbackChecks( + status( + { forge: "gitea", mergeable: true, hasMerged: false, ciStatus: "warning" }, + "https://gitea.com/group/repo/pulls/7", + ), + "gitea", + ), + ).toEqual([ + { + provider: "gitea", + name: "CI", + status: "failure", + url: "https://gitea.com/group/repo/pulls/7", + }, + ]); + }); + + it("returns no fallback checks for a forge that reports none", () => { + expect( + getNativeFallbackChecks( + status( + { forge: "gitea", mergeable: true, hasMerged: false, ciStatus: null }, + "https://gitea.com/group/repo/pulls/7", + ), + "gitea", + ), + ).toEqual([]); + expect( + getNativeFallbackChecks( + status(gitlabFacts(), "https://gitlab.com/group/repo/-/merge_requests/7"), + "gitlab", + ), + ).toEqual([]); + }); +}); diff --git a/packages/app/src/git/pull-request-panel/native-data.ts b/packages/app/src/git/pull-request-panel/native-data.ts new file mode 100644 index 000000000..94fa83e0b --- /dev/null +++ b/packages/app/src/git/pull-request-panel/native-data.ts @@ -0,0 +1,34 @@ +/** + * Compatibility exports for tests and non-visual derivations. Rich PR-pane + * contributions now come from client forge modules and validate the open + * `forgeSpecific` envelope at runtime. + */ +import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages"; +import type { Forge } from "@/git/forge"; +import { CLIENT_FORGE_LOGIC_MODULES } from "@/git/forges"; +import type { PrPaneCheck } from "./data"; + +type CheckoutPrStatus = NonNullable; + +const CLIENT_NATIVE_FALLBACK_CONTRIBUTIONS = CLIENT_FORGE_LOGIC_MODULES.flatMap( + (module) => module.facts?.nativeFallbackChecks ?? [], +); + +/** + * Fallback check rows for a status whose forge reports no individual checks. + * Returns [] for forges that don't synthesize one. + */ +export function getNativeFallbackChecks(status: CheckoutPrStatus, forge: Forge): PrPaneCheck[] { + const facts = status.forgeSpecific; + if (!facts) { + return []; + } + const checks: PrPaneCheck[] = []; + for (const contribute of CLIENT_NATIVE_FALLBACK_CONTRIBUTIONS) { + const check = contribute.contribute(facts, status, forge); + if (check) { + checks.push(check); + } + } + return checks; +} diff --git a/packages/app/src/git/pull-request-panel/pane.tsx b/packages/app/src/git/pull-request-panel/pane.tsx index 1f487af6d..3aba5bc89 100644 --- a/packages/app/src/git/pull-request-panel/pane.tsx +++ b/packages/app/src/git/pull-request-panel/pane.tsx @@ -10,11 +10,7 @@ import { } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { - ChevronDown, - ChevronRight, CircleCheck, - CircleDot, - CircleSlash, CircleX, Copy, ExternalLink, @@ -50,6 +46,9 @@ import { useCheckoutGitActionsStore } from "@/git/actions-store"; import { isNative } from "@/constants/platform"; import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { getForgePresentation } from "@/git/forge"; +import { CLIENT_FORGE_VIEW_MODULES } from "@/git/forges/view"; +import type { PaneNativeContribution } from "@/git/client-forge-module"; import { PrActivitySkeleton } from "./activity-skeleton"; import { collapseActivity, @@ -68,19 +67,28 @@ import { canAddPullRequestCheckLogsToChat, } from "./context-attachment"; import { getActivityVerb, getStateLabel } from "./data"; -import type { CheckStatus, PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data"; +import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data"; +import type { ForgeSpecificStatusFacts } from "@/git/merge-capability"; import { buildPrTimeline, type PrReviewEntry, type PrThreadEntry, type PrTimelineEntry, } from "./timeline"; +import { + CheckStatusIcon, + Section, + SUMMARY_DANGER_ICON, + SUMMARY_SUCCESS_ICON, + SUMMARY_WARNING_ICON, + SummaryPill, + dangerColorMapping, + foregroundMutedColorMapping, + sectionKitStyles, + successColorMapping, +} from "./section-kit"; -const ThemedChevronDown = withUnistyles(ChevronDown); -const ThemedChevronRight = withUnistyles(ChevronRight); const ThemedCircleCheck = withUnistyles(CircleCheck); -const ThemedCircleDot = withUnistyles(CircleDot); -const ThemedCircleSlash = withUnistyles(CircleSlash); const ThemedCircleX = withUnistyles(CircleX); const ThemedCopy = withUnistyles(Copy); const ThemedExternalLink = withUnistyles(ExternalLink); @@ -95,12 +103,20 @@ const ThemedRotateCw = withUnistyles(RotateCw); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); -const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess }); -const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger }); -const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning }); const mergedColorMapping = (theme: Theme) => ({ color: theme.colors.statusMerged }); +const CLIENT_PANE_CONTRIBUTIONS: readonly PaneNativeContribution[] = + CLIENT_FORGE_VIEW_MODULES.flatMap((module) => module.paneContributions ?? []); + +function resolvePaneContribution( + facts: ForgeSpecificStatusFacts | undefined, +): PaneNativeContribution | null { + if (!facts) { + return null; + } + return CLIENT_PANE_CONTRIBUTIONS.find((contribution) => contribution.guard(facts)) ?? null; +} + type IconColorMapping = typeof foregroundColorMapping; interface PrStatePresentation { @@ -115,9 +131,6 @@ const PR_STATE_PRESENTATION: Record = { closed: { Icon: ThemedGitPullRequestClosed, iconColor: dangerColorMapping }, }; -const SUMMARY_SUCCESS_ICON = ; -const SUMMARY_DANGER_ICON = ; -const SUMMARY_WARNING_ICON = ; const SUMMARY_COMMENT_ICON = ( ); @@ -133,7 +146,7 @@ function handleMarkdownLinkPress(url: string): boolean { } function rowPressableStyle({ hovered }: { hovered?: boolean }) { - return [styles.checkRow, Boolean(hovered) && styles.hoverable]; + return [sectionKitStyles.checkRow, Boolean(hovered) && styles.hoverable]; } function entryHeaderPressableStyle({ hovered }: { hovered?: boolean }) { @@ -163,8 +176,11 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { } function getCheckIdentity(check: PrPaneCheck): string { - if (check.github?.checkRunId !== undefined) { - return `${check.provider}:check-run:${check.github.checkRunId}`; + if (check.detailRef?.checkRunId !== undefined) { + return `${check.provider}:check-run:${check.detailRef.checkRunId}`; + } + if (check.detailRef?.workflowRunId !== undefined) { + return `${check.provider}:workflow-run:${check.detailRef.workflowRunId}`; } return `${check.provider}:${check.name}:${check.url}`; } @@ -203,9 +219,17 @@ export function PullRequestPane({ const { t } = useTranslation(); const toast = useToast(); const daemonClient = useHostRuntimeClient(serverId); + // COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once + // all supported clients use checkout.forge.get_check_details.*. const canFetchGitHubCheckDetails = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.githubCheckDetails === true, ); + const canFetchForgeCheckDetails = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.forgeCheckDetails === true, + ); + const forgeProvidersEnabled = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.forgeProviders === true, + ); const addWorkspaceAttachment = useWorkspaceAttachmentsStore( (state) => state.addWorkspaceAttachment, ); @@ -281,6 +305,7 @@ export function PullRequestPane({ } const input = { provider: data.provider, + forge: data.forge, pullRequest: { number: data.number, title: data.title, url: data.url }, activity, }; @@ -298,6 +323,7 @@ export function PullRequestPane({ }, [ addWorkspaceAttachment, + data.forge, data.number, data.provider, data.title, @@ -313,6 +339,7 @@ export function PullRequestPane({ } const attachment = buildPullRequestThreadContextAttachment({ provider: data.provider, + forge: data.forge, pullRequest: { number: data.number, title: data.title, url: data.url }, thread, }); @@ -326,6 +353,7 @@ export function PullRequestPane({ }, [ addWorkspaceAttachment, + data.forge, data.number, data.provider, data.title, @@ -345,7 +373,7 @@ export function PullRequestPane({ } const threads = entry.kind === "thread" ? [entry] : entry.threads; for (const thread of threads) { - if (thread.location.isResolved === true) { + if (thread.isResolved === true) { continue; } handleAddThreadToChat(thread); @@ -363,23 +391,32 @@ export function PullRequestPane({ let details = null; try { - const ref = check.github; + const ref = check.detailRef; + // The neutral forge RPC fetches detail for any forge; fall back to the + // legacy github-only RPC only for GitHub against a daemon that predates + // it. A non-GitHub forge therefore needs the neutral capability present. + const canFetchDetail = + canFetchForgeCheckDetails || (check.provider === "github" && canFetchGitHubCheckDetails); if ( - canFetchGitHubCheckDetails && + canFetchDetail && daemonClient && - check.provider === "github" && - ref?.checkRunId !== undefined && + (ref?.checkRunId !== undefined || ref?.workflowRunId !== undefined) && data.repoOwner && data.repoName ) { try { - const payload = await daemonClient.checkoutGithubGetCheckDetails({ + const request = { cwd, repoOwner: data.repoOwner, repoName: data.repoName, checkRunId: ref.checkRunId, workflowRunId: ref.workflowRunId, - }); + }; + // COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once + // all supported clients use checkout.forge.get_check_details.*. + const payload = canFetchForgeCheckDetails + ? await daemonClient.checkoutForgeGetCheckDetails(request) + : await daemonClient.checkoutGithubGetCheckDetails(request); details = payload.success ? payload.details : null; } catch { details = null; @@ -387,6 +424,7 @@ export function PullRequestPane({ } const attachment = buildPullRequestCheckContextAttachment({ provider: data.provider, + forge: data.forge, pullRequest: { number: data.number, title: data.title, url: data.url }, check, githubDetails: details, @@ -403,9 +441,11 @@ export function PullRequestPane({ }, [ addWorkspaceAttachment, + canFetchForgeCheckDetails, canFetchGitHubCheckDetails, cwd, daemonClient, + data.forge, data.number, data.provider, data.repoName, @@ -428,6 +468,28 @@ export function PullRequestPane({ const statePresentation = PR_STATE_PRESENTATION[data.state]; const StateIcon = statePresentation.Icon; + const forgePresentation = getForgePresentation(data.forge); + const repoIdentity = + data.projectPath ?? + (data.repoOwner && data.repoName ? `${data.repoOwner}/${data.repoName}` : null); + + // Native forge surfaces (e.g. GitLab approvals/pipeline) come from a registry + // keyed by the facts-family, so the central render has no per-forge branch. + const nativeContribution = resolvePaneContribution(data.forgeSpecific); + const nativeHeaderMeta = data.forgeSpecific + ? nativeContribution?.renderHeaderMeta(data.forgeSpecific) + : null; + const nativeChecksSection = data.forgeSpecific + ? nativeContribution?.renderChecksSection(data.forgeSpecific, { + serverId, + cwd, + changeRequestNumber: data.number, + open: checksOpen, + onToggle: handleToggleChecks, + enabled: forgeProvidersEnabled, + canFetchCheckDetails: canFetchForgeCheckDetails, + }) + : null; return ( @@ -451,7 +513,7 @@ export function PullRequestPane({ accessibilityLabel={ isRefreshing ? t("workspace.git.diff.refreshing") - : t("workspace.git.diff.refreshState") + : t("workspace.git.diff.refreshState", { brand: forgePresentation.brandLabel }) } testID="pr-pane-refresh" style={refreshButtonStyle} @@ -478,16 +540,21 @@ export function PullRequestPane({ <> {data.title} - #{data.number} + + {" "} + {forgePresentation.numberPrefix} + {data.number} + {getStateLabel(data.state)} - {data.repoOwner && data.repoName ? ( + {nativeHeaderMeta} + {repoIdentity ? ( - {data.repoOwner}/{data.repoName} + {repoIdentity} ) : null} @@ -498,50 +565,52 @@ export function PullRequestPane({ )} -
- - - - - } - > - {data.checks.length === 0 ? ( - No checks - ) : ( - data.checks.map((check) => { - const checkKey = getCheckIdentity(check); - return ( - + - ); - }) - )} -
+ + + + } + > + {data.checks.length === 0 ? ( + No checks + ) : ( + data.checks.map((check) => { + const checkKey = getCheckIdentity(check); + return ( + + ); + }) + )} + + )} @@ -572,7 +641,7 @@ export function PullRequestPane({ ) : null} {activityLoading ? : null} {!activityLoading && visibleEntries.length === 0 ? ( - No activity yet + No activity yet ) : null} {!activityLoading ? visibleEntries.map(({ entry, collapsed }) => ( @@ -582,6 +651,7 @@ export function PullRequestPane({ collapsed={collapsed} collapsedEntryIds={collapsedEntryIds} attachEnabled={attachEnabled} + brandLabel={forgePresentation.brandLabel} onAddToChat={handleAddActivityToChat} onAddThreadToChat={handleAddThreadToChat} onToggleCollapsed={handleToggleEntryCollapsed} @@ -601,58 +671,6 @@ function stateLabelStyle(state: PrState) { return styles.stateLabelClosed; } -interface SectionProps { - title: string; - open: boolean; - onToggle: () => void; - summary: React.ReactNode; - children: React.ReactNode; -} - -function Section({ title, open, onToggle, summary, children }: SectionProps) { - return ( - - - {open ? ( - - ) : ( - - )} - {title} - {summary} - - {open ? {children} : null} - - ); -} - -function SummaryPill({ - count, - icon, - variant, - testID, -}: { - count: number; - icon: React.ReactNode; - variant: "success" | "danger" | "warning" | "muted"; - testID?: string; -}) { - if (count === 0) return null; - return ( - - {icon} - {count} - - ); -} - -function summaryPillTextStyle(variant: "success" | "danger" | "warning" | "muted") { - if (variant === "success") return styles.summaryPillSuccessText; - if (variant === "danger") return styles.summaryPillDangerText; - if (variant === "warning") return styles.summaryPillWarningText; - return styles.summaryPillMutedText; -} - function CheckRow({ check, attachEnabled, @@ -677,15 +695,15 @@ function CheckRow({ return ( - + {check.name} {check.workflow && ( - + {check.workflow} )} - + {attachEnabled && canAddPullRequestCheckLogsToChat(check) ? (