feat(forge): pluggable forge abstraction + GitLab and Gitea/Forgejo/Codeberg (#1913)

* refactor(forge): forge-neutral foundation (GitHub-only)

Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged.

- Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry.
- GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved.
- forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client).
- createPullRequest reports typed CLI/auth errors instead of a generic message.
- forge-resolver host/remote caches are LRU-bounded.
- Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com.
- Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology.
- Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs.

* feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals

GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals.

- threadIsResolved is part of the neutral timeline item.
- Pipeline load failures show an error instead of an empty section.
- Manual pipeline jobs render as pending.
- Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request).

* feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg)

One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts.

- CI status aggregates commit statuses and Actions runs together.
- Gitea's terminal "warning" state maps to failure on server and client.
- Gitea Actions check details are reachable from the PR pane by workflowRunId.

* refactor(forge): localize compatibility handling

* test(forge): expect normalized GitLab facts

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
nllptrx
2026-07-17 09:03:26 +02:00
committed by GitHub
parent 04d1ebdce0
commit a8ebd390fa
187 changed files with 20988 additions and 2907 deletions

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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):**

176
docs/forge-providers.md Normal file
View File

@@ -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<string, unknown>;
```
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.

View File

@@ -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".

View File

@@ -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.

View File

@@ -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

View File

@@ -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,

View File

@@ -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;
};

View File

@@ -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"

View File

@@ -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({

View File

@@ -347,6 +347,7 @@ function ExplorerSidebarContent({
testID="explorer-tab-pr"
>
<PullRequestTabIcon
forge={prPane.forge}
size={13}
color={
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted

View File

@@ -0,0 +1,8 @@
import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon";
const CODEBERG_ICON_PATH =
"M11.999.747A11.974 11.974 0 0 0 0 12.75c0 2.254.635 4.465 1.833 6.376L11.837 6.19c.072-.092.251-.092.323 0l4.178 5.402h-2.992l.065.239h3.113l.882 1.138h-3.674l.103.374h3.86l.777 1.003h-4.358l.135.483h4.593l.695.894h-5.038l.165.589h5.326l.609.785h-5.717l.182.65h6.038l.562.727h-6.397l.183.65h6.717A12.003 12.003 0 0 0 24 12.75 11.977 11.977 0 0 0 11.999.747zm3.654 19.104.182.65h5.326c.173-.204.353-.433.513-.65zm.385 1.377.18.65h3.563c.233-.198.485-.428.712-.65zm.383 1.377.182.648h1.203c.356-.204.685-.412 1.042-.648z";
export function CodebergIcon(props: SvgPathIconProps) {
return <SvgPathIcon {...props} viewBox="0 0 24 24" path={CODEBERG_ICON_PATH} />;
}

View File

@@ -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 <SvgPathIcon {...props} viewBox="0 0 24 24" path={FORGEJO_ICON_PATH} />;
}

View File

@@ -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 <SvgPathIcon {...props} viewBox="0 0 24 24" path={GITEA_ICON_PATH} />;
}

View File

@@ -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 <SvgPathIcon {...props} viewBox="0 0 24 24" path={GITLAB_ICON_PATH} />;
}

View File

@@ -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 (
<Svg width={size} height={size} viewBox={viewBox} fill={color}>
<Path d={path} />
</Svg>
);
}

View File

@@ -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 (
<Pressable
accessibilityRole="link"
accessibilityLabel={t("workspace.git.pr.accessibility.pullRequest", {
number: hint.number,
context: presentation.changeRequestContext,
})}
hitSlop={4}
onPressIn={handlePressIn}
@@ -339,6 +342,7 @@ export function PrBadge({ hint }: { hint: PrHint }) {
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
)}
<Text style={textStyle} numberOfLines={1}>
{presentation.numberPrefix}
{hint.number}
</Text>
</Pressable>

View File

@@ -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 <ForgeBrandIcon iconKind={icon} size={10} uniProps={redColorMapping} />;
}
type SidebarWorkspaceScriptIconKind = "service" | "command";
export function SidebarWorkspaceRowFrame({
@@ -156,7 +161,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
{workspace.prHint ? (
<View style={styles.workspacePrBadgeRow}>
<PrBadge hint={workspace.prHint} />
<ChecksBadge checks={workspace.prHint.checks} />
<ChecksBadge checks={workspace.prHint.checks} forge={workspace.prHint.forge} />
</View>
) : null}
</View>
@@ -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 (
<Pressable
accessibilityRole="link"
accessibilityLabel={`Pull request #${hint.number}`}
accessibilityLabel={t("workspace.git.pr.accessibility.pullRequest", {
number: hint.number,
context: presentation.changeRequestContext,
})}
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
@@ -330,19 +340,21 @@ function PrBadge({ hint }: { hint: PrHint }) {
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
)}
<Text style={textStyle} numberOfLines={1}>
{presentation.numberPrefix}
{hint.number}
</Text>
</Pressable>
);
}
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 (
<View style={checksBadgeStyles.badge}>
<ThemedGitHubIcon size={10} uniProps={redColorMapping} />
{renderChecksBadgeForgeIcon(icon)}
<Text style={checksBadgeStyles.text}>{failed} failed</Text>
</View>
);

View File

@@ -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 ? (
<>
<View style={styles.separator} />
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} />
<ChecksSummaryPressable
checks={prHint.checks}
url={prHint.url}
forge={prHint.forge}
/>
</>
) : null}
</FloatingSurface>
@@ -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 <ForgeBrandIcon iconKind={icon} size={12} uniProps={iconUniProps} />;
}
function CopyableInfoRow({
icon: Icon,
value,
@@ -488,9 +496,11 @@ function ChecksSummaryPill({
function ChecksSummaryContent({
checks,
forge,
hovered,
}: {
checks: NonNullable<PrHint["checks"]>;
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 ? (
<ThemedExternalLink size={12} uniProps={iconUniProps} />
) : (
<ThemedGitHubIcon size={12} uniProps={iconUniProps} />
renderChecksSummaryForgeIcon(icon, iconUniProps)
)}
<Text style={labelStyle}>{t("workspace.git.pr.sections.checks")}</Text>
<View style={styles.checksSummaryCounts}>
@@ -518,9 +529,11 @@ function ChecksSummaryContent({
function ChecksSummaryPressable({
checks,
forge,
url,
}: {
checks: NonNullable<PrHint["checks"]>;
forge: PrHint["forge"];
url: string;
}) {
const handlePress = useCallback(() => {
@@ -529,9 +542,9 @@ function ChecksSummaryPressable({
const renderChildren = useCallback(
({ hovered }: { pressed: boolean; hovered?: boolean }) => (
<ChecksSummaryContent checks={checks} hovered={Boolean(hovered)} />
<ChecksSummaryContent checks={checks} forge={forge} hovered={Boolean(hovered)} />
),
[checks],
[checks, forge],
);
return (

View File

@@ -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,
],
);

View File

@@ -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", () => {

View File

@@ -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<Array<{ data: string; mimeType: string }> | undefined>;
@@ -171,7 +175,9 @@ export interface DispatchComposerAgentMessageInput {
export async function dispatchComposerAgentMessage(
input: DispatchComposerAgentMessageInput,
): Promise<void> {
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<UserComposerAttachment, { kind: "github_issue" } | { kind: "github_pr" }> {
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;

View File

@@ -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);
}

View File

@@ -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<TDraftAgent, TCreateResult>({
}
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<TDraftAgent, TCreateResult>({
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,

View File

@@ -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<UserComposerAttachment[]>(
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 },

View File

@@ -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<SetStateAction<UserComposerAttachment[]>>;
}
@@ -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;
}

View File

@@ -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<ComposerAttachment, { kind: "github_pr" | "github_issue" }>;
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}
>
<AttachmentLabel
icon={item.kind === "pr" ? githubPrPillIcon : githubIssuePillIcon}
icon={isChangeRequest ? githubPrPillIcon : githubIssuePillIcon}
title={item.title}
subtitle={`${item.kind === "pr" ? "PR" : "Issue"} #${item.number}`}
subtitle={`${subtitleKind} ${numberPrefix}${item.number}`}
/>
</AttachmentPill>
);
@@ -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" ? (
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
) : (
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
@@ -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: <ThemedGithub size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
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 (
<ForgeBrandIcon iconKind={icon} size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />
);
}
const githubPrPillIcon = (
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
);

View File

@@ -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,

View File

@@ -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()

View File

@@ -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<CheckoutGitActionsStoreState>()
},
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<CheckoutGitActionsStoreState>()
},
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);
}

View File

@@ -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<CheckoutPrStatusPayload> = {}): 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<CheckoutPrStatusPayload> = {}): CheckoutPrS
reviewDecision: null,
},
githubFeaturesEnabled: true,
authState: "authenticated",
forge: "github",
error: null,
requestId: "pr-status-1",
...overrides,
@@ -62,7 +74,7 @@ function prStatus(overrides: Partial<CheckoutPrStatusPayload> = {}): CheckoutPrS
function checkoutStatusUpdate(
payload: CheckoutStatusPayload,
extraPrStatus?: CheckoutPrStatusPayload,
extraPrStatus?: NonNullable<CheckoutStatusUpdate["payload"]["prStatus"]>,
): 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<CheckoutPrStatusPayload>(checkoutPrStatusQueryKey(serverId, cwd))
?.authState,
).toBe("unauthenticated");
expect(
queryClient.getQueryData<CheckoutStatusUpdate["payload"]>(
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);
});
});

View File

@@ -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<CheckoutStatusUpdate["payload"]["prStatus"]>;
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
export interface CheckoutStatusClient {
getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload>;
@@ -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;
}

View File

@@ -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<ForgeIconProps>;
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<string, unknown>;
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
export type LegacyGithubMergeFacts = NonNullable<CheckoutPrStatus["github"]>;
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<TFacts extends ForgeSpecificEnvelope> {
renderHeaderMeta?: (facts: TFacts) => ReactNode;
renderChecksSection?: (facts: TFacts, ctx: PaneChecksSlotContext) => ReactNode;
}
interface TypedNativeFallbackCheck<TFacts extends ForgeSpecificEnvelope> {
contribute: (facts: TFacts, status: CheckoutPrStatus, forge: Forge) => PrPaneCheck | null;
}
export interface ClientForgeFactsEntry<TFacts extends ForgeSpecificEnvelope> {
readonly family: TFacts["forge"];
parse: (facts: unknown) => TFacts | null;
deriveMergeCapability: (facts: unknown) => MergeCapability | null;
readonly nativeFallbackChecks: readonly NativeFallbackCheckEntry[];
}
export interface ClientForgeFactsRegistration<TFacts extends ForgeSpecificEnvelope> {
readonly family: TFacts["forge"];
readonly schema: z.ZodType<TFacts>;
readonly deriveMergeCapability?: (facts: TFacts) => MergeCapability;
readonly nativeFallbackChecks?: readonly NativeFallbackCheckEntry[];
}
function parseFacts<TFacts extends ForgeSpecificEnvelope>(
schema: z.ZodType<TFacts>,
facts: unknown,
): TFacts | null {
if (!facts) {
return null;
}
const result = schema.safeParse(facts);
return result.success ? result.data : null;
}
export function defineNativeFallbackCheck<TFacts extends ForgeSpecificEnvelope>(
schema: z.ZodType<TFacts>,
contribution: TypedNativeFallbackCheck<TFacts>,
): NativeFallbackCheckEntry {
return {
contribute: (facts, status, forge) => {
const parsed = parseFacts(schema, facts);
return parsed ? contribution.contribute(parsed, status, forge) : null;
},
};
}
export function definePaneContribution<TFacts extends ForgeSpecificEnvelope>(
schema: z.ZodType<TFacts>,
contribution: TypedPaneContribution<TFacts>,
): 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<TFacts extends ForgeSpecificEnvelope>(
registration: ClientForgeFactsRegistration<TFacts>,
): ClientForgeFactsEntry<TFacts> {
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<TFacts>;
}
/**
* 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[];
}

View File

@@ -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,
})}
</DropdownMenuItem>
</>
) : 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<ForgeSetupAction, null>;
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: <ThemedDownload size={16} uniProps={foregroundMutedIconColorMapping} />,
push: <ThemedUpload size={16} uniProps={foregroundMutedIconColorMapping} />,
pullAndPush: <ThemedArrowDownUp size={16} uniProps={foregroundMutedIconColorMapping} />,
viewPr: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
createPr: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
mergePrSquash: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
mergePrMerge: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
mergePrRebase: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
merge: <ThemedGitMerge size={16} uniProps={foregroundMutedIconColorMapping} />,
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={foregroundMutedIconColorMapping} />,
archive: <ThemedArchive size={16} uniProps={foregroundMutedIconColorMapping} />,
@@ -2531,6 +2615,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
/>
) : null}
<DiffOptionsMenu
brand={getForgePresentation(forge).brandLabel}
hideWhitespace={changesPreferences.hideWhitespace}
isMobile={isMobile}
isRefreshing={isRefreshing}
@@ -2546,6 +2631,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
</View>
) : null}
{forgeSetupMessage ? (
<View style={styles.forgeSetupCallout} testID="forge-setup-callout">
<Text style={styles.forgeSetupCalloutText}>{forgeSetupMessage}</Text>
</View>
) : null}
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
<View style={styles.diffContainer}>{bodyContent}</View>
@@ -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,

View File

@@ -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();
});
});

View File

@@ -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 <ThemedIcon size={size} uniProps={uniProps} />;
}

View File

@@ -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);
});
});

View File

@@ -0,0 +1,127 @@
/**
* Forge-neutral web URL builders for "Open on <forge>" 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;
}

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<typeof GiteaMergeFactsSchema>;
// 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<GiteaMergeFacts>;

View File

@@ -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;

View File

@@ -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<typeof GithubMergeFactsSchema>;
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<GithubMergeFacts>;

View File

@@ -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;

View File

@@ -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<string>(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<typeof GitlabMergeFactsSchema>;
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<GitlabMergeFacts>;

View File

@@ -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 <GitlabApprovalsBadge approvals={approvals} />;
}
function renderGitlabChecksSection(facts: GitlabMergeFacts, ctx: PaneChecksSlotContext): ReactNode {
if (!ctx.enabled) {
return null;
}
const summary = deriveGitlabPipelineSummary(facts);
if (!summary) {
return null;
}
return (
<GitLabPipelineSection
serverId={ctx.serverId}
cwd={ctx.cwd}
changeRequestNumber={ctx.changeRequestNumber}
summary={summary}
open={ctx.open}
onToggle={ctx.onToggle}
canFetchCheckDetails={ctx.canFetchCheckDetails}
/>
);
}
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedExternalLink = withUnistyles(ExternalLink);
function GitlabApprovalsBadge({ approvals }: { approvals: GitlabApprovals }) {
const { t } = useTranslation();
return (
<View style={styles.approvalsBadge} testID="pr-pane-approvals">
<ThemedCircleCheck
size={11}
uniProps={
approvals.given >= approvals.required ? successColorMapping : foregroundMutedColorMapping
}
/>
<Text style={styles.approvalsText}>
{t("workspace.git.pr.approvals", { given: approvals.given, required: approvals.required })}
</Text>
</View>
);
}
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 = (
<>
<SummaryPill
count={displayCounts.passed}
icon={SUMMARY_SUCCESS_ICON}
variant="success"
testID="pr-pane-pipeline-passed"
/>
<SummaryPill
count={displayCounts.failed}
icon={SUMMARY_DANGER_ICON}
variant="danger"
testID="pr-pane-pipeline-failed"
/>
<SummaryPill
count={displayCounts.pending}
icon={SUMMARY_WARNING_ICON}
variant="warning"
testID="pr-pane-pipeline-pending"
/>
{showBreakdown ? null : <CheckStatusIcon status={summary.status} />}
</>
);
return (
<Section
title={t("workspace.git.pr.sections.pipeline")}
open={open}
onToggle={onToggle}
summary={sectionSummary}
>
<Pressable
onPress={handleOpenPipeline}
style={rowPressableStyle}
disabled={!summary.url}
testID="pr-pane-pipeline-link"
>
<CheckStatusIcon status={summary.status} />
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
{`Pipeline #${summary.id}`}
</Text>
{summary.rawStatus ? (
<Text style={sectionKitStyles.checkWorkflow} numberOfLines={1}>
{summary.rawStatus}
</Text>
) : null}
{summary.url ? (
<View style={sectionKitStyles.checkTrailing}>
<ThemedExternalLink size={12} uniProps={foregroundMutedColorMapping} />
</View>
) : null}
</Pressable>
{isLoading ? (
<Text style={sectionKitStyles.emptyText}>
{t("workspace.git.pr.empty.loadingPipeline")}
</Text>
) : null}
{!isLoading && pipeline && pipeline.stages.length === 0 ? (
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noJobs")}</Text>
) : null}
{!isLoading && pipeline && pipeline.stages.length > 0
? pipeline.stages.map((stage) => <PipelineStageGroup key={stage.name} stage={stage} />)
: null}
{!isLoading && !pipeline && error ? (
<Text style={sectionKitStyles.emptyText}>
{t("workspace.git.pr.empty.pipelineJobsLoadFailed")}
</Text>
) : null}
</Section>
);
}
function PipelineStageGroup({ stage }: { stage: CheckoutPipelineStage }) {
return (
<View>
<View style={styles.pipelineStageHeader}>
<CheckStatusIcon status={mapPipelineStatus(stage.status)} />
<Text style={styles.pipelineStageName} numberOfLines={1}>
{stage.name}
</Text>
</View>
{stage.jobs.map((job) => (
<PipelineJobRow key={job.id} job={job} />
))}
</View>
);
}
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 (
<Pressable onPress={handlePress} style={jobRowPressableStyle} disabled={!job.url}>
<CheckStatusIcon status={mapPipelineStatus(job.status)} />
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
{job.name}
</Text>
{job.allowFailure ? (
<Text style={sectionKitStyles.checkWorkflow} numberOfLines={1}>
{t("workspace.git.pr.empty.allowedToFail")}
</Text>
) : null}
<View style={sectionKitStyles.checkTrailing}>
{duration ? <Text style={sectionKitStyles.checkDuration}>{duration}</Text> : null}
</View>
</Pressable>
);
}
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,
},
}));

View File

@@ -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/<id>.ts (logic) + forges/<id>.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);
});
});

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -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> = {}): 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> = {}): 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> = {}): 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);
});
});

View File

@@ -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);
}

View File

@@ -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<BuildGitActionsInput["pullRequestGithub"]>> = {},
): NonNullable<BuildGitActionsInput["pullRequestGithub"]> {
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> = {}): GithubMergeFactsFixture {
return {
forge: "github",
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
@@ -27,10 +50,17 @@ function githubStatus(
};
}
function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
function createInput(
overrides: Partial<Omit<BuildGitActionsInput, "mergeCapability">> & {
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<BuildGitActionsInput> = {}): 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<BuildGitActionsInput> = {}): 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)",

View File

@@ -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<CheckoutPrStatusResponse["payload"]["status"]>["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<PullRequestGithubStatus> {
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);
}

View File

@@ -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();
});
});

View File

@@ -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"],

View File

@@ -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"]> = {},
): 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");
});
});

View File

@@ -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<WireCheckoutPrStatusPayload, "authState"> & {
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"),
};
}

View File

@@ -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(), {

View File

@@ -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 (

View File

@@ -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";
}

View File

@@ -11,14 +11,15 @@ import {
import type { PrPaneActivity, PrPaneCheck } from "./data";
import type { PrThreadEntry } from "./timeline";
const baseInput: Omit<PullRequestContextBuilderInput, "activity"> = {
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<PullRequestContextBuilderInput, "activity">;
function comment(overrides: Partial<PrPaneActivity> = {}): PrPaneActivity {
return {
@@ -55,7 +56,7 @@ function check(overrides: Partial<PrPaneCheck> = {}): 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",

View File

@@ -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)}`;
}

View File

@@ -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", () => {

View File

@@ -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<CheckoutPrStatus>): PrState {
return "open";
}
function mapCheck(check: NonNullable<CheckoutPrStatus>["checks"][number]): PrPaneCheck[] {
function mapChecks(status: NonNullable<CheckoutPrStatus>, 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<CheckoutPrStatus>["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<CheckoutPrStatus>["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<CheckoutPrStatus>["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,

View File

@@ -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<CheckoutPrStatusResponse["payload"]["status"]>;
function gitlabFacts(overrides: Partial<GitlabMergeFacts> = {}): 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([]);
});
});

View File

@@ -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<CheckoutPrStatusResponse["payload"]["status"]>;
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;
}

View File

@@ -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<PrState, PrStatePresentation> = {
closed: { Icon: ThemedGitPullRequestClosed, iconColor: dangerColorMapping },
};
const SUMMARY_SUCCESS_ICON = <ThemedCircleCheck size={12} uniProps={successColorMapping} />;
const SUMMARY_DANGER_ICON = <ThemedCircleX size={12} uniProps={dangerColorMapping} />;
const SUMMARY_WARNING_ICON = <ThemedCircleDot size={12} uniProps={warningColorMapping} />;
const SUMMARY_COMMENT_ICON = (
<ThemedMessageSquare size={11} uniProps={foregroundMutedColorMapping} />
);
@@ -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 (
<View style={styles.root} testID="pr-pane">
@@ -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({
<>
<Text style={styles.title} testID="pr-pane-title">
{data.title}
<Text style={styles.titleNumber}> #{data.number}</Text>
<Text style={styles.titleNumber}>
{" "}
{forgePresentation.numberPrefix}
{data.number}
</Text>
</Text>
<View style={styles.metaLine}>
<StateIcon size={14} uniProps={statePresentation.iconColor} />
<Text style={stateLabelStyle(data.state)} testID="pr-pane-state">
{getStateLabel(data.state)}
</Text>
{data.repoOwner && data.repoName ? (
{nativeHeaderMeta}
{repoIdentity ? (
<Text style={styles.repoRef} numberOfLines={1}>
{data.repoOwner}/{data.repoName}
{repoIdentity}
</Text>
) : null}
<View style={hovered ? styles.headerLinkIcon : styles.headerLinkIconHidden}>
@@ -498,50 +565,52 @@ export function PullRequestPane({
)}
</Pressable>
<Section
title="Checks"
open={checksOpen}
onToggle={handleToggleChecks}
summary={
<>
<SummaryPill
count={passed}
icon={SUMMARY_SUCCESS_ICON}
variant="success"
testID="pr-pane-check-passed"
/>
<SummaryPill
count={failed}
icon={SUMMARY_DANGER_ICON}
variant="danger"
testID="pr-pane-check-failed"
/>
<SummaryPill
count={pending}
icon={SUMMARY_WARNING_ICON}
variant="warning"
testID="pr-pane-check-pending"
/>
</>
}
>
{data.checks.length === 0 ? (
<Text style={styles.emptyText}>No checks</Text>
) : (
data.checks.map((check) => {
const checkKey = getCheckIdentity(check);
return (
<CheckRow
key={checkKey}
check={check}
attachEnabled={attachEnabled}
isAddingLogsToChat={loadingCheckKeys.has(checkKey)}
onAddLogsToChat={handleAddCheckLogsToChat}
{nativeChecksSection ?? (
<Section
title="Checks"
open={checksOpen}
onToggle={handleToggleChecks}
summary={
<>
<SummaryPill
count={passed}
icon={SUMMARY_SUCCESS_ICON}
variant="success"
testID="pr-pane-check-passed"
/>
);
})
)}
</Section>
<SummaryPill
count={failed}
icon={SUMMARY_DANGER_ICON}
variant="danger"
testID="pr-pane-check-failed"
/>
<SummaryPill
count={pending}
icon={SUMMARY_WARNING_ICON}
variant="warning"
testID="pr-pane-check-pending"
/>
</>
}
>
{data.checks.length === 0 ? (
<Text style={sectionKitStyles.emptyText}>No checks</Text>
) : (
data.checks.map((check) => {
const checkKey = getCheckIdentity(check);
return (
<CheckRow
key={checkKey}
check={check}
attachEnabled={attachEnabled}
isAddingLogsToChat={loadingCheckKeys.has(checkKey)}
onAddLogsToChat={handleAddCheckLogsToChat}
/>
);
})
)}
</Section>
)}
<View style={styles.divider} />
@@ -572,7 +641,7 @@ export function PullRequestPane({
) : null}
{activityLoading ? <PrActivitySkeleton /> : null}
{!activityLoading && visibleEntries.length === 0 ? (
<Text style={styles.emptyText}>No activity yet</Text>
<Text style={sectionKitStyles.emptyText}>No activity yet</Text>
) : 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 (
<View>
<Pressable style={styles.sectionHeader} onPress={onToggle}>
{open ? (
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />
)}
<Text style={styles.sectionTitle}>{title}</Text>
<View style={styles.summaryWrap}>{summary}</View>
</Pressable>
{open ? <View style={styles.sectionBody}>{children}</View> : null}
</View>
);
}
function SummaryPill({
count,
icon,
variant,
testID,
}: {
count: number;
icon: React.ReactNode;
variant: "success" | "danger" | "warning" | "muted";
testID?: string;
}) {
if (count === 0) return null;
return (
<View style={styles.summaryPill} testID={testID}>
{icon}
<Text style={summaryPillTextStyle(variant)}>{count}</Text>
</View>
);
}
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 (
<Pressable onPress={handlePress} style={rowPressableStyle}>
<CheckStatusIcon status={check.status} />
<Text style={styles.checkName} numberOfLines={1}>
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
{check.name}
</Text>
{check.workflow && (
<Text style={styles.checkWorkflow} numberOfLines={1}>
<Text style={sectionKitStyles.checkWorkflow} numberOfLines={1}>
{check.workflow}
</Text>
)}
<View style={styles.checkTrailing}>
<View style={sectionKitStyles.checkTrailing}>
{attachEnabled && canAddPullRequestCheckLogsToChat(check) ? (
<Button
variant="ghost"
@@ -698,21 +716,15 @@ function CheckRow({
{isAddingLogsToChat ? "Adding..." : "Add to chat"}
</Button>
) : null}
{check.duration && <Text style={styles.checkDuration}>{check.duration}</Text>}
{check.duration && <Text style={sectionKitStyles.checkDuration}>{check.duration}</Text>}
</View>
</Pressable>
);
}
function CheckStatusIcon({ status }: { status: CheckStatus }) {
if (status === "success") return <ThemedCircleCheck size={14} uniProps={successColorMapping} />;
if (status === "failure") return <ThemedCircleX size={14} uniProps={dangerColorMapping} />;
if (status === "pending") return <ThemedCircleDot size={14} uniProps={warningColorMapping} />;
return <ThemedCircleSlash size={14} uniProps={foregroundMutedColorMapping} />;
}
interface TimelineEntryCallbacks {
attachEnabled: boolean;
brandLabel: string;
onAddToChat: (activity: PrPaneActivity) => void;
onAddThreadToChat: (thread: PrThreadEntry) => void;
onToggleCollapsed: (entryId: string, collapsed: boolean) => void;
@@ -758,15 +770,18 @@ function ActivityKebab({
activity,
visible,
attachEnabled,
brandLabel,
onMenuOpenChange,
onAddToChat,
}: {
activity: PrPaneActivity;
visible: boolean;
attachEnabled: boolean;
brandLabel: string;
onMenuOpenChange: (open: boolean) => void;
onAddToChat: (activity: PrPaneActivity) => void;
}) {
const { t } = useTranslation();
const handleAddToChat = useCallback(() => onAddToChat(activity), [activity, onAddToChat]);
const handleCopy = useCallback(() => {
void writeMarkdownToRichClipboard(activity.body, getDefaultMarkdownClipboardEnvironment());
@@ -797,7 +812,7 @@ function ActivityKebab({
</DropdownMenuItem>
) : null}
<DropdownMenuItem leading={OPEN_MENU_ICON} onSelect={handleOpen}>
Open on GitHub
{t("workspace.git.pr.actions.openOn", { brand: brandLabel })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -887,6 +902,7 @@ function SingleActivityCard({
entry,
collapsed,
attachEnabled,
brandLabel,
onAddToChat,
onToggleCollapsed,
}: TimelineEntryCallbacks & {
@@ -920,6 +936,7 @@ function SingleActivityCard({
activity={activity}
visible={actionsVisible}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onMenuOpenChange={setMenuOpen}
onAddToChat={onAddToChat}
/>
@@ -942,6 +959,7 @@ function SingleActivityCard({
activity={activity}
visible={actionsVisible}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onMenuOpenChange={setMenuOpen}
onAddToChat={onAddToChat}
/>
@@ -974,6 +992,7 @@ function ThreadCard({
entry,
collapsed,
attachEnabled,
brandLabel,
onAddToChat,
onAddThreadToChat,
onToggleCollapsed,
@@ -987,6 +1006,7 @@ function ThreadCard({
thread={entry}
collapsed={collapsed}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onAddToChat={onAddToChat}
onAddThreadToChat={onAddThreadToChat}
onToggleCollapsed={onToggleCollapsed}
@@ -1000,6 +1020,7 @@ function ReviewCard({
collapsed,
collapsedEntryIds,
attachEnabled,
brandLabel,
onAddToChat,
onAddThreadToChat,
onToggleCollapsed,
@@ -1047,6 +1068,7 @@ function ReviewCard({
activity={review}
visible={actionsVisible}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onMenuOpenChange={setMenuOpen}
onAddToChat={onAddToChat}
/>
@@ -1079,6 +1101,7 @@ function ReviewCard({
thread={thread}
collapsed={collapsedEntryIds.has(thread.id)}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onAddToChat={onAddToChat}
onAddThreadToChat={onAddThreadToChat}
onToggleCollapsed={onToggleCollapsed}
@@ -1097,6 +1120,7 @@ function ThreadBlock({
thread,
collapsed,
attachEnabled,
brandLabel,
onAddToChat,
onAddThreadToChat,
onToggleCollapsed,
@@ -1104,10 +1128,12 @@ function ThreadBlock({
thread: PrThreadEntry;
collapsed: boolean;
attachEnabled: boolean;
brandLabel: string;
onAddToChat: (activity: PrPaneActivity) => void;
onAddThreadToChat: (thread: PrThreadEntry) => void;
onToggleCollapsed: (entryId: string, collapsed: boolean) => void;
}) {
const { t } = useTranslation();
const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } =
useRevealOnHover();
const handleHeaderPress = useCallback(() => {
@@ -1127,10 +1153,12 @@ function ThreadBlock({
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
<Pressable onPress={handleHeaderPress} style={threadHeaderPressableStyle}>
<Text style={styles.threadPath} numberOfLines={1}>
{formatPullRequestThreadPath(thread.location)}
{thread.location
? formatPullRequestThreadPath(thread.location)
: t("workspace.git.pr.thread.discussion")}
</Text>
{thread.location.isResolved ? <StatusBadge label="Resolved" variant="success" /> : null}
{thread.location.isOutdated ? <StatusBadge label="Outdated" /> : null}
{thread.isResolved ? <StatusBadge label="Resolved" variant="success" /> : null}
{thread.location?.isOutdated ? <StatusBadge label="Outdated" /> : null}
<View style={styles.headerTrailing}>
{collapsed ? (
<View style={styles.threadCount}>
@@ -1152,7 +1180,7 @@ function ThreadBlock({
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={200}>
<DropdownMenuItem leading={OPEN_MENU_ICON} onSelect={handleOpenThread}>
Open on GitHub
{t("workspace.git.pr.actions.openOn", { brand: brandLabel })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -1161,7 +1189,12 @@ function ThreadBlock({
</Pressable>
{collapsed ? null : (
<>
<ThreadComment comment={root} attachEnabled={attachEnabled} onAddToChat={onAddToChat} />
<ThreadComment
comment={root}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onAddToChat={onAddToChat}
/>
{replies.length > 0 ? (
<View style={styles.replyRail}>
{replies.map((reply) => (
@@ -1169,6 +1202,7 @@ function ThreadBlock({
<ThreadComment
comment={reply}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onAddToChat={onAddToChat}
contentStyle={styles.replyThreadComment}
/>
@@ -1205,11 +1239,13 @@ function threadCommentStyle(contentStyle?: ViewStyle) {
function ThreadComment({
comment,
attachEnabled,
brandLabel,
onAddToChat,
contentStyle,
}: {
comment: PrPaneActivity;
attachEnabled: boolean;
brandLabel: string;
onAddToChat: (activity: PrPaneActivity) => void;
contentStyle?: ViewStyle;
}) {
@@ -1227,6 +1263,7 @@ function ThreadComment({
activity={comment}
visible={actionsVisible}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onMenuOpenChange={setMenuOpen}
onAddToChat={onAddToChat}
/>
@@ -1357,87 +1394,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
},
sectionHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
sectionTitle: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
sectionBody: {
paddingBottom: theme.spacing[3],
},
summaryWrap: {
marginLeft: "auto",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
summaryPill: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
summaryPillSuccessText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusSuccess,
},
summaryPillDangerText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusDanger,
},
summaryPillWarningText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusWarning,
},
summaryPillMutedText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
emptyText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
checkRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
minHeight: 32,
},
checkName: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
flexShrink: 1,
},
checkWorkflow: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
checkTrailing: {
marginLeft: "auto",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
checkDuration: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
checkAddButton: {
paddingVertical: 0,
},

View File

@@ -11,3 +11,20 @@ export function prPaneTimelineQueryKey({
}) {
return [prPaneTimelineQueryKind, serverId, cwd, prNumber] as const;
}
export const prPanePipelineQueryKind = "prPanePipeline";
export function prPanePipelineQueryKey({
serverId,
cwd,
pipelineId,
changeRequestNumber,
}: {
serverId: string;
cwd: string;
pipelineId: number | null;
/** MR iid the pipeline is fetched by; part of the key since the fetch routes by it. */
changeRequestNumber: number;
}) {
return [prPanePipelineQueryKind, serverId, cwd, pipelineId, changeRequestNumber] as const;
}

View File

@@ -0,0 +1,176 @@
import React, { type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
ChevronDown,
ChevronRight,
CircleCheck,
CircleDot,
CircleSlash,
CircleX,
} from "lucide-react-native";
import type { Theme } from "@/styles/theme";
import type { CheckStatus } from "./check-status";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleSlash = withUnistyles(CircleSlash);
const ThemedCircleX = withUnistyles(CircleX);
export const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
export const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
export const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
export const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
export const SUMMARY_SUCCESS_ICON = <ThemedCircleCheck size={12} uniProps={successColorMapping} />;
export const SUMMARY_DANGER_ICON = <ThemedCircleX size={12} uniProps={dangerColorMapping} />;
export const SUMMARY_WARNING_ICON = <ThemedCircleDot size={12} uniProps={warningColorMapping} />;
interface SectionProps {
title: string;
open: boolean;
onToggle: () => void;
summary: ReactNode;
children: ReactNode;
}
export function Section({ title, open, onToggle, summary, children }: SectionProps) {
return (
<View>
<Pressable style={sectionKitStyles.sectionHeader} onPress={onToggle}>
{open ? (
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />
)}
<Text style={sectionKitStyles.sectionTitle}>{title}</Text>
<View style={sectionKitStyles.summaryWrap}>{summary}</View>
</Pressable>
{open ? <View style={sectionKitStyles.sectionBody}>{children}</View> : null}
</View>
);
}
export type SummaryPillVariant = "success" | "danger" | "warning" | "muted";
export function SummaryPill({
count,
icon,
variant,
testID,
}: {
count: number;
icon: ReactNode;
variant: SummaryPillVariant;
testID?: string;
}) {
if (count === 0) return null;
return (
<View style={sectionKitStyles.summaryPill} testID={testID}>
{icon}
<Text style={summaryPillTextStyle(variant)}>{count}</Text>
</View>
);
}
function summaryPillTextStyle(variant: SummaryPillVariant) {
if (variant === "success") return sectionKitStyles.summaryPillSuccessText;
if (variant === "danger") return sectionKitStyles.summaryPillDangerText;
if (variant === "warning") return sectionKitStyles.summaryPillWarningText;
return sectionKitStyles.summaryPillMutedText;
}
export function CheckStatusIcon({ status }: { status: CheckStatus }) {
if (status === "success") return <ThemedCircleCheck size={14} uniProps={successColorMapping} />;
if (status === "failure") return <ThemedCircleX size={14} uniProps={dangerColorMapping} />;
if (status === "pending") return <ThemedCircleDot size={14} uniProps={warningColorMapping} />;
return <ThemedCircleSlash size={14} uniProps={foregroundMutedColorMapping} />;
}
export const sectionKitStyles = StyleSheet.create((theme) => ({
sectionHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
sectionTitle: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
sectionBody: {
paddingBottom: theme.spacing[3],
},
summaryWrap: {
marginLeft: "auto",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
summaryPill: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
summaryPillSuccessText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusSuccess,
},
summaryPillDangerText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusDanger,
},
summaryPillWarningText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusWarning,
},
summaryPillMutedText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
emptyText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
checkRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
minHeight: 32,
},
checkName: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
flexShrink: 1,
},
checkWorkflow: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
checkTrailing: {
marginLeft: "auto",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
checkDuration: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
}));

View File

@@ -1,5 +1,15 @@
import { GitHubIcon } from "@/components/icons/github-icon";
import { getForgeIconComponent } from "@/git/forge-icon";
import { getForgePresentation, type Forge } from "@/git/forge";
export function PullRequestTabIcon({ size, color }: { size: number; color: string }) {
return <GitHubIcon size={size} color={color} />;
export function PullRequestTabIcon({
forge,
size,
color,
}: {
forge: Forge;
size: number;
color: string;
}) {
const Icon = getForgeIconComponent(getForgePresentation(forge).icon);
return <Icon size={size} color={color} />;
}

View File

@@ -45,12 +45,33 @@ describe("buildPrTimeline", () => {
kind: "thread",
id: "thread:PRRT_1",
location: { path: "src/a.ts", line: 12, threadId: "PRRT_1", isResolved: false },
isResolved: false,
comments: [root, reply],
},
{ kind: "single", id: "c2", activity: between },
]);
});
it("groups comments sharing a top-level threadId with no location into one thread", () => {
const root = activity({ id: "n1", threadId: "disc-1" });
const between = activity({ id: "c1" });
const reply = activity({ id: "n2", author: "bob", threadId: "disc-1" });
expect(buildPrTimeline([root, between, reply])).toEqual([
{ kind: "thread", id: "thread:disc-1", comments: [root, reply] },
{ kind: "single", id: "c1", activity: between },
]);
});
it("surfaces general-discussion resolution as thread isResolved without a location", () => {
const root = activity({ id: "g1", threadId: "disc-2", threadIsResolved: true });
const reply = activity({ id: "g2", author: "bob", threadId: "disc-2", threadIsResolved: true });
expect(buildPrTimeline([root, reply])).toEqual([
{ kind: "thread", id: "thread:disc-2", isResolved: true, comments: [root, reply] },
]);
});
it("keeps located comments without a threadId as single entries", () => {
const located = activity({ id: "c1", location: { path: "src/a.ts", line: 3 } });

View File

@@ -5,7 +5,14 @@ export type PrThreadLocation = NonNullable<PrPaneActivity["location"]>;
export interface PrThreadEntry {
kind: "thread";
id: string;
location: PrThreadLocation;
/** Absent for general (non-file) discussion threads that carry no position. */
location?: PrThreadLocation;
/**
* Thread resolution state, unified across file threads (`location.isResolved`)
* and general discussions (`threadIsResolved`). Absent when the forge exposes no
* resolution for the thread.
*/
isResolved?: boolean;
comments: PrPaneActivity[];
}
@@ -23,8 +30,10 @@ export type PrTimelineEntry =
/**
* Builds the GitHub-style nested timeline:
* - comments sharing a `location.threadId` collapse into one thread entry
* (root comment + replies), placed at the first comment's position;
* - comments sharing a thread id collapse into one thread entry (root comment +
* replies), placed at the first comment's position. The id is the top-level
* `threadId` (forge-neutral, used by GitLab general discussions) falling back
* to `location.threadId` (file-position threads);
* - threads whose root comment carries a `reviewId` nest under that review
* (GitHub's explicit signal: PullRequestReviewComment.pullRequestReview);
* - everything else stays a standalone entry in original order.
@@ -39,8 +48,8 @@ function groupThreads(activities: readonly PrPaneActivity[]): PrTimelineEntry[]
const threadsById = new Map<string, PrThreadEntry>();
for (const activity of activities) {
const threadId = activity.location?.threadId;
if (!activity.location || !threadId) {
const threadId = activity.threadId ?? activity.location?.threadId;
if (!threadId) {
entries.push({ kind: "single", id: activity.id, activity });
continue;
}
@@ -51,10 +60,12 @@ function groupThreads(activities: readonly PrPaneActivity[]): PrTimelineEntry[]
continue;
}
const resolved = activity.location?.isResolved ?? activity.threadIsResolved;
const thread: PrThreadEntry = {
kind: "thread",
id: `thread:${threadId}`,
location: activity.location,
...(activity.location ? { location: activity.location } : {}),
...(resolved !== undefined ? { isResolved: resolved } : {}),
comments: [activity],
};
threadsById.set(threadId, thread);

View File

@@ -38,6 +38,7 @@ const githubStatus: CheckoutPrStatus["github"] = {
function prStatus(overrides: Partial<CheckoutPrStatus> = {}): CheckoutPrStatus {
return {
forge: "github",
number: 42,
url: "https://github.com/getpaseo/paseo/pull/42",
title: "Wire real PR pane data",
@@ -377,6 +378,34 @@ describe("selectPrPaneState", () => {
expect(state.data?.activity).toEqual([]);
});
it("passes GitLab native facts through the selected pane data", () => {
const gitlabFacts = {
forge: "gitlab" as const,
detailedMergeStatus: "mergeable",
hasConflicts: false,
blockingDiscussionsResolved: true,
approvalsRequired: 2,
approvalsGiven: 1,
pipelineStatus: null,
pipelineId: null,
pipelineUrl: null,
mergeWhenPipelineSucceeds: false,
};
const state = selectPrPaneState({
...baseSelectInput,
forge: "gitlab",
status: prStatus({
forge: "gitlab",
github: undefined,
forgeSpecific: gitlabFacts,
}),
timelinePayload: timelinePayload(),
});
expect(state.data?.provider).toEqual({ id: "gitlab", label: "GitLab" });
expect(state.data?.forgeSpecific).toEqual({ ...gitlabFacts, mergeStatus: null });
});
it("returns null data when the consumer disabled timeline rendering", () => {
const state = selectPrPaneState({
...baseSelectInput,

View File

@@ -8,6 +8,7 @@ import type {
} from "@getpaseo/protocol/messages";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import type { Forge } from "@/git/forge";
import { i18n } from "@/i18n/i18next";
import { mapPrPaneData, type PrPaneData } from "./data";
import { prPaneTimelineQueryKey } from "./query-keys";
@@ -25,6 +26,7 @@ export interface UsePrPaneDataOptions {
export interface UsePrPaneDataResult {
data: PrPaneData | null;
forge: Forge;
prNumber: number | null;
isLoading: boolean;
activityLoading: boolean;
@@ -150,6 +152,7 @@ export interface SelectPrPaneStateInput {
statusIsLoading: boolean;
statusIsFetching: boolean;
githubFeaturesEnabled: boolean;
forge?: Forge;
timelineEnabled: boolean;
shouldFetchTimeline: boolean;
timelinePayload: PullRequestTimeline | undefined;
@@ -165,7 +168,7 @@ export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataR
const data =
identity.prNumber === null || !input.timelineEnabled
? null
: mapPrPaneData(input.status, input.timelinePayload);
: mapPrPaneData(input.status, input.timelinePayload, undefined, input.forge ?? "github");
const statusRefreshing = input.statusIsFetching && !input.statusIsLoading;
const timelineRefreshing = input.timelineIsFetching && !input.timelineIsLoading;
const timelinePending =
@@ -173,6 +176,7 @@ export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataR
return {
data,
forge: input.forge ?? "github",
prNumber: identity.prNumber,
isLoading: input.statusIsLoading || timelinePending,
activityLoading: timelinePending,
@@ -262,6 +266,7 @@ export function usePrPaneData({
statusIsLoading: checkoutPrStatus.isLoading,
statusIsFetching: checkoutPrStatus.isFetching,
githubFeaturesEnabled,
forge: checkoutPrStatus.forge,
timelineEnabled,
shouldFetchTimeline,
timelinePayload: timelineQuery.data,

View File

@@ -0,0 +1,87 @@
import { useMemo } from "react";
import type { CheckoutPipeline } from "@getpaseo/protocol/messages";
import { useFetchQuery } from "@/data/query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { prPanePipelineQueryKey } from "./query-keys";
/** Poll cadence for an in-progress pipeline; finished pipelines are immutable. */
const LIVE_PIPELINE_REFETCH_MS = 15_000;
const FINISHED_PIPELINE_STALE_MS = 24 * 60 * 60 * 1_000;
export interface UseGitLabPipelineOptions {
serverId: string;
cwd: string;
pipelineId: number | null;
/** MR iid, so the fetch resolves a fork/detached head pipeline correctly. */
changeRequestNumber: number;
enabled: boolean;
/** True while the pipeline is still running, so jobs keep refreshing. */
live: boolean;
}
export interface UseGitLabPipelineResult {
pipeline: CheckoutPipeline | null;
isLoading: boolean;
isFetching: boolean;
/**
* True while the query is serving the previous change request's data as a
* placeholder after the query key changed (keepPreviousData). Callers use it
* to avoid rendering a stale job breakdown for the newly selected MR.
*/
isPlaceholderData: boolean;
error: Error | null;
}
/**
* Fetches a GitLab pipeline through the existing forge-routed check-details RPC.
* The pipeline id is carried in checkRunId; GitLab resolves its project from cwd.
*/
export function useGitLabPipeline({
serverId,
cwd,
pipelineId,
changeRequestNumber,
enabled,
live,
}: UseGitLabPipelineOptions): UseGitLabPipelineResult {
const daemonClient = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const shouldFetch = enabled && !!daemonClient && isConnected && !!cwd && pipelineId !== null;
const query = useFetchQuery<CheckoutPipeline | null>({
queryKey: useMemo(
() => prPanePipelineQueryKey({ serverId, cwd, pipelineId, changeRequestNumber }),
[serverId, cwd, pipelineId, changeRequestNumber],
),
queryFn: async () => {
if (!daemonClient || pipelineId === null) {
return null;
}
const payload = await daemonClient.checkoutForgeGetCheckDetails({
cwd,
checkRunId: pipelineId,
changeRequestNumber,
});
// A failed fetch must surface as a query error so the section shows its
// error state; null is reserved for a successful response with no pipeline.
if (!payload.success) {
throw new Error(payload.error?.message ?? "Could not load pipeline jobs");
}
return payload.details?.pipeline ?? null;
},
enabled: shouldFetch,
dataShape: "list",
staleTimeMs: live ? 0 : FINISHED_PIPELINE_STALE_MS,
refetchInterval: live && shouldFetch ? LIVE_PIPELINE_REFETCH_MS : false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
return {
pipeline: query.data ?? null,
isLoading: shouldFetch && query.isLoading,
isFetching: query.isFetching,
isPlaceholderData: query.isPlaceholderData,
error: query.error,
};
}

View File

@@ -7,7 +7,10 @@ import {
invalidateCheckoutGitQueriesForClient,
invalidateCheckoutGitQueriesForServer,
} from "@/git/query-keys";
import { prPaneTimelineQueryKey } from "@/git/pull-request-panel/query-keys";
import {
prPanePipelineQueryKey,
prPaneTimelineQueryKey,
} from "@/git/pull-request-panel/query-keys";
describe("checkout query keys", () => {
const serverId = "server-1";
@@ -27,10 +30,25 @@ describe("checkout query keys", () => {
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 }), {
items: [],
});
queryClient.setQueryData(
prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }),
{
stages: [],
},
);
queryClient.setQueryData(
prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }),
{ items: [] },
);
queryClient.setQueryData(
prPanePipelineQueryKey({
serverId,
cwd: "/tmp/other",
pipelineId: 9001,
changeRequestNumber: 1,
}),
{ stages: [] },
);
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd });
@@ -52,11 +70,26 @@ describe("checkout query keys", () => {
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 }))
?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(
prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }),
)?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(
prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }),
)?.isInvalidated,
).toBe(false);
expect(
queryClient.getQueryState(
prPanePipelineQueryKey({
serverId,
cwd: "/tmp/other",
pipelineId: 9001,
changeRequestNumber: 1,
}),
)?.isInvalidated,
).toBe(false);
queryClient.clear();
});
@@ -72,6 +105,12 @@ describe("checkout query keys", () => {
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), {
items: [],
});
queryClient.setQueryData(
prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }),
{
stages: [],
},
);
// Subscription-fed diff queries are deliberately not part of the server-wide sweep.
queryClient.setQueryData(checkoutDiffQueryKey(serverId, cwd, "base", "main", true), {
files: [],
@@ -93,6 +132,11 @@ describe("checkout query keys", () => {
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }))
?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(
prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }),
)?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(checkoutDiffQueryKey(serverId, cwd, "base", "main", true))
?.isInvalidated,

View File

@@ -1,5 +1,5 @@
import type { Query, QueryClient } from "@tanstack/react-query";
import { prPaneTimelineQueryKind } from "./pull-request-panel/query-keys";
import { prPanePipelineQueryKind, prPaneTimelineQueryKind } from "./pull-request-panel/query-keys";
interface CheckoutQueryIdentity {
serverId: string;
@@ -65,6 +65,9 @@ export async function invalidateCheckoutGitQueriesForClient(
queryClient.invalidateQueries({
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
}),
queryClient.invalidateQueries({
predicate: checkoutQueryPredicate(prPanePipelineQueryKind, identity),
}),
]);
}
@@ -75,7 +78,12 @@ export async function invalidateCheckoutGitQueriesForServer(
queryClient: QueryClient,
serverId: string,
) {
const kinds = ["checkoutStatus", "checkoutPrStatus", prPaneTimelineQueryKind];
const kinds = [
"checkoutStatus",
"checkoutPrStatus",
prPaneTimelineQueryKind,
prPanePipelineQueryKind,
];
await Promise.all(
kinds.map((kind) =>
queryClient.invalidateQueries({ predicate: checkoutQueryPredicate(kind, { serverId }) }),
@@ -87,9 +95,14 @@ export async function invalidatePrPaneTimelineForCheckout(
queryClient: QueryClient,
identity: CheckoutQueryIdentity,
) {
await queryClient.invalidateQueries({
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
});
await Promise.all([
queryClient.invalidateQueries({
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
}),
queryClient.invalidateQueries({
predicate: checkoutQueryPredicate(prPanePipelineQueryKind, identity),
}),
]);
}
function checkoutQueryPredicate(

View File

@@ -1,6 +1,9 @@
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useTranslation } from "react-i18next";
import type { Theme } from "@/styles/theme";
import { getForgePresentation, type Forge } from "@/git/forge";
import { ForgeBrandIcon, getForgeBrandColorMapping } from "@/git/forge-icon";
import { type CheckoutGitActionStatus, useCheckoutGitActionsStore } from "@/git/actions-store";
import { type CheckoutStatusPayload, useCheckoutStatusQuery } from "@/git/use-status-query";
import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
@@ -11,6 +14,7 @@ import {
type GitAction,
type GitActions,
} from "@/git/policy";
import { deriveMergeCapability } from "@/git/merge-capability";
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context";
@@ -26,6 +30,29 @@ import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
const forgeMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/**
* The leading icon for every change-request action (create/view/merge) is the
* forge's brand mark, tinted in its brand color (the GitLab tanuki orange, etc).
* GitHub and unknown forges have no brand color and render in a neutral tone.
* The merge variants all share this one icon, so we build it once.
*/
function renderForgePrIcon(forge: Forge): ReactElement {
const icon = getForgePresentation(forge).icon;
return (
<ForgeBrandIcon
iconKind={icon}
size={16}
uniProps={getForgeBrandColorMapping(icon) ?? forgeMutedColorMapping}
/>
);
}
function forgeVocabulary(forge: Forge): { context: "mr" | undefined } {
return { context: getForgePresentation(forge).changeRequestContext };
}
function openURLInNewTab(url: string): void {
void openExternalUrl(url);
}
@@ -144,11 +171,6 @@ interface UseGitActionsInput {
pull: ReactElement;
push: ReactElement;
pullAndPush: ReactElement;
viewPr: ReactElement;
createPr: ReactElement;
mergePrSquash: ReactElement;
mergePrMerge: ReactElement;
mergePrRebase: ReactElement;
merge: ReactElement;
mergeFromBase: ReactElement;
archive: ReactElement;
@@ -281,11 +303,16 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
const { status: prStatus, githubFeaturesEnabled } = useCheckoutPrStatusQuery({
const {
status: prStatus,
githubFeaturesEnabled,
forge,
} = useCheckoutPrStatusQuery({
serverId,
cwd,
enabled: isGit,
});
const prIcon = useMemo(() => renderForgePrIcon(forge), [forge]);
const baseRefLabel = useMemo(
() => formatBaseRefLabel(baseRef, t("workspace.git.diff.base")),
[baseRef, t],
@@ -403,7 +430,9 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const runMergeBranch = useCheckoutGitActionsStore((s) => s.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase);
const githubAutoMergeActionsEnabled = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutGithubSetAutoMerge === true,
(s) =>
s.sessions[serverId]?.serverInfo?.features?.checkoutForgeSetAutoMerge === true ||
s.sessions[serverId]?.serverInfo?.features?.checkoutGithubSetAutoMerge === true,
);
const toastActionError = useCallback(
@@ -470,13 +499,22 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
void persistShipDefault("pr");
void runCreatePr({ serverId, cwd })
.then(() => {
toastActionSuccess(t("workspace.git.actions.createPr.success"));
toastActionSuccess(t("workspace.git.actions.createPr.success", forgeVocabulary(forge)));
return;
})
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedCreatePr"));
});
}, [cwd, persistShipDefault, runCreatePr, serverId, t, toastActionError, toastActionSuccess]);
}, [
cwd,
forge,
persistShipDefault,
runCreatePr,
serverId,
t,
toastActionError,
toastActionSuccess,
]);
const handleMergePr = useCallback(
(method: CheckoutPrMergeMethod) => {
@@ -484,14 +522,14 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
void runMergePr({ serverId, cwd, method })
.then(() => {
setPostShipArchiveSuggested(true);
toastActionSuccess(t("workspace.git.actions.mergePr.success"));
toastActionSuccess(t("workspace.git.actions.mergePr.success", forgeVocabulary(forge)));
return;
})
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedMergePr"));
});
},
[cwd, persistShipDefault, runMergePr, serverId, t, toastActionError, toastActionSuccess],
[cwd, forge, persistShipDefault, runMergePr, serverId, t, toastActionError, toastActionSuccess],
);
const handleEnablePrAutoMerge = useCallback(
@@ -615,10 +653,13 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
}, [prStatus?.url, handleCreatePr]);
// Build actions
const gitActionsInput = useMemo<BuildGitActionsInput>(
() => ({
const gitActionsInput = useMemo<BuildGitActionsInput>(() => {
const presentation = getForgePresentation(forge);
return {
isGit,
githubFeaturesEnabled,
forgeBrandLabel: presentation.brandLabel,
forgeChangeRequestNoun: presentation.changeRequestAbbrev,
githubAutoMergeActionsEnabled,
hasPullRequest,
pullRequestUrl: prStatus?.url ?? null,
@@ -626,7 +667,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
pullRequestIsDraft: prStatus?.isDraft ?? false,
pullRequestIsMerged: prStatus?.isMerged ?? false,
pullRequestMergeable: prStatus?.mergeable ?? "UNKNOWN",
pullRequestGithub: prStatus?.github ?? null,
mergeCapability: deriveMergeCapability(prStatus?.forgeSpecific, prStatus?.github),
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
@@ -667,49 +708,49 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
pr: {
disabled: isActionDisabled(actionsDisabled, prCreateStatus),
status: hasPullRequest ? "idle" : prCreateStatus,
icon: hasPullRequest ? icons.viewPr : icons.createPr,
icon: prIcon,
handler: handlePrAction,
},
"merge-pr-squash": {
disabled: isActionDisabled(actionsDisabled, mergePrStatuses.squash),
status: mergePrStatuses.squash,
icon: icons.mergePrSquash,
icon: prIcon,
handler: () => handleMergePr("squash"),
},
"merge-pr-merge": {
disabled: isActionDisabled(actionsDisabled, mergePrStatuses.merge),
status: mergePrStatuses.merge,
icon: icons.mergePrMerge,
icon: prIcon,
handler: () => handleMergePr("merge"),
},
"merge-pr-rebase": {
disabled: isActionDisabled(actionsDisabled, mergePrStatuses.rebase),
status: mergePrStatuses.rebase,
icon: icons.mergePrRebase,
icon: prIcon,
handler: () => handleMergePr("rebase"),
},
"enable-pr-auto-merge-squash": {
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.squash),
status: enablePrAutoMergeStatuses.squash,
icon: icons.mergePrSquash,
icon: prIcon,
handler: () => handleEnablePrAutoMerge("squash"),
},
"enable-pr-auto-merge-merge": {
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.merge),
status: enablePrAutoMergeStatuses.merge,
icon: icons.mergePrMerge,
icon: prIcon,
handler: () => handleEnablePrAutoMerge("merge"),
},
"enable-pr-auto-merge-rebase": {
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.rebase),
status: enablePrAutoMergeStatuses.rebase,
icon: icons.mergePrRebase,
icon: prIcon,
handler: () => handleEnablePrAutoMerge("rebase"),
},
"disable-pr-auto-merge": {
disabled: isActionDisabled(actionsDisabled, disablePrAutoMergeStatus),
status: disablePrAutoMergeStatus,
icon: icons.viewPr,
icon: prIcon,
handler: handleDisablePrAutoMerge,
},
"merge-branch": {
@@ -731,66 +772,73 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
handler: handleArchiveWorkspace,
},
},
}),
[
isGit,
hasRemote,
hasPullRequest,
prStatus?.url,
prStatus?.state,
prStatus?.isDraft,
prStatus?.isMerged,
prStatus?.mergeable,
prStatus?.github,
aheadCount,
behindBaseCount,
isPaseoOwnedWorktree,
isOnBaseBranch,
githubFeaturesEnabled,
githubAutoMergeActionsEnabled,
hasUncommittedChanges,
aheadOfOrigin,
behindOfOrigin,
shipDefault,
baseRefLabel,
shouldPromoteArchive,
actionsDisabled,
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergePrStatuses.squash,
mergePrStatuses.merge,
mergePrStatuses.rebase,
enablePrAutoMergeStatuses.squash,
enablePrAutoMergeStatuses.merge,
enablePrAutoMergeStatuses.rebase,
disablePrAutoMergeStatus,
mergeStatus,
mergeFromBaseStatus,
archiveController.canArchive,
archiveController.isArchiving,
handleCommit,
handlePull,
handlePush,
handlePullAndPush,
handlePrAction,
handleMergePr,
handleEnablePrAutoMerge,
handleDisablePrAutoMerge,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorkspace,
icons,
baseRef,
],
);
};
}, [
isGit,
hasRemote,
hasPullRequest,
prStatus?.url,
prStatus?.state,
prStatus?.isDraft,
prStatus?.isMerged,
prStatus?.mergeable,
prStatus?.forgeSpecific,
prStatus?.github,
aheadCount,
behindBaseCount,
isPaseoOwnedWorktree,
isOnBaseBranch,
githubFeaturesEnabled,
forge,
githubAutoMergeActionsEnabled,
hasUncommittedChanges,
aheadOfOrigin,
behindOfOrigin,
shipDefault,
baseRefLabel,
shouldPromoteArchive,
actionsDisabled,
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergePrStatuses.squash,
mergePrStatuses.merge,
mergePrStatuses.rebase,
enablePrAutoMergeStatuses.squash,
enablePrAutoMergeStatuses.merge,
enablePrAutoMergeStatuses.rebase,
disablePrAutoMergeStatus,
mergeStatus,
mergeFromBaseStatus,
archiveController.canArchive,
archiveController.isArchiving,
handleCommit,
handlePull,
handlePush,
handlePullAndPush,
handlePrAction,
handleMergePr,
handleEnablePrAutoMerge,
handleDisablePrAutoMerge,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorkspace,
icons,
prIcon,
baseRef,
]);
const gitActions: GitActions = useMemo(
() =>
translateGitActions(buildGitActions(gitActionsInput), { baseRefLabel, hasPullRequest, t }),
[gitActionsInput, baseRefLabel, hasPullRequest, t],
translateGitActions(buildGitActions(gitActionsInput), {
baseRefLabel,
hasPullRequest,
forge,
t,
}),
[gitActionsInput, baseRefLabel, hasPullRequest, forge, t],
);
return { gitActions, branchLabel, isGit };
@@ -801,6 +849,7 @@ function translateGitActions(
input: {
baseRefLabel: string;
hasPullRequest: boolean;
forge: Forge;
t: (key: string, options?: Record<string, unknown>) => string;
},
): GitActions {
@@ -816,14 +865,16 @@ function translateGitAction(
{
baseRefLabel,
hasPullRequest,
forge,
t,
}: {
baseRefLabel: string;
hasPullRequest: boolean;
forge: Forge;
t: (key: string, options?: Record<string, unknown>) => string;
},
): GitAction {
const labels = getTranslatedGitActionLabels(action, { baseRefLabel, hasPullRequest, t });
const labels = getTranslatedGitActionLabels(action, { baseRefLabel, hasPullRequest, forge, t });
return {
...action,
...labels,
@@ -839,10 +890,12 @@ function getTranslatedGitActionLabels(
{
baseRefLabel,
hasPullRequest,
forge,
t,
}: {
baseRefLabel: string;
hasPullRequest: boolean;
forge: Forge;
t: (key: string, options?: Record<string, unknown>) => string;
},
): Pick<GitAction, "label" | "pendingLabel" | "successLabel"> {
@@ -874,32 +927,32 @@ function getTranslatedGitActionLabels(
case "pr":
return hasPullRequest
? {
label: t("workspace.git.actions.viewPr"),
pendingLabel: t("workspace.git.actions.viewPr"),
successLabel: t("workspace.git.actions.viewPr"),
label: t("workspace.git.actions.viewPr", forgeVocabulary(forge)),
pendingLabel: t("workspace.git.actions.viewPr", forgeVocabulary(forge)),
successLabel: t("workspace.git.actions.viewPr", forgeVocabulary(forge)),
}
: {
label: t("workspace.git.actions.createPr.label"),
pendingLabel: t("workspace.git.actions.createPr.pending"),
successLabel: t("workspace.git.actions.createPr.success"),
label: t("workspace.git.actions.createPr.label", forgeVocabulary(forge)),
pendingLabel: t("workspace.git.actions.createPr.pending", forgeVocabulary(forge)),
successLabel: t("workspace.git.actions.createPr.success", forgeVocabulary(forge)),
};
case "merge-pr-squash":
return {
label: t("workspace.git.actions.mergePr.squash"),
pendingLabel: t("workspace.git.actions.mergePr.pending"),
successLabel: t("workspace.git.actions.mergePr.success"),
label: t("workspace.git.actions.mergePr.squash", forgeVocabulary(forge)),
pendingLabel: t("workspace.git.actions.mergePr.pending", forgeVocabulary(forge)),
successLabel: t("workspace.git.actions.mergePr.success", forgeVocabulary(forge)),
};
case "merge-pr-merge":
return {
label: t("workspace.git.actions.mergePr.merge"),
pendingLabel: t("workspace.git.actions.mergePr.pending"),
successLabel: t("workspace.git.actions.mergePr.success"),
label: t("workspace.git.actions.mergePr.merge", forgeVocabulary(forge)),
pendingLabel: t("workspace.git.actions.mergePr.pending", forgeVocabulary(forge)),
successLabel: t("workspace.git.actions.mergePr.success", forgeVocabulary(forge)),
};
case "merge-pr-rebase":
return {
label: t("workspace.git.actions.mergePr.rebase"),
pendingLabel: t("workspace.git.actions.mergePr.pending"),
successLabel: t("workspace.git.actions.mergePr.success"),
label: t("workspace.git.actions.mergePr.rebase", forgeVocabulary(forge)),
pendingLabel: t("workspace.git.actions.mergePr.pending", forgeVocabulary(forge)),
successLabel: t("workspace.git.actions.mergePr.success", forgeVocabulary(forge)),
};
case "enable-pr-auto-merge-squash":
return {
@@ -958,8 +1011,6 @@ function translateGitActionUnavailableMessage(
): string | undefined {
if (!message) return undefined;
const keyByMessage: Record<string, string> = {
"View PR isn't available right now because GitHub isn't connected":
"workspace.git.actions.unavailable.viewPrNoGithub",
"Pull isn't available here because this branch is not connected to a remote yet":
"workspace.git.actions.unavailable.pullNoRemote",
"Pull isn't available while you have local changes so commit or stash them first":
@@ -978,8 +1029,6 @@ function translateGitActionUnavailableMessage(
"workspace.git.actions.unavailable.pullAndPushDirty",
"Pull and push isn't available because this branch is already in sync":
"workspace.git.actions.unavailable.pullAndPushInSync",
"Create PR isn't available right now because GitHub isn't connected":
"workspace.git.actions.unavailable.createPrNoGithub",
"Create PR isn't available because this branch doesn't have any new commits yet":
"workspace.git.actions.unavailable.createPrNoCommits",
"Merge isn't available because we couldn't determine the base branch":
@@ -994,6 +1043,8 @@ function translateGitActionUnavailableMessage(
"workspace.git.actions.unavailable.updateDirty",
"Merge PR isn't available right now because GitHub isn't connected":
"workspace.git.actions.unavailable.mergePrNoGithub",
"Archive isn't available here because this workspace was not created as a Paseo worktree":
"workspace.git.actions.unavailable.archiveNotWorktree",
"Merge PR isn't available because there isn't a pull request yet":
"workspace.git.actions.unavailable.mergePrMissing",
"Merge PR isn't available because the pull request is still a draft":
@@ -1006,8 +1057,6 @@ function translateGitActionUnavailableMessage(
"workspace.git.actions.unavailable.mergePrConflicts",
"Merge PR isn't available here because this repository uses a merge queue":
"workspace.git.actions.unavailable.mergePrQueue",
"Merge PR isn't available until GitHub reports the pull request is ready to merge":
"workspace.git.actions.unavailable.mergePrNotReady",
"Auto-merge is enabled, but this account can't disable it":
"workspace.git.actions.unavailable.autoMergeCannotDisable",
};

View File

@@ -0,0 +1,282 @@
import { describe, expect, it } from "vitest";
import { buildForgeSearchQueryOptions, forgeSearchQueryKey } from "./use-forge-search-query";
describe("forgeSearchQueryKey", () => {
it("keeps the shared cache key shape for no-kinds searches", () => {
expect(forgeSearchQueryKey("server-1", "/repo", " 123 ")).toEqual([
"forge-search",
"server-1",
"/repo",
"forge",
"123",
]);
});
it("adds a deterministic kinds key when kinds are specified", () => {
expect(forgeSearchQueryKey("server-1", "/repo", "123", ["change_request", "issue"])).toEqual([
"forge-search",
"server-1",
"/repo",
"forge",
"123",
"change_request,issue",
]);
});
it("separates legacy GitHub fallback results from forge search results", () => {
expect(forgeSearchQueryKey("server-1", "/repo", "123", undefined, "github")).toEqual([
"forge-search",
"server-1",
"/repo",
"github",
"123",
]);
});
});
describe("buildForgeSearchQueryOptions", () => {
it("forwards kinds to the forge search request when specified", async () => {
const requests: unknown[] = [];
const query = buildForgeSearchQueryOptions({
client: {
async searchForge(options) {
requests.push(options);
return {
items: [],
authState: "authenticated",
error: null,
requestId: "request-1",
};
},
},
serverId: "server-1",
cwd: "/repo",
query: " 123 ",
kinds: ["change_request"],
enabled: true,
});
await query.queryFn();
expect(requests).toEqual([
{ cwd: "/repo", query: "123", limit: 20, kinds: ["change_request"] },
]);
});
it("uses the legacy GitHub search request when forge search is unsupported", async () => {
const forgeRequests: unknown[] = [];
const githubRequests: unknown[] = [];
const query = buildForgeSearchQueryOptions({
client: {
async searchForge(options) {
forgeRequests.push(options);
return {
items: [],
authState: "authenticated",
error: null,
requestId: "forge-request",
};
},
async searchGitHub(options) {
githubRequests.push(options);
return {
items: [],
featuresEnabled: true,
authState: "authenticated",
githubFeaturesEnabled: true,
error: null,
requestId: "github-request",
};
},
},
serverId: "server-1",
cwd: "/repo",
query: " 456 ",
kinds: ["issue"],
enabled: true,
supportsForgeSearch: false,
});
await query.queryFn();
expect(forgeRequests).toEqual([]);
expect(githubRequests).toEqual([
{ cwd: "/repo", query: "456", limit: 20, kinds: ["github-issue"] },
]);
});
it("normalizes legacy GitHub PR items into neutral change-request items", async () => {
const query = buildForgeSearchQueryOptions({
client: {
async searchForge() {
throw new Error("unexpected forge search");
},
async searchGitHub() {
return {
items: [
{
kind: "pr" as const,
number: 17,
title: "Fix search",
url: "https://github.com/acme/repo/pull/17",
state: "open",
body: null,
labels: ["bug"],
baseRefName: "main",
headRefName: "fix-search",
},
],
featuresEnabled: true,
authState: "authenticated" as const,
githubFeaturesEnabled: true,
error: null,
requestId: "github-request",
};
},
},
serverId: "server-1",
cwd: "/repo",
query: " 456 ",
enabled: true,
supportsForgeSearch: false,
});
const result = await query.queryFn();
expect(result.items).toEqual([
{
kind: "change_request",
number: 17,
title: "Fix search",
url: "https://github.com/acme/repo/pull/17",
state: "open",
body: null,
labels: ["bug"],
baseRefName: "main",
headRefName: "fix-search",
},
]);
});
it("interprets modern search payloads at the query boundary", async () => {
const query = buildForgeSearchQueryOptions({
client: {
async searchForge() {
return {
items: [
{
kind: "issue",
number: 23,
title: "Keep this",
url: "https://gitlab.com/acme/repo/-/issues/23",
state: "open",
body: null,
labels: [],
},
{ kind: "future_kind", futureField: true },
],
authState: "future_auth_state",
error: null,
requestId: "forge-request",
};
},
},
serverId: "server-1",
cwd: "/repo",
query: "23",
enabled: true,
supportsForgeSearch: true,
});
const result = await query.queryFn();
expect(result.items).toHaveLength(1);
expect(result.items[0]?.kind).toBe("issue");
expect(result.authState).toBe("unauthenticated");
});
it("derives legacy search auth from legacy feature flags", async () => {
const query = buildForgeSearchQueryOptions({
client: {
async searchForge() {
throw new Error("unexpected forge search");
},
async searchGitHub() {
return {
items: [],
githubFeaturesEnabled: false,
error: null,
requestId: "github-request",
};
},
},
serverId: "server-1",
cwd: "/repo",
query: "23",
enabled: true,
supportsForgeSearch: false,
});
expect((await query.queryFn()).authState).toBe("unauthenticated");
});
it("invokes forge search bound to the client so this-dependent methods work", async () => {
const client = new ThisDependentSearchClient();
const query = buildForgeSearchQueryOptions({
client,
serverId: "server-1",
cwd: "/repo",
query: " 789 ",
enabled: true,
supportsForgeSearch: true,
});
const result = await query.queryFn();
expect(result.requestId).toBe("forge.search.request");
expect(client.requests).toEqual([{ cwd: "/repo", query: "789", limit: 20 }]);
});
it("invokes the legacy GitHub search bound to the client", async () => {
const client = new ThisDependentSearchClient();
const query = buildForgeSearchQueryOptions({
client,
serverId: "server-1",
cwd: "/repo",
query: " 789 ",
enabled: true,
supportsForgeSearch: false,
});
const result = await query.queryFn();
expect(result.requestId).toBe("github_search_request");
expect(client.requests).toEqual([{ cwd: "/repo", query: "789", limit: 20 }]);
});
});
class ThisDependentSearchClient {
readonly requests: unknown[] = [];
private send(requestId: string, options: { cwd: string; query: string; limit?: number }) {
this.requests.push(options);
return Promise.resolve({
items: [],
featuresEnabled: true,
authState: "authenticated" as const,
githubFeaturesEnabled: true,
error: null,
requestId,
});
}
async searchForge(options: { cwd: string; query: string; limit?: number }) {
return this.send("forge.search.request", options);
}
async searchGitHub(options: { cwd: string; query: string; limit?: number }) {
return this.send("github_search_request", options);
}
}

View File

@@ -0,0 +1,172 @@
import { useTranslation } from "react-i18next";
import {
ForgeSearchItemSchema,
GitHubSearchItemSchema,
type ForgeAuthState,
type ForgeSearchItem,
ForgeSearchKind,
type ForgeSearchResponse,
type GitHubSearchResponse,
} from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { useFetchQuery } from "@/data/query";
import { parseForgeAuthState } from "@/git/forge";
export const FORGE_SEARCH_STALE_TIME = 30_000;
export interface ForgeSearchPayload {
items: ForgeSearchItem[];
authState: ForgeAuthState;
error: string | null;
requestId: string;
}
interface ForgeSearchOptions {
cwd: string;
query: string;
limit?: number;
kinds?: ForgeSearchKind[];
}
interface LegacyGitHubSearchOptions {
cwd: string;
query: string;
limit?: number;
kinds?: LegacyGitHubSearchKind[];
}
export interface ForgeSearchClient {
searchForge: (
options: ForgeSearchOptions,
requestId?: string,
) => Promise<ForgeSearchResponse["payload"]>;
searchGitHub?: (
options: LegacyGitHubSearchOptions,
requestId?: string,
) => Promise<GitHubSearchResponse["payload"]>;
}
type LegacyGitHubSearchKind = "github-issue" | "github-pr";
interface ForgeSearchQueryInput {
client: ForgeSearchClient | null;
serverId: string;
cwd: string;
query: string;
kinds?: ForgeSearchKind[];
enabled: boolean;
supportsForgeSearch?: boolean;
hostDisconnectedMessage?: string;
}
export function forgeSearchQueryKey(
serverId: string,
cwd: string,
query: string,
kinds?: ForgeSearchKind[],
transport: "forge" | "github" = "forge",
) {
const trimmedQuery = query.trim();
if (!kinds) {
return ["forge-search", serverId, cwd, transport, trimmedQuery] as const;
}
return [
"forge-search",
serverId,
cwd,
transport,
trimmedQuery,
[...kinds].sort().join(","),
] as const;
}
export function buildForgeSearchQueryOptions(input: ForgeSearchQueryInput) {
const query = input.query.trim();
const transport = input.supportsForgeSearch === true ? "forge" : "github";
return {
queryKey: forgeSearchQueryKey(input.serverId, input.cwd, query, input.kinds, transport),
queryFn: async (): Promise<ForgeSearchPayload> => {
if (!input.client) {
throw new Error(
input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"),
);
}
const request = input.kinds
? { cwd: input.cwd, query, limit: 20, kinds: input.kinds }
: { cwd: input.cwd, query, limit: 20 };
// COMPAT(githubSearchRpc): added in v0.1.106, remove after 2026-12-28 once
// clients use forge.search.*.
if (transport === "github" && input.client.searchGitHub) {
return normalizeLegacyGitHubSearchPayload(
await input.client.searchGitHub(toLegacyGitHubSearchRequest(request)),
);
}
return normalizeForgeSearchPayload(await input.client.searchForge(request));
},
enabled: input.enabled && Boolean(input.client),
dataShape: "list" as const,
staleTimeMs: FORGE_SEARCH_STALE_TIME,
};
}
function normalizeForgeSearchPayload(payload: ForgeSearchResponse["payload"]): ForgeSearchPayload {
return {
items: payload.items.flatMap((item) => {
const result = ForgeSearchItemSchema.safeParse(item);
return result.success ? [result.data] : [];
}),
authState: parseForgeAuthState(payload.authState) ?? "unauthenticated",
error: payload.error,
requestId: payload.requestId,
};
}
function normalizeLegacyGitHubSearchPayload(
payload: GitHubSearchResponse["payload"],
): ForgeSearchPayload {
// COMPAT(githubSearchAuthState): added in v0.1.106, remove after 2026-12-28.
const featuresEnabled = payload.featuresEnabled ?? payload.githubFeaturesEnabled ?? true;
return {
items: payload.items.flatMap((item) => {
const result = GitHubSearchItemSchema.safeParse(item);
if (!result.success) {
return [];
}
return [
result.data.kind === "pr"
? { ...result.data, kind: "change_request" as const }
: { ...result.data, kind: "issue" as const },
];
}),
authState:
parseForgeAuthState(payload.authState) ??
(featuresEnabled ? "authenticated" : "unauthenticated"),
error: payload.error,
requestId: payload.requestId,
};
}
function toLegacyGitHubSearchKind(kind: ForgeSearchKind): LegacyGitHubSearchKind {
return kind === "change_request" ? "github-pr" : "github-issue";
}
function toLegacyGitHubSearchRequest(request: ForgeSearchOptions): LegacyGitHubSearchOptions {
if (!request.kinds) {
return { cwd: request.cwd, query: request.query, limit: request.limit };
}
return {
...request,
kinds: request.kinds.map(toLegacyGitHubSearchKind),
};
}
export function useForgeSearchQuery(input: ForgeSearchQueryInput) {
const { t } = useTranslation();
return useFetchQuery(
buildForgeSearchQueryOptions({
...input,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
);
}

View File

@@ -1,42 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildGithubSearchQueryOptions, githubSearchQueryKey } from "./use-github-search-query";
describe("githubSearchQueryKey", () => {
it("keeps the shared cache key shape for no-kinds searches", () => {
expect(githubSearchQueryKey("server-1", "/repo", " 123 ")).toEqual([
"github-search",
"server-1",
"/repo",
"123",
]);
});
it("adds a deterministic kinds key when kinds are specified", () => {
expect(githubSearchQueryKey("server-1", "/repo", "123", ["github-pr", "github-issue"])).toEqual(
["github-search", "server-1", "/repo", "123", "github-issue,github-pr"],
);
});
});
describe("buildGithubSearchQueryOptions", () => {
it("forwards kinds to the GitHub search request when specified", async () => {
const requests: unknown[] = [];
const query = buildGithubSearchQueryOptions({
client: {
async searchGitHub(options) {
requests.push(options);
return { items: [], githubFeaturesEnabled: true, error: null, requestId: "request-1" };
},
},
serverId: "server-1",
cwd: "/repo",
query: " 123 ",
kinds: ["github-pr"],
enabled: true,
});
await query.queryFn();
expect(requests).toEqual([{ cwd: "/repo", query: "123", limit: 20, kinds: ["github-pr"] }]);
});
});

View File

@@ -1,75 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { GitHubSearchRequest, GitHubSearchResponse } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
export const GITHUB_SEARCH_STALE_TIME = 30_000;
export type GitHubSearchPayload = GitHubSearchResponse["payload"];
export interface GitHubSearchClient {
searchGitHub: (
options: {
cwd: string;
query: string;
limit?: number;
kinds?: GitHubSearchRequest["kinds"];
},
requestId?: string,
) => Promise<GitHubSearchPayload>;
}
interface GitHubSearchQueryInput {
client: GitHubSearchClient | null;
serverId: string;
cwd: string;
query: string;
kinds?: GitHubSearchRequest["kinds"];
enabled: boolean;
hostDisconnectedMessage?: string;
}
export function githubSearchQueryKey(
serverId: string,
cwd: string,
query: string,
kinds?: GitHubSearchRequest["kinds"],
) {
const trimmedQuery = query.trim();
if (!kinds) {
return ["github-search", serverId, cwd, trimmedQuery] as const;
}
return ["github-search", serverId, cwd, trimmedQuery, [...kinds].sort().join(",")] as const;
}
export function buildGithubSearchQueryOptions(input: GitHubSearchQueryInput) {
const query = input.query.trim();
return {
queryKey: githubSearchQueryKey(input.serverId, input.cwd, query, input.kinds),
queryFn: async (): Promise<GitHubSearchPayload> => {
if (!input.client) {
throw new Error(
input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"),
);
}
const request = { cwd: input.cwd, query, limit: 20 };
if (input.kinds) {
return input.client.searchGitHub({ ...request, kinds: input.kinds });
}
return input.client.searchGitHub(request);
},
enabled: input.enabled && Boolean(input.client),
staleTime: GITHUB_SEARCH_STALE_TIME,
};
}
export function useGithubSearchQuery(input: GitHubSearchQueryInput) {
const { t } = useTranslation();
return useQuery(
buildGithubSearchQueryOptions({
...input,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
);
}

View File

@@ -1,9 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
import { checkoutPrStatusQueryKey } from "@/git/query-keys";
import { normalizeForge } from "@/git/forge";
import { selectPrHintFromStatus, type PrHint } from "@/git/pr-hint";
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
interface UseCheckoutPrStatusQueryOptions {
serverId: string;
@@ -11,11 +12,11 @@ interface UseCheckoutPrStatusQueryOptions {
enabled?: boolean;
}
export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
export { selectPrHintFromStatus, type PrHint } from "@/git/pr-hint";
function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null {
return selectPrHintFromStatus(payload.status);
return selectPrHintFromStatus(payload.status, payload.forge);
}
export function useCheckoutPrStatusQuery({
@@ -33,7 +34,7 @@ export function useCheckoutPrStatusQuery({
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return await client.checkoutPrStatus(cwd);
return normalizeCheckoutPrStatusPayload(await client.checkoutPrStatus(cwd));
},
enabled: !!client && isConnected && !!cwd && enabled,
staleTime: Infinity,
@@ -47,6 +48,11 @@ export function useCheckoutPrStatusQuery({
return {
status: query.data?.status ?? null,
githubFeaturesEnabled: query.data?.githubFeaturesEnabled ?? true,
authState: query.data?.authState,
forge: normalizeForge(query.data?.forge),
// Null until a response arrives, so callers that can infer the forge from
// the remote URL (e.g. web-URL grammar) don't act on the github default.
resolvedForge: query.data === undefined ? null : normalizeForge(query.data.forge),
payloadError: query.data?.error ?? null,
isLoading: query.isLoading,
isFetching: query.isFetching,
@@ -70,7 +76,7 @@ export function useWorkspacePrHint({
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return await client.checkoutPrStatus(cwd);
return normalizeCheckoutPrStatusPayload(await client.checkoutPrStatus(cwd));
},
enabled: !!client && isConnected && !!cwd && enabled,
staleTime: Infinity,

View File

@@ -8,7 +8,6 @@ import {
RefreshCcw,
Upload,
} from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import { GitActionsSplitButton } from "@/git/actions-split-button";
import { useGitActions } from "@/git/use-actions";
import type { Theme } from "@/styles/theme";
@@ -23,7 +22,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);
@@ -37,11 +35,6 @@ const ICONS = {
pull: <ThemedDownload size={16} uniProps={mutedColorMapping} />,
push: <ThemedUpload size={16} uniProps={mutedColorMapping} />,
pullAndPush: <ThemedArrowDownUp size={16} uniProps={mutedColorMapping} />,
viewPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
createPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
mergePrSquash: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
mergePrMerge: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
mergePrRebase: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
merge: <ThemedGitMerge size={16} uniProps={mutedColorMapping} />,
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={mutedColorMapping} />,
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,

View File

@@ -8,11 +8,62 @@ import {
buildSidebarWorkspacePlacementModel,
buildSidebarProjectsFromStructure,
computeSidebarOrderUpdates,
createSidebarWorkspaceEntry,
deriveSidebarLoadingState,
shouldShowSidebarHostLabels,
type SidebarProjectEntry,
} from "./sidebar-workspaces-view-model";
function workspaceWithForge(forge: string | undefined, prUrl: string): WorkspaceDescriptor {
return {
id: "ws-1",
projectId: "proj",
projectDisplayName: "repo",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "worktree",
name: "feature",
title: null,
status: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
forge,
githubRuntime: {
featuresEnabled: true,
pullRequest: {
url: prUrl,
title: "Change",
state: "open",
baseRefName: "main",
headRefName: "feature",
isMerged: false,
},
error: null,
},
};
}
describe("createSidebarWorkspaceEntry forge threading", () => {
it("threads a gitlab summary forge onto the prHint", () => {
const entry = createSidebarWorkspaceEntry({
serverId: "srv",
workspace: workspaceWithForge("gitlab", "https://gitlab.com/group/proj/-/merge_requests/7"),
});
expect(entry.prHint).toMatchObject({ number: 7, forge: "gitlab" });
});
it("falls back to github when the summary omits forge (old daemon)", () => {
const entry = createSidebarWorkspaceEntry({
serverId: "srv",
workspace: workspaceWithForge(undefined, "https://github.com/acme/repo/pull/42"),
});
expect(entry.prHint).toMatchObject({ number: 42, forge: "github" });
});
});
interface OrderedItem {
key: string;
}

View File

@@ -164,7 +164,10 @@ export function createSidebarWorkspaceEntry(input: {
statusEnteredAt: effectiveStatus.enteredAt,
archivingAt: input.workspace.archivingAt,
diffStat: input.workspace.diffStat,
prHint: selectPrHintFromStatus(input.workspace.githubRuntime?.pullRequest),
prHint: selectPrHintFromStatus(
input.workspace.githubRuntime?.pullRequest,
input.workspace.forge,
),
archiveHasUncommittedChanges: input.workspace.gitRuntime?.isDirty ?? null,
archiveUnpushedCommitCount: input.workspace.gitRuntime?.aheadOfOrigin ?? null,
scripts: input.workspace.scripts,

View File

@@ -194,7 +194,10 @@ describe("translation resources", () => {
expect(en.composer.input.sendMessage).toBe("Send message");
expect(en.composer.voice.startDictation).toBe("Start dictation");
expect(en.composer.attachments.addIssueOrPr).toBe("Add issue or PR");
expect(en.composer.attachments.addIssueOrPr_mr).toBe("Add issue or MR");
expect(en.composer.github.title).toBe("Attach issue or PR");
expect(en.composer.github.title_mr).toBe("Attach issue or MR");
expect(en.composer.github.searchPlaceholder_mr).toBe("Search issues and MRs...");
expect(en.agentControls.provider.fallback).toBe("Provider");
expect(en.agentControls.hints.model).toBe("Change model");
expect(en.agentControls.hints.mode).toBe("Change mode");
@@ -258,7 +261,14 @@ describe("translation resources", () => {
expect(en.workspace.git.actions.commit.label).toBe("Commit");
expect(en.workspace.git.diff.binaryFile).toBe("Binary file");
expect(en.workspace.git.pr.sections.checks).toBe("Checks");
expect(en.workspace.git.pr.sections.pipeline).toBe("Pipeline");
expect(en.workspace.git.pr.actions.viewPullRequest).toBe("View");
expect(en.workspace.git.pr.actions.openOn).toBe("Open on {{brand}}");
expect(en.workspace.git.pr.empty.noJobs).toBe("No jobs");
expect(en.workspace.git.pr.empty.loadingPipeline).toBe("Loading pipeline…");
expect(en.workspace.git.pr.empty.pipelineJobsLoadFailed).toBe("Could not load pipeline jobs");
expect(en.workspace.git.pr.empty.allowedToFail).toBe("allowed to fail");
expect(en.workspace.git.pr.approvals).toBe("{{given}} of {{required}} approvals");
expect(en.review.comment.placeholder).toBe("Leave a comment");
});

View File

@@ -102,6 +102,7 @@ export const ar: TranslationResources = {
addImage: "أضف صورة",
addFile: "Upload file",
addIssueOrPr: "أضف مشكلة أو PR",
addIssueOrPr_mr: "أضف مشكلة أو MR",
dropImagesHere: "إسقاط الصور هنا",
dropFilesHere: "Drop files here",
editQueuedMessage: "تحرير الرسالة في قائمة الانتظار",
@@ -109,8 +110,8 @@ export const ar: TranslationResources = {
openImage: "فتح مرفق الصورة",
removeImage: "إزالة مرفق الصورة",
removeFile: "Remove file attachment",
openGithub: "افتح{{kind}}#{{number}}",
removeGithub: "إزالة{{kind}}#{{number}}",
openGithub: "افتح {{kind}} {{number}}",
removeGithub: "إزالة {{kind}} {{number}}",
element: "عنصر",
openBrowserElement: "افتح مرفق عنصر المتصفح",
removeBrowserElement: "إزالة مرفق عنصر المتصفح",
@@ -134,7 +135,9 @@ export const ar: TranslationResources = {
searching: "جارٍ البحث...",
noResults: "لم يتم العثور على نتائج.",
searchPlaceholder: "بحث القضايا والعلاقات العامة...",
searchPlaceholder_mr: "بحث القضايا و MR...",
title: "إرفاق المشكلة أو PR",
title_mr: "إرفاق المشكلة أو MR",
},
},
agentControls: {
@@ -618,6 +621,9 @@ export const ar: TranslationResources = {
label: "إنشاء PR",
pending: "إنشاء PR...",
success: "تم إنشاء PR",
label_mr: "إنشاء MR",
pending_mr: "إنشاء MR...",
success_mr: "تم إنشاء MR",
},
mergeBranch: {
label: "دمج محليا",
@@ -640,7 +646,13 @@ export const ar: TranslationResources = {
rebase: "دمج PR (rebase)",
pending: "دمج PR...",
success: "تم دمج PR",
squash_mr: "دمج MR (squash)",
merge_mr: "دمج MR (merge)",
rebase_mr: "دمج MR (rebase)",
pending_mr: "دمج MR...",
success_mr: "تم دمج MR",
},
viewPr_mr: "عرض MR",
autoMerge: {
enableSquash: "دمج تلقائي (squash)",
enableMerge: "دمج تلقائي (merge)",
@@ -651,7 +663,7 @@ export const ar: TranslationResources = {
disabled: "تم تعطيل الدمج التلقائي",
},
unavailable: {
viewPrNoGithub: "عرض PR غير متاح الآن لأن GitHub غير متصل",
viewPrNoForge: "عرض {{noun}} غير متاح الآن لأن {{brand}} غير متصل",
pullNoRemote: "السحب غير متاح هنا لأن هذا الفرع غير متصل بجهاز التحكم عن بعد بعد",
pullDirty: "السحب غير متاح أثناء وجود تغييرات محلية، لذا قم بتنفيذها أو تخزينها أولاً",
pullUpToDate: "السحب غير متاح لأن هذا الفرع محدث بالفعل",
@@ -662,8 +674,10 @@ export const ar: TranslationResources = {
"السحب والدفع غير متاح هنا لأن هذا الفرع غير متصل بجهاز التحكم عن بعد بعد",
pullAndPushDirty:
"لا يتوفر السحب والدفع أثناء وجود تغييرات محلية، لذا قم بتنفيذها أو تخزينها أولاً",
pullAndPushNoIncoming: "السحب والدفع غير متاحين لأنه لا توجد تغييرات واردة للسحب أولاً",
pullAndPushInSync: "السحب والدفع غير متاح لأن هذا الفرع متزامن بالفعل",
createPrNoGithub: "إنشاء PR غير متاح حاليًا لأن GitHub غير متصل",
pullAndPushNothingToPush: "السحب والدفع غير متاحين لأنه لا يوجد شيء جديد لإرساله بعد السحب",
createPrNoForge: "إنشاء {{noun}} غير متاح حاليًا لأن {{brand}} غير متصل",
createPrNoCommits: "إنشاء PR غير متاح لأن هذا الفرع ليس لديه أي التزامات جديدة حتى الآن",
mergeNoBase: "الدمج غير متاح لأننا لم نتمكن من تحديد الفرع الأساسي",
mergeDirty:
@@ -673,13 +687,16 @@ export const ar: TranslationResources = {
updateDirty: "التحديث غير متاح أثناء وجود تغييرات محلية، لذا قم بتنفيذها أو تخزينها أولاً",
updateCurrent: "التحديث غير متاح لأن هذا الفرع محدث بالفعل باستخدام{{baseRef}}",
mergePrNoGithub: "دمج PR غير متاح الآن لأن GitHub غير متصل",
archiveNotWorktree:
"الأرشيف غير متاح هنا لأنه لم يتم إنشاء مساحة العمل هذه كشجرة عمل Paseo",
mergePrNoForge: "دمج {{noun}} غير متاح الآن لأن {{brand}} غير متصل",
mergePrMissing: "دمج PR غير متاح لأنه لا يوجد طلب سحب حتى الآن",
mergePrDraft: "دمج PR غير متاح لأن طلب السحب لا يزال مسودة",
mergePrMerged: "دمج PR غير متاح لأن طلب السحب مدمج بالفعل",
mergePrClosed: "دمج PR غير متاح لأن طلب السحب مغلق",
mergePrConflicts: "دمج PR غير متاح لأن طلب السحب به تعارضات",
mergePrQueue: "دمج PR غير متاح هنا لأن هذا المستودع يستخدم قائمة انتظار دمج",
mergePrNotReady: "دمج PR غير متاح حتى يبلغ GitHub أن طلب السحب جاهز للدمج",
mergePrNotReady: "دمج {{noun}} غير متاح حتى يبلغ {{brand}} أن {{noun}} جاهز للدمج",
autoMergeCannotDisable: "تم تمكين الدمج التلقائي، ولكن لا يمكن لهذا الحساب تعطيله",
},
toasts: {
@@ -729,7 +746,7 @@ export const ar: TranslationResources = {
expandAllFolders: "توسيع كافة المجلدات",
refreshing: "منعش",
refresh: "ينعش",
refreshState: "تحديث بوابة وحالة GitHub",
refreshState: "تحديث حالة git و{{brand}}",
failedRefresh: "فشل تحديث حالة git.",
emptyHiddenWhitespace: "لا توجد تغييرات مرئية بعد إخفاء المسافة البيضاء",
emptyUncommitted: "لا توجد تغييرات غير ملتزم بها",
@@ -765,13 +782,23 @@ export const ar: TranslationResources = {
pr: {
actions: {
viewPullRequest: "عرض",
openOn: "فتح على {{brand}}",
},
sections: {
checks: "الشيكات",
pipeline: "خط المعالجة",
reviews: "التعليقات",
},
empty: {
noJobs: "لا توجد مهام",
loadingPipeline: "جارٍ تحميل خط المعالجة...",
pipelineJobsLoadFailed: "تعذر تحميل مهام خط المعالجة",
allowedToFail: "مسموح بالفشل",
},
approvals: "{{given}} من {{required}} موافقات",
accessibility: {
pullRequest: "سحب الطلب #{{number}}",
pullRequest_mr: "طلب دمج !{{number}}",
},
states: {
draft: "مسودة",
@@ -788,11 +815,19 @@ export const ar: TranslationResources = {
time: {
justNow: "الآن",
},
thread: {
discussion: "سلسلة المناقشة",
},
errors: {
statusLoadFailed: "غير قادر على تحميل حالة طلب السحب",
activityLoadFailed: "غير قادر على تحميل نشاط طلب السحب",
},
},
forgeSetup: {
installCli: "ثبّت واجهة سطر الأوامر {{cli}} لاستخدام ميزات {{brand}}.",
signIn: "نفّذ {{command}} لاستخدام ميزات {{brand}}.",
generic: "قم بإعداد {{brand}} على هذا المضيف لاستخدام ميزاته.",
},
},
},
sidebar: {
@@ -924,10 +959,10 @@ export const ar: TranslationResources = {
refPicker: {
startingRef: "بدء المرجع",
chooseStart: "اختر من أين تبدأ",
checkoutHint: "تحقق من PR#{{number}}؟",
checkoutPr: "تحقق من PR#{{number}}",
dismissCheckoutHint: "تجاهل تلميح الخروج PR#{{number}}",
intoBase: "إلى{{baseRef}}",
checkoutHint: "تحقق من {{noun}} {{numberPrefix}}{{number}}؟",
checkoutPr: "تحقق من {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint: "تجاهل تلميح الخروج {{noun}} {{numberPrefix}}{{number}}",
intoBase: "إلى {{baseRef}}",
searching: "جارٍ البحث...",
noMatchingRefs: "لا توجد مراجع مطابقة.",
searchPlaceholder: "بحث الفروع والعلاقات العامة",

View File

@@ -100,6 +100,7 @@ export const en = {
addImage: "Add image",
addFile: "Upload file",
addIssueOrPr: "Add issue or PR",
addIssueOrPr_mr: "Add issue or MR",
dropImagesHere: "Drop images here",
dropFilesHere: "Drop files here",
editQueuedMessage: "Edit queued message",
@@ -107,8 +108,8 @@ export const en = {
openImage: "Open image attachment",
removeImage: "Remove image attachment",
removeFile: "Remove file attachment",
openGithub: "Open {{kind}} #{{number}}",
removeGithub: "Remove {{kind}} #{{number}}",
openGithub: "Open {{kind}} {{number}}",
removeGithub: "Remove {{kind}} {{number}}",
element: "Element",
openBrowserElement: "Open browser element attachment",
removeBrowserElement: "Remove browser element attachment",
@@ -132,7 +133,9 @@ export const en = {
searching: "Searching...",
noResults: "No results found.",
searchPlaceholder: "Search issues and PRs...",
searchPlaceholder_mr: "Search issues and MRs...",
title: "Attach issue or PR",
title_mr: "Attach issue or MR",
},
},
agentControls: {
@@ -612,10 +615,14 @@ export const en = {
success: "Pulled and pushed",
},
viewPr: "View PR",
viewPr_mr: "View MR",
createPr: {
label: "Create PR",
pending: "Creating PR...",
success: "PR Created",
label_mr: "Create MR",
pending_mr: "Creating MR...",
success_mr: "MR Created",
},
mergeBranch: {
label: "Merge locally",
@@ -638,6 +645,11 @@ export const en = {
rebase: "Merge PR (rebase)",
pending: "Merging PR...",
success: "PR merged",
squash_mr: "Merge MR (squash)",
merge_mr: "Merge MR (merge)",
rebase_mr: "Merge MR (rebase)",
pending_mr: "Merging MR...",
success_mr: "MR merged",
},
autoMerge: {
enableSquash: "Auto merge (squash)",
@@ -649,7 +661,8 @@ export const en = {
disabled: "Auto-merge disabled",
},
unavailable: {
viewPrNoGithub: "View PR isn't available right now because GitHub isn't connected",
viewPrNoForge:
"View {{noun}} isn't available right now because {{brand}} isn't connected",
pullNoRemote:
"Pull isn't available here because this branch is not connected to a remote yet",
pullDirty:
@@ -663,8 +676,13 @@ export const en = {
"Pull and push isn't available here because this branch is not connected to a remote yet",
pullAndPushDirty:
"Pull and push isn't available while you have local changes so commit or stash them first",
pullAndPushNoIncoming:
"Pull and push isn't available because there are no incoming changes to pull first",
pullAndPushInSync: "Pull and push isn't available because this branch is already in sync",
createPrNoGithub: "Create PR isn't available right now because GitHub isn't connected",
pullAndPushNothingToPush:
"Pull and push isn't available because there is nothing new to send after pulling",
createPrNoForge:
"Create {{noun}} isn't available right now because {{brand}} isn't connected",
createPrNoCommits:
"Create PR isn't available because this branch doesn't have any new commits yet",
mergeNoBase: "Merge isn't available because we couldn't determine the base branch",
@@ -678,6 +696,10 @@ export const en = {
updateCurrent:
"Update isn't available because this branch is already up to date with {{baseRef}}",
mergePrNoGithub: "Merge PR isn't available right now because GitHub isn't connected",
archiveNotWorktree:
"Archive isn't available here because this workspace was not created as a Paseo worktree",
mergePrNoForge:
"Merge {{noun}} isn't available right now because {{brand}} isn't connected",
mergePrMissing: "Merge PR isn't available because there isn't a pull request yet",
mergePrDraft: "Merge PR isn't available because the pull request is still a draft",
mergePrMerged: "Merge PR isn't available because the pull request is already merged",
@@ -685,7 +707,7 @@ export const en = {
mergePrConflicts: "Merge PR isn't available because the pull request has conflicts",
mergePrQueue: "Merge PR isn't available here because this repository uses a merge queue",
mergePrNotReady:
"Merge PR isn't available until GitHub reports the pull request is ready to merge",
"Merge {{noun}} isn't available until {{brand}} reports the {{noun}} is ready to merge",
autoMergeCannotDisable: "Auto-merge is enabled, but this account can't disable it",
},
toasts: {
@@ -735,7 +757,7 @@ export const en = {
expandAllFolders: "Expand all folders",
refreshing: "Refreshing",
refresh: "Refresh",
refreshState: "Refresh git and GitHub state",
refreshState: "Refresh git and {{brand}} state",
failedRefresh: "Failed to refresh git state.",
emptyHiddenWhitespace: "No visible changes after hiding whitespace",
emptyUncommitted: "No uncommitted changes",
@@ -771,13 +793,23 @@ export const en = {
pr: {
actions: {
viewPullRequest: "View",
openOn: "Open on {{brand}}",
},
sections: {
checks: "Checks",
pipeline: "Pipeline",
reviews: "Reviews",
},
empty: {
noJobs: "No jobs",
loadingPipeline: "Loading pipeline…",
pipelineJobsLoadFailed: "Could not load pipeline jobs",
allowedToFail: "allowed to fail",
},
approvals: "{{given}} of {{required}} approvals",
accessibility: {
pullRequest: "Pull request #{{number}}",
pullRequest_mr: "Merge request !{{number}}",
},
states: {
draft: "Draft",
@@ -794,11 +826,19 @@ export const en = {
time: {
justNow: "just now",
},
thread: {
discussion: "Discussion thread",
},
errors: {
statusLoadFailed: "Unable to load pull request status",
activityLoadFailed: "Unable to load pull request activity",
},
},
forgeSetup: {
installCli: "Install the {{cli}} CLI to use {{brand}} features.",
signIn: "Run {{command}} to use {{brand}} features.",
generic: "Set up {{brand}} on this host to use its features.",
},
},
},
sidebar: {
@@ -930,9 +970,9 @@ export const en = {
refPicker: {
startingRef: "Starting ref",
chooseStart: "Choose where to start from",
checkoutHint: "Check out PR #{{number}}?",
checkoutPr: "Check out PR #{{number}}",
dismissCheckoutHint: "Dismiss PR #{{number}} checkout hint",
checkoutHint: "Check out {{noun}} {{numberPrefix}}{{number}}?",
checkoutPr: "Check out {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint: "Dismiss {{noun}} {{numberPrefix}}{{number}} checkout hint",
intoBase: "into {{baseRef}}",
searching: "Searching...",
noMatchingRefs: "No matching refs.",

View File

@@ -102,6 +102,7 @@ export const es: TranslationResources = {
addImage: "Agregar imagen",
addFile: "Upload file",
addIssueOrPr: "Agregar problema oPR",
addIssueOrPr_mr: "Agregar problema o MR",
dropImagesHere: "Suelta imágenes aquí",
dropFilesHere: "Drop files here",
editQueuedMessage: "Editar mensaje en cola",
@@ -109,8 +110,8 @@ export const es: TranslationResources = {
openImage: "Abrir imagen adjunta",
removeImage: "Quitar imagen adjunta",
removeFile: "Remove file attachment",
openGithub: "Abrir{{kind}}#{{number}}",
removeGithub: "Quitar{{kind}}#{{number}}",
openGithub: "Abrir {{kind}} {{number}}",
removeGithub: "Quitar {{kind}} {{number}}",
element: "Elemento",
openBrowserElement: "Abrir archivo adjunto de elemento del navegador",
removeBrowserElement: "Eliminar el archivo adjunto del elemento del navegador",
@@ -134,7 +135,9 @@ export const es: TranslationResources = {
searching: "Búsqueda...",
noResults: "No se encontraron resultados.",
searchPlaceholder: "Problemas de búsqueda y relaciones públicas...",
searchPlaceholder_mr: "Problemas de búsqueda y MRs...",
title: "Adjuntar problema oPR",
title_mr: "Adjuntar problema o MR",
},
},
agentControls: {
@@ -624,6 +627,9 @@ export const es: TranslationResources = {
label: "CrearPR",
pending: "CreandoPR...",
success: "PRcreado",
label_mr: "Crear MR",
pending_mr: "Creando MR...",
success_mr: "MR creado",
},
mergeBranch: {
label: "Fusionar localmente",
@@ -646,7 +652,13 @@ export const es: TranslationResources = {
rebase: "Fusionar PR (rebase)",
pending: "Fusionando PR...",
success: "PR fusionado",
squash_mr: "Fusionar MR (squash)",
merge_mr: "Fusionar MR (merge)",
rebase_mr: "Fusionar MR (rebase)",
pending_mr: "Fusionando MR...",
success_mr: "MR fusionado",
},
viewPr_mr: "Ver MR",
autoMerge: {
enableSquash: "Fusión automática (squash)",
enableMerge: "Fusión automática (merge)",
@@ -657,7 +669,8 @@ export const es: TranslationResources = {
disabled: "Fusión automática deshabilitada",
},
unavailable: {
viewPrNoGithub: "VerPRno está disponible en este momento porqueGitHubno está conectado",
viewPrNoForge:
"Ver {{noun}} no está disponible en este momento porque {{brand}} no está conectado",
pullNoRemote:
"Pull no está disponible aquí porque esta rama aún no está conectada a un control remoto",
pullDirty:
@@ -672,10 +685,14 @@ export const es: TranslationResources = {
"Tirar y empujar no está disponible aquí porque esta rama aún no está conectada a un control remoto",
pullAndPushDirty:
"Tirar y empujar no está disponible mientras tenga cambios locales, así que confírmelos o guárdelos primero",
pullAndPushNoIncoming:
"Pull and push no está disponible porque no hay cambios entrantes que traer primero",
pullAndPushInSync:
"Pull and push no está disponible porque esta rama ya está sincronizada",
createPrNoGithub:
"CrearPRno está disponible en este momento porqueGitHubno está conectado",
pullAndPushNothingToPush:
"Pull and push no está disponible porque no hay nada nuevo que enviar después de pull",
createPrNoForge:
"Crear {{noun}} no está disponible en este momento porque {{brand}} no está conectado",
createPrNoCommits:
"CrearPRno está disponible porque esta rama aún no tiene nuevas confirmaciones",
mergeNoBase:
@@ -692,6 +709,10 @@ export const es: TranslationResources = {
"La actualización no está disponible porque esta rama ya está actualizada con{{baseRef}}",
mergePrNoGithub:
"FusionarPRno está disponible en este momento porqueGitHubno está conectado",
archiveNotWorktree:
"El archivo no está disponible aquí porque este espacio de trabajo no se creó como un árbol de trabajoPaseo",
mergePrNoForge:
"Fusionar {{noun}} no está disponible en este momento porque {{brand}} no está conectado",
mergePrMissing:
"FusionarPRno está disponible porque aún no hay una solicitud de extracción",
mergePrDraft:
@@ -705,7 +726,7 @@ export const es: TranslationResources = {
mergePrQueue:
"FusionarPRno está disponible aquí porque este repositorio utiliza una cola de fusión",
mergePrNotReady:
"FusionarPRno está disponible hasta queGitHubinforme que la solicitud de extracción está lista para fusionarse",
"Fusionar {{noun}} no está disponible hasta que {{brand}} informe que {{noun}} está listo para fusionarse",
autoMergeCannotDisable:
"La combinación automática está habilitada, pero esta cuenta no puede deshabilitarla",
},
@@ -756,7 +777,7 @@ export const es: TranslationResources = {
expandAllFolders: "Expandir todas las carpetas",
refreshing: "Refrescante",
refresh: "Refrescar",
refreshState: "Actualizar el estado de git yGitHub",
refreshState: "Actualizar el estado de git y {{brand}}",
failedRefresh: "No se pudo actualizar el estado de git.",
emptyHiddenWhitespace: "No hay cambios visibles después de ocultar espacios en blanco",
emptyUncommitted: "Sin cambios no confirmados",
@@ -792,13 +813,23 @@ export const es: TranslationResources = {
pr: {
actions: {
viewPullRequest: "Ver",
openOn: "Abrir en {{brand}}",
},
sections: {
checks: "cheques",
pipeline: "Pipeline",
reviews: "Reseñas",
},
empty: {
noJobs: "Sin trabajos",
loadingPipeline: "Cargando pipeline...",
pipelineJobsLoadFailed: "No se pudieron cargar los trabajos del pipeline",
allowedToFail: "permitido fallar",
},
approvals: "{{given}} de {{required}} aprobaciones",
accessibility: {
pullRequest: "Solicitud de extracción n.°{{number}}",
pullRequest_mr: "Solicitud de fusión !{{number}}",
},
states: {
draft: "Borrador",
@@ -815,11 +846,19 @@ export const es: TranslationResources = {
time: {
justNow: "En este momento",
},
thread: {
discussion: "Hilo de discusión",
},
errors: {
statusLoadFailed: "No se puede cargar el estado de la solicitud de extracción",
activityLoadFailed: "No se puede cargar la actividad de solicitud de extracción",
},
},
forgeSetup: {
installCli: "Instala la CLI de {{cli}} para usar las funciones de {{brand}}.",
signIn: "Ejecuta {{command}} para usar las funciones de {{brand}}.",
generic: "Configura {{brand}} en este host para usar sus funciones.",
},
},
},
sidebar: {
@@ -951,10 +990,10 @@ export const es: TranslationResources = {
refPicker: {
startingRef: "Árbitro inicial",
chooseStart: "Elige por dónde empezar",
checkoutHint: "¿MiraPR#{{number}}?",
checkoutPr: "Echa un vistazo aPR#{{number}}",
dismissCheckoutHint: "Descartar la sugerencia de pago dePR#{{number}}",
intoBase: "en{{baseRef}}",
checkoutHint: "¿Mira {{noun}} {{numberPrefix}}{{number}}?",
checkoutPr: "Echa un vistazo a {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint: "Descartar la sugerencia de pago de {{noun}} {{numberPrefix}}{{number}}",
intoBase: "en {{baseRef}}",
searching: "Búsqueda...",
noMatchingRefs: "No hay árbitros coincidentes.",
searchPlaceholder: "Buscar sucursales y relaciones públicas",

View File

@@ -103,6 +103,7 @@ export const fr: TranslationResources = {
addImage: "Ajouter une image",
addFile: "Upload file",
addIssueOrPr: "Ajouter un problème ouPR",
addIssueOrPr_mr: "Ajouter un problème ou MR",
dropImagesHere: "Déposez des images ici",
dropFilesHere: "Drop files here",
editQueuedMessage: "Modifier le message en file d'attente",
@@ -110,8 +111,8 @@ export const fr: TranslationResources = {
openImage: "Ouvrir la pièce jointe de l'image",
removeImage: "Supprimer l'image jointe",
removeFile: "Remove file attachment",
openGithub: "Ouvrir{{kind}}#{{number}}",
removeGithub: "Supprimer{{kind}}#{{number}}",
openGithub: "Ouvrir {{kind}} {{number}}",
removeGithub: "Supprimer {{kind}} {{number}}",
element: "Élément",
openBrowserElement: "Ouvrir la pièce jointe de l'élément de navigateur",
removeBrowserElement: "Supprimer la pièce jointe d'un élément de navigateur",
@@ -135,7 +136,9 @@ export const fr: TranslationResources = {
searching: "Recherche...",
noResults: "Aucun résultat trouvé.",
searchPlaceholder: "Problèmes de recherche et PR...",
searchPlaceholder_mr: "Problèmes de recherche et MR...",
title: "Joindre le problème ouPR",
title_mr: "Joindre le problème ou MR",
},
},
agentControls: {
@@ -623,6 +626,9 @@ export const fr: TranslationResources = {
label: "CréerPR",
pending: "Création dePR...",
success: "PRcréé",
label_mr: "Créer MR",
pending_mr: "Création de MR...",
success_mr: "MR créé",
},
mergeBranch: {
label: "Fusionner localement",
@@ -645,7 +651,13 @@ export const fr: TranslationResources = {
rebase: "Fusionner PR (rebase)",
pending: "Fusion de PR...",
success: "PR fusionné",
squash_mr: "Fusionner MR (squash)",
merge_mr: "Fusionner MR (merge)",
rebase_mr: "Fusionner MR (rebase)",
pending_mr: "Fusion de MR...",
success_mr: "MR fusionné",
},
viewPr_mr: "Voir MR",
autoMerge: {
enableSquash: "Fusion automatique (squash)",
enableMerge: "Fusion automatique (merge)",
@@ -656,7 +668,8 @@ export const fr: TranslationResources = {
disabled: "Fusion automatique désactivée",
},
unavailable: {
viewPrNoGithub: "ViewPRn'est pas disponible pour le moment carGitHubn'est pas connecté",
viewPrNoForge:
"Voir {{noun}} n'est pas disponible pour le moment car {{brand}} n'est pas connecté",
pullNoRemote:
"Pull n'est pas disponible ici car cette branche n'est pas encore connectée à une télécommande",
pullDirty:
@@ -671,10 +684,14 @@ export const fr: TranslationResources = {
"Le pull et le push ne sont pas disponibles ici car cette branche n'est pas encore connectée à une télécommande",
pullAndPushDirty:
"Pull et push ne sont pas disponibles tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord",
pullAndPushNoIncoming:
"Pull et push ne sont pas disponibles car il n'y a aucun changement entrant à récupérer d'abord",
pullAndPushInSync:
"Les fonctions Pull et Push ne sont pas disponibles car cette branche est déjà synchronisée",
createPrNoGithub:
"CréerPRn'est pas disponible pour le moment carGitHubn'est pas connecté",
pullAndPushNothingToPush:
"Pull et push ne sont pas disponibles car il n'y a rien de nouveau à envoyer après le pull",
createPrNoForge:
"Créer {{noun}} n'est pas disponible pour le moment car {{brand}} n'est pas connecté",
createPrNoCommits:
"CréerPRn'est pas disponible car cette branche n'a pas encore de nouveaux commits",
mergeNoBase:
@@ -691,6 +708,10 @@ export const fr: TranslationResources = {
"La mise à jour n'est pas disponible car cette branche est déjà à jour avec{{baseRef}}",
mergePrNoGithub:
"La fusionPRn'est pas disponible pour le moment carGitHubn'est pas connecté",
archiveNotWorktree:
"L'archive n'est pas disponible ici car cet espace de travail n'a pas été créé en tant qu'arbre de travailPaseo",
mergePrNoForge:
"La fusion {{noun}} n'est pas disponible pour le moment car {{brand}} n'est pas connecté",
mergePrMissing:
"La fusionPRn'est pas disponible car il n'y a pas encore de demande d'extraction",
mergePrDraft:
@@ -703,7 +724,7 @@ export const fr: TranslationResources = {
mergePrQueue:
"MergePRn'est pas disponible ici car ce référentiel utilise une file d'attente de fusion",
mergePrNotReady:
"La fusionPRn'est pas disponible jusqu'à ce queGitHubsignale que la demande d'extraction est prête à fusionner",
"La fusion {{noun}} n'est pas disponible jusqu'à ce que {{brand}} signale que {{noun}} est prête à fusionner",
autoMergeCannotDisable:
"La fusion automatique est activée, mais ce compte ne peut pas la désactiver",
},
@@ -754,7 +775,7 @@ export const fr: TranslationResources = {
expandAllFolders: "Développer tous les dossiers",
refreshing: "Rafraîchissant",
refresh: "Rafraîchir",
refreshState: "Actualiser l'état de git etGitHub",
refreshState: "Actualiser l'état de git et de {{brand}}",
failedRefresh: "Échec de l'actualisation de l'état git.",
emptyHiddenWhitespace: "Aucun changement visible après avoir masqué les espaces",
emptyUncommitted: "Aucune modification non validée",
@@ -790,13 +811,23 @@ export const fr: TranslationResources = {
pr: {
actions: {
viewPullRequest: "Voir",
openOn: "Ouvrir sur {{brand}}",
},
sections: {
checks: "Chèques",
pipeline: "Pipeline",
reviews: "Avis",
},
empty: {
noJobs: "Aucune tâche",
loadingPipeline: "Chargement du pipeline...",
pipelineJobsLoadFailed: "Impossible de charger les tâches du pipeline",
allowedToFail: "autorisé à échouer",
},
approvals: "{{given}} sur {{required}} approbations",
accessibility: {
pullRequest: "Demande de tirage #{{number}}",
pullRequest_mr: "Demande de fusion !{{number}}",
},
states: {
draft: "Brouillon",
@@ -813,11 +844,19 @@ export const fr: TranslationResources = {
time: {
justNow: "tout à l' heure",
},
thread: {
discussion: "Fil de discussion",
},
errors: {
statusLoadFailed: "Impossible de charger le statut de la demande d'extraction",
activityLoadFailed: "Impossible de charger l'activité de demande d'extraction",
},
},
forgeSetup: {
installCli: "Installez la CLI {{cli}} pour utiliser les fonctionnalités {{brand}}.",
signIn: "Exécutez {{command}} pour utiliser les fonctionnalités {{brand}}.",
generic: "Configurez {{brand}} sur cet hôte pour utiliser ses fonctionnalités.",
},
},
},
sidebar: {
@@ -949,10 +988,10 @@ export const fr: TranslationResources = {
refPicker: {
startingRef: "Réf de départ",
chooseStart: "Choisissez par où commencer",
checkoutHint: "DécouvrezPR#{{number}}?",
checkoutPr: "DécouvrezPR#{{number}}",
dismissCheckoutHint: "Ignorer l'indice de paiementPR#{{number}}",
intoBase: "dans{{baseRef}}",
checkoutHint: "Découvrez {{noun}} {{numberPrefix}}{{number}} ?",
checkoutPr: "Découvrez {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint: "Ignorer l'indice de paiement {{noun}} {{numberPrefix}}{{number}}",
intoBase: "dans {{baseRef}}",
searching: "Recherche...",
noMatchingRefs: "Aucune référence correspondante.",
searchPlaceholder: "Rechercher des succursales et des PR",

View File

@@ -102,6 +102,7 @@ export const ja: TranslationResources = {
addImage: "画像を追加",
addFile: "ファイルをアップロード",
addIssueOrPr: "イシューまたはPRを追加",
addIssueOrPr_mr: "イシューまたはMRを追加",
dropImagesHere: "ここに画像をドロップ",
dropFilesHere: "ここにファイルをドロップ",
editQueuedMessage: "キューに入れたメッセージを編集",
@@ -109,8 +110,8 @@ export const ja: TranslationResources = {
openImage: "画像添付ファイルを開く",
removeImage: "画像添付ファイルを削除",
removeFile: "ファイル添付ファイルを削除",
openGithub: "{{kind}} #{{number}}を開く",
removeGithub: "{{kind}} #{{number}}を削除",
openGithub: "{{kind}} {{number}}を開く",
removeGithub: "{{kind}} {{number}}を削除",
element: "要素",
openBrowserElement: "ブラウザ要素の添付ファイルを開く",
removeBrowserElement: "ブラウザ要素の添付ファイルを削除",
@@ -134,7 +135,9 @@ export const ja: TranslationResources = {
searching: "検索中...",
noResults: "結果が見つかりません。",
searchPlaceholder: "イシューとPRを検索...",
searchPlaceholder_mr: "イシューとMRを検索...",
title: "イシューまたはPRを添付",
title_mr: "イシューまたはMRを添付",
},
},
agentControls: {
@@ -622,6 +625,9 @@ export const ja: TranslationResources = {
label: "PRを作成",
pending: "PRを作成中...",
success: "PRが作成されました",
label_mr: "MRを作成",
pending_mr: "MRを作成中...",
success_mr: "MRが作成されました",
},
mergeBranch: {
label: "ローカルでマージ",
@@ -644,7 +650,13 @@ export const ja: TranslationResources = {
rebase: "PRをマージリベース",
pending: "PRをマージ中...",
success: "PRがマージされました",
squash_mr: "MRをマージスカッシュ",
merge_mr: "MRをマージマージ",
rebase_mr: "MRをマージリベース",
pending_mr: "MRをマージ中...",
success_mr: "MRがマージされました",
},
viewPr_mr: "MRを表示",
autoMerge: {
enableSquash: "自動マージ(スカッシュ)",
enableMerge: "自動マージ(マージ)",
@@ -655,7 +667,7 @@ export const ja: TranslationResources = {
disabled: "自動マージが無効になりました",
},
unavailable: {
viewPrNoGithub: "GitHubが接続されていないため、PRの表示は現在利用できません",
viewPrNoForge: "{{brand}}が接続されていないため、{{noun}}の表示は現在利用できません",
pullNoRemote:
"このブランチはまだリモートに接続されていないため、プルはここでは利用できません",
pullDirty:
@@ -669,9 +681,12 @@ export const ja: TranslationResources = {
"このブランチはまだリモートに接続されていないため、プル&プッシュはここでは利用できません",
pullAndPushDirty:
"ローカルに変更があるためプル&プッシュは利用できません。先にコミットまたはスタッシュしてください",
pullAndPushNoIncoming: "先にプルする受信変更がないため、プル&プッシュは利用できません",
pullAndPushInSync:
"このブランチはすでに同期されているため、プル&プッシュは利用できません",
createPrNoGithub: "GitHubが接続されていないため、PRの作成は現在利用できません",
pullAndPushNothingToPush:
"プル後に送信する新しい変更がないため、プル&プッシュは利用できません",
createPrNoForge: "{{brand}}が接続されていないため、{{noun}}の作成は現在利用できません",
createPrNoCommits: "このブランチにまだ新しいコミットがないため、PRの作成は利用できません",
mergeNoBase: "ベースブランチを特定できなかったため、マージは利用できません",
mergeDirty:
@@ -682,6 +697,9 @@ export const ja: TranslationResources = {
"ローカルに変更があるため更新は利用できません。先にコミットまたはスタッシュしてください",
updateCurrent: "このブランチはすでに{{baseRef}}と最新の状態のため、更新は利用できません",
mergePrNoGithub: "GitHubが接続されていないため、PRのマージは現在利用できません",
archiveNotWorktree:
"このワークスペースはPaseoワークツリーとして作成されていないため、アーカイブはここでは利用できません",
mergePrNoForge: "{{brand}}が接続されていないため、{{noun}}のマージは現在利用できません",
mergePrMissing: "プルリクエストがまだないため、PRのマージは利用できません",
mergePrDraft: "プルリクエストがまだドラフトのため、PRのマージは利用できません",
mergePrMerged: "プルリクエストはすでにマージされているため、PRのマージは利用できません",
@@ -690,7 +708,7 @@ export const ja: TranslationResources = {
mergePrQueue:
"このリポジトリはマージキューを使用しているため、PRのマージはここでは利用できません",
mergePrNotReady:
"GitHub上でプルリクエストがマージ可能になるまで、PRのマージは利用できません",
"{{brand}}上で{{noun}}がマージ可能になるまで、{{noun}}のマージは利用できません",
autoMergeCannotDisable:
"自動マージは有効になっていますが、このアカウントでは無効にできません",
},
@@ -741,7 +759,7 @@ export const ja: TranslationResources = {
expandAllFolders: "すべてのフォルダを展開",
refreshing: "更新中",
refresh: "更新",
refreshState: "gitとGitHubの状態を更新",
refreshState: "gitと{{brand}}の状態を更新",
failedRefresh: "gitの状態の更新に失敗しました。",
emptyHiddenWhitespace: "空白を非表示にすると変更は表示されません",
emptyUncommitted: "未コミットの変更なし",
@@ -777,13 +795,23 @@ export const ja: TranslationResources = {
pr: {
actions: {
viewPullRequest: "表示",
openOn: "{{brand}}で開く",
},
sections: {
checks: "チェック",
pipeline: "パイプライン",
reviews: "レビュー",
},
empty: {
noJobs: "ジョブなし",
loadingPipeline: "パイプラインを読み込み中...",
pipelineJobsLoadFailed: "パイプラインのジョブを読み込めませんでした",
allowedToFail: "失敗を許可",
},
approvals: "{{given}} / {{required}} 承認",
accessibility: {
pullRequest: "プルリクエスト#{{number}}",
pullRequest_mr: "マージリクエスト !{{number}}",
},
states: {
draft: "ドラフト",
@@ -800,11 +828,19 @@ export const ja: TranslationResources = {
time: {
justNow: "たった今",
},
thread: {
discussion: "ディスカッションスレッド",
},
errors: {
statusLoadFailed: "プルリクエストのステータスを読み込めません",
activityLoadFailed: "プルリクエストのアクティビティを読み込めません",
},
},
forgeSetup: {
installCli: "{{brand}} の機能を使うには {{cli}} CLI をインストールしてください。",
signIn: "{{brand}} の機能を使うには {{command}} を実行してください。",
generic: "このホストで {{brand}} をセットアップすると、その機能を使えます。",
},
},
},
sidebar: {
@@ -936,9 +972,9 @@ export const ja: TranslationResources = {
refPicker: {
startingRef: "開始Ref",
chooseStart: "開始点を選択",
checkoutHint: "PR #{{number}}をチェックアウトしますか?",
checkoutPr: "PR #{{number}}をチェックアウト",
dismissCheckoutHint: "PR #{{number}}のチェックアウトヒントを閉じる",
checkoutHint: "{{noun}} {{numberPrefix}}{{number}}をチェックアウトしますか?",
checkoutPr: "{{noun}} {{numberPrefix}}{{number}}をチェックアウト",
dismissCheckoutHint: "{{noun}} {{numberPrefix}}{{number}}のチェックアウトヒントを閉じる",
intoBase: "{{baseRef}}に",
searching: "検索中...",
noMatchingRefs: "一致するRefがありません。",

View File

@@ -102,6 +102,7 @@ export const ptBR: TranslationResources = {
addImage: "Adicionar imagem",
addFile: "Enviar arquivo",
addIssueOrPr: "Adicionar issue ou PR",
addIssueOrPr_mr: "Adicionar issue ou MR",
dropImagesHere: "Solte imagens aqui",
dropFilesHere: "Solte arquivos aqui",
editQueuedMessage: "Editar mensagem na fila",
@@ -109,8 +110,8 @@ export const ptBR: TranslationResources = {
openImage: "Abrir anexo de imagem",
removeImage: "Remover anexo de imagem",
removeFile: "Remover anexo de arquivo",
openGithub: "Abrir {{kind}} #{{number}}",
removeGithub: "Remover {{kind}} #{{number}}",
openGithub: "Abrir {{kind}} {{number}}",
removeGithub: "Remover {{kind}} {{number}}",
element: "Elemento",
openBrowserElement: "Abrir anexo de elemento do navegador",
removeBrowserElement: "Remover anexo de elemento do navegador",
@@ -134,7 +135,9 @@ export const ptBR: TranslationResources = {
searching: "Buscando...",
noResults: "Nenhum resultado encontrado.",
searchPlaceholder: "Buscar issues e PRs...",
searchPlaceholder_mr: "Buscar issues e MRs...",
title: "Anexar issue ou PR",
title_mr: "Anexar issue ou MR",
},
},
agentControls: {
@@ -622,6 +625,9 @@ export const ptBR: TranslationResources = {
label: "Criar PR",
pending: "Criando PR...",
success: "PR criada",
label_mr: "Criar MR",
pending_mr: "Criando MR...",
success_mr: "MR criada",
},
mergeBranch: {
label: "Fazer merge localmente",
@@ -644,7 +650,13 @@ export const ptBR: TranslationResources = {
rebase: "Merge",
pending: "Fazendo merge da PR...",
success: "PR mergeada",
squash_mr: "Merge",
merge_mr: "Merge",
rebase_mr: "Merge",
pending_mr: "Fazendo merge da MR...",
success_mr: "MR mergeada",
},
viewPr_mr: "Ver MR",
autoMerge: {
enableSquash: "Merge automático",
enableMerge: "Merge automático",
@@ -655,7 +667,8 @@ export const ptBR: TranslationResources = {
disabled: "Merge automático desativado",
},
unavailable: {
viewPrNoGithub: "Ver PR não está disponível agora porque o GitHub não está conectado",
viewPrNoForge:
"Ver {{noun}} não está disponível agora porque o {{brand}} não está conectado",
pullNoRemote:
"Pull não está disponível aqui porque esta branch ainda não está conectada a um remoto",
pullDirty:
@@ -670,9 +683,14 @@ export const ptBR: TranslationResources = {
"Pull e push não estão disponíveis aqui porque esta branch ainda não está conectada a um remoto",
pullAndPushDirty:
"Pull e push não estão disponíveis enquanto há alterações locais. Faça commit ou stash primeiro",
pullAndPushNoIncoming:
"Pull e push não estão disponíveis porque não há alterações de entrada para puxar primeiro",
pullAndPushInSync:
"Pull e push não estão disponíveis porque esta branch já está sincronizada",
createPrNoGithub: "Criar PR não está disponível agora porque o GitHub não está conectado",
pullAndPushNothingToPush:
"Pull e push não estão disponíveis porque não há nada novo para enviar depois do pull",
createPrNoForge:
"Criar {{noun}} não está disponível agora porque o {{brand}} não está conectado",
createPrNoCommits:
"Criar PR não está disponível porque esta branch ainda não tem commits novos",
mergeNoBase: "Merge não está disponível porque não foi possível determinar a branch base",
@@ -688,6 +706,10 @@ export const ptBR: TranslationResources = {
"Atualizar não está disponível porque esta branch já está atualizada com {{baseRef}}",
mergePrNoGithub:
"Merge da PR não está disponível agora porque o GitHub não está conectado",
archiveNotWorktree:
"Arquivar não está disponível aqui porque este workspace não foi criado como um worktree do Paseo",
mergePrNoForge:
"Merge da {{noun}} não está disponível agora porque o {{brand}} não está conectado",
mergePrMissing: "Merge da PR não está disponível porque ainda não há uma pull request",
mergePrDraft: "Merge da PR não está disponível porque a pull request ainda é um rascunho",
mergePrMerged: "Merge da PR não está disponível porque a pull request já foi mergeada",
@@ -696,7 +718,7 @@ export const ptBR: TranslationResources = {
mergePrQueue:
"Merge da PR não está disponível aqui porque este repositório usa uma merge queue",
mergePrNotReady:
"Merge da PR não está disponível até o GitHub informar que a pull request está pronta para merge",
"Merge da {{noun}} não está disponível até o {{brand}} informar que a {{noun}} está pronta para merge",
autoMergeCannotDisable:
"O merge automático está ativado, mas esta conta não pode desativá-lo",
},
@@ -747,7 +769,7 @@ export const ptBR: TranslationResources = {
expandAllFolders: "Expandir todas as pastas",
refreshing: "Atualizando",
refresh: "Atualizar",
refreshState: "Atualizar estado do git e do GitHub",
refreshState: "Atualizar estado do git e do {{brand}}",
failedRefresh: "Falha ao atualizar estado do git.",
emptyHiddenWhitespace: "Nenhuma alteração visível após ocultar espaços em branco",
emptyUncommitted: "Nenhuma alteração sem commit",
@@ -783,13 +805,23 @@ export const ptBR: TranslationResources = {
pr: {
actions: {
viewPullRequest: "Ver",
openOn: "Abrir no {{brand}}",
},
sections: {
checks: "Verificações",
pipeline: "Pipeline",
reviews: "Revisões",
},
empty: {
noJobs: "Sem jobs",
loadingPipeline: "Carregando pipeline...",
pipelineJobsLoadFailed: "Não foi possível carregar os jobs do pipeline",
allowedToFail: "permitido falhar",
},
approvals: "{{given}} de {{required}} aprovações",
accessibility: {
pullRequest: "Pull request #{{number}}",
pullRequest_mr: "Merge request !{{number}}",
},
states: {
draft: "Rascunho",
@@ -806,11 +838,19 @@ export const ptBR: TranslationResources = {
time: {
justNow: "agora mesmo",
},
thread: {
discussion: "Tópico de discussão",
},
errors: {
statusLoadFailed: "Não foi possível carregar o status da pull request",
activityLoadFailed: "Não foi possível carregar a atividade da pull request",
},
},
forgeSetup: {
installCli: "Instale a CLI {{cli}} para usar os recursos do {{brand}}.",
signIn: "Execute {{command}} para usar os recursos do {{brand}}.",
generic: "Configure o {{brand}} neste host para usar seus recursos.",
},
},
},
sidebar: {
@@ -942,9 +982,9 @@ export const ptBR: TranslationResources = {
refPicker: {
startingRef: "Ref inicial",
chooseStart: "Escolha de onde começar",
checkoutHint: "Fazer checkout da PR #{{number}}?",
checkoutPr: "Fazer checkout da PR #{{number}}",
dismissCheckoutHint: "Dispensar dica de checkout da PR #{{number}}",
checkoutHint: "Fazer checkout da {{noun}} {{numberPrefix}}{{number}}?",
checkoutPr: "Fazer checkout da {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint: "Dispensar dica de checkout da {{noun}} {{numberPrefix}}{{number}}",
intoBase: "em {{baseRef}}",
searching: "Buscando...",
noMatchingRefs: "Nenhuma ref correspondente.",

Some files were not shown because too many files have changed in this diff Show More