From 14a57e8bfef93892d41d79c305d488ef409cc7fd Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 19:56:21 +0200 Subject: [PATCH 01/34] docs: add GitHub Enterprise connector design spec Separate `github-enterprise` provider kind supporting multiple enterprise hosts, derived from `gh auth status`. Reuses the existing `gh` CLI integration; host targeting via GH_HOST for repo-less operations. Co-Authored-By: Claude Opus 5 (1M context) --- ...7-30-github-enterprise-connector-design.md | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-github-enterprise-connector-design.md diff --git a/docs/superpowers/specs/2026-07-30-github-enterprise-connector-design.md b/docs/superpowers/specs/2026-07-30-github-enterprise-connector-design.md new file mode 100644 index 00000000000..f1c8ff7e0da --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-github-enterprise-connector-design.md @@ -0,0 +1,279 @@ +# GitHub Enterprise Connector — Design + +## Problem + +T3 has a GitHub source control connector built on the `gh` CLI. It does not recognize GitHub +Enterprise. Two host families fail: + +- **GitHub Enterprise Server** on an arbitrary hostname (`git.corp.com`) — + `detectSourceControlProviderFromRemoteUrl` (`packages/shared/src/sourceControl.ts:186`) falls + through to kind `unknown`, so no provider is routed. +- **GitHub Enterprise Cloud with data residency** on `.ghe.com` — same failure; the + hostname contains neither `github` nor `github.com`. + +A third case is mislabeled rather than broken: `github.acme.com` matches +`isGitHubHost`'s `host.includes("github")` and resolves to kind `github`, name +"GitHub Self-Hosted". + +The `gh` CLI itself supports enterprise hosts natively — `gh auth login --hostname`, +`gh auth status --json hosts`, and per-repo host resolution from the git remote. The gap is +entirely on the T3 side. + +## Goals + +Full parity with the existing GitHub connector, against any number of enterprise hosts +simultaneously: PR list / view / create / checkout, repository lookup, clone, publish, default +branch, discovery, and settings display. + +Enterprise hosts appear as distinct connections, one per host, in the same way Cursor models +separate GitHub and GitHub Enterprise connections. + +## Non-goals + +T3 does not drive `gh auth login`. Enterprise authentication stays in the user's `gh` config, +consistent with how GitLab, Bitbucket, and Azure DevOps auth work today. An unauthenticated host +surfaces in settings with a `gh auth login --hostname ` hint. + +## Prior art in this repo + +GitLab already solved the self-hosted detection problem. `GitLabSourceControlProvider.ts:73` +defines `refineUnknownGitLabRemote`, wired into the discovery spec as `refineUnknownRemote`. When +a remote resolves to kind `unknown`, `refineUnknownRemoteProvider` +(`SourceControlProviderDiscovery.ts:270`) runs each spec's auth probe and lets it claim the host. +The GitHub spec has no such hook. This design follows the same mechanism. + +## Design + +### 1. Contract + +`packages/contracts/src/sourceControl.ts:5` gains a kind: + +```ts +export const SourceControlProviderKind = Schema.Literals([ + "github", "github-enterprise", "gitlab", "azure-devops", "bitbucket", "unknown", +]); +``` + +Because multiple enterprise hosts coexist, a discovery item needs identity beyond its kind: + +```ts +export const SourceControlProviderDiscoveryItem = Schema.Struct({ + kind: SourceControlProviderKind, + id: TrimmedNonEmptyString, // "github" | "github-enterprise:git.acme.com" + host: Schema.Option(TrimmedNonEmptyString), + ...SourceControlDiscoverySharedFields, + auth: SourceControlProviderAuth, +}); +``` + +`id` replaces `kind` as the React key for settings rows +(`apps/web/src/components/settings/SourceControlSettings.tsx:571`). `host` drives the row subtitle +and host-scoped commands. + +Operations that run outside a repository need a target host, so `host` is added as an optional +field to `SourceControlRepositoryLookupInput`, `SourceControlCloneRepositoryInput`, and +`SourceControlPublishRepositoryInput`. It is ignored for every kind except `github-enterprise`, +where it is required. + +Nothing persists a provider kind — it is computed at runtime from the git remote on every +request, and the only references outside runtime code are in `packages/contracts/src/git.ts` and +`packages/contracts/src/sourceControl.ts`. Adding a kind therefore needs no data migration, and +reclassifying `github.acme.com` breaks no stored rows. + +### 2. Host detection + +`detectSourceControlProviderFromRemoteUrl` is synchronous and has no process access, so it can +only classify hosts recognizable by name: + +```ts +function isGitHubEnterpriseHost(host: string): boolean { + return host !== "github.com" && (host.endsWith(".ghe.com") || host.includes("github")); +} +``` + +Resolution order: `github.com` → kind `github`. Then `*.ghe.com` or a hostname containing +`github` → kind `github-enterprise`, `name` = the hostname, `baseUrl` = `https://`. +Everything else falls through unchanged to `unknown`. + +This moves `github.acme.com` from kind `github` / name "GitHub Self-Hosted" to kind +`github-enterprise`. Intended, and safe per §1. + +Arbitrary hostnames cannot be classified by name, so they resolve to `unknown` and are refined by +CLI probe. Add to the GitHub discovery spec: + +```ts +function refineUnknownGitHubRemote(input: SourceControlUnknownRemoteRefinementInput) { + const host = input.context.provider.name.toLowerCase(); + const account = parseGitHubAuthStatus(input.auth.stdout).accounts + .find((entry) => entry.host === host && entry.authenticated); + if (!account) return null; + return { kind: "github-enterprise", name: host, baseUrl: input.context.provider.baseUrl } as const; +} +``` + +A GHES install on an arbitrary hostname is thus recognized once `gh auth login --hostname` has +been run for it — which is required for the connector to function at all, so this adds no burden. +`refineUnknownRemoteProvider` already runs specs in order and takes the first non-null result, and +the outcome is cached for 5 seconds (`SourceControlProviderRegistry.ts:29`). + +A `refineUnknownRemote` hook returning a kind other than its own spec's kind is already permitted +by the signature — it returns a full `SourceControlProviderInfo`, not a kind-bound value. No +change to `SourceControlProviderDiscovery.ts`'s refinement path is needed. + +### 3. Discovery — one `gh` probe, N rows + +`discover` currently maps one spec to one item (`SourceControlProviderRegistry.ts:272`). +Enterprise needs one spec to produce N items, without spawning `gh --version` and +`gh auth status` twice to populate two sections. + +The `gh` spec fans out. `SourceControlCliDiscoverySpec` gains an optional hook: + +```ts +readonly expandInstances?: (input: SourceControlAuthProbeInput) => ReadonlyArray<{ + readonly kind: SourceControlProviderKind; + readonly id: string; + readonly host: string | null; + readonly label: string; + readonly auth: SourceControlProviderAuth; +}>; +``` + +`probeSourceControlProvider` returns `ReadonlyArray` and +`discover` flattens. Specs without `expandInstances` return a single-element array, leaving +GitLab, Bitbucket, and Azure DevOps behavior unchanged. + +`expandInstances` is consulted only on the success path, after the auth probe runs. When it is +present it supersedes `parseAuth` for that spec — the `gh` spec keeps `parseAuth` only as the +fallback used by `refineUnknownRemote`, which receives a raw probe rather than expanded items. On +the failure paths — executable missing, or the auth probe itself erroring — the existing +single-item construction runs unchanged using the spec's own `kind`, so a missing `gh` still +yields exactly one `github` row. + +The `gh` expansion reads `parseGitHubAuthStatus`, which already parses `hosts` as a record +(`gitHubAuthStatus.ts:44`), and emits: + +- always one `github` item for `github.com`, auth from the github.com account — identical to + today's row +- one `github-enterprise` item per other host, with `id: "github-enterprise:"`, + `label: `, and auth from that host's account + +When `gh` is missing: one `github` item with status `missing`, zero enterprise items. When `gh` is +present but no enterprise host is logged in: zero enterprise items. Enterprise rows exist only +when a connection exists. + +Registry side: `github-enterprise` registers a provider (§4) but no discovery spec of its own, so +`SourceControlProviderRegistration.discovery` becomes optional and `discoverySpecs` filters nulls. + +Settings groups the flattened list by kind, keys rows on `item.id`, and renders `item.host` as the +row subtitle so two enterprise connections are visually distinct. + +### 4. Provider and `gh` host routing + +`gh` resolves its target host from the repository's git remote, so every in-repo operation — +`pr list`, `pr view`, `pr create`, `pr checkout`, `repo view --json defaultBranchRef` — works +against an authenticated GHES host with no extra flags. Those paths need only registration. + +Operations that run outside a repository do need targeting: `repo view owner/repo` and +`repo create`. Those `GitHubCli` methods gain an optional `host`, threaded into the `env` support +`VcsProcess` already exposes (`VcsProcess.ts:27`): + +```ts +...(input.host ? { env: { ...process.env, GH_HOST: input.host } } : {}), +``` + +`GitHubSourceControlProvider.make` becomes parametrized by kind, since it currently hardcodes +`provider: "github"` in `toChangeRequest` and in every `SourceControlProviderError`: + +```ts +export const makeProvider = (kind: "github" | "github-enterprise") => Effect.gen(...) +``` + +The registry registers both kinds against the same `GitHubCli` service — one binary, two routing +keys, no duplicated command logic. + +This also fixes `GitHubCli.ts:279`: `deriveRepositoryCloneUrlsFromCreateOutput` hardcodes +`fallbackHost = "github.com"`, which on an enterprise host silently fabricates a `github.com` URL. +It takes the caller's host, defaulting to `github.com`. + +### 5. Host-targeted operations and pickers + +`PUBLISH_PROVIDER_OPTIONS` (`apps/web/src/components/GitActionsControl.tsx:158`) is a static list +of four hosted providers. It becomes that list plus one entry per discovered enterprise +connection: + +``` +GitHub github.com +GitHub Enterprise git.corp.com +GitHub Enterprise acme.ghe.com +GitLab gitlab.com +… +``` + +Selecting an enterprise entry sets `provider: "github-enterprise"` and `host: ` on the +publish input. `pathPlaceholder` stays `owner/repo`; `description` is the host. The clone and Add +Project repository pickers on web and mobile (`AddProjectRepositoryRoute.tsx`, +`AddProjectScreen.tsx`) take the same shape. + +`SourceControlRepositoryService` threads `host` from those inputs into `getRepositoryCloneUrls` +and `createRepository`, which forward it as `GH_HOST` per §4. `ensureConcreteProvider` +(`SourceControlRepositoryService.ts:101`) additionally rejects `github-enterprise` with no host — +a kind that cannot be routed without one. + +Icons: `SOURCE_CONTROL_PROVIDER_ICONS` (web) and `SourceControlIcon.tsx` (mobile) map +`github-enterprise` to the existing GitHub mark. + +### 6. Presentation and reference parsing + +`packages/shared/src/sourceControl.ts` gains `GITHUB_ENTERPRISE_CHANGE_REQUEST_PRESENTATION`, +identical to the GitHub one — PR / pull request / `gh pr checkout 123`, icon `github` — except +`providerName: "GitHub Enterprise"` and +`urlExample: "https://git.company.com/owner/repo/pull/42"`. + +The `switch` in `resolveChangeRequestPresentation` is exhaustive over the literal union, so +TypeScript flags every other site needing the new case. That compiler output is the authoritative +checklist for the remaining web and mobile switches. + +`apps/web/src/pullRequestReference.ts:2` pins the PR URL pattern to `github.com`, so an enterprise +PR URL is rejected. Widen it the way the GitLab pattern at line 3 already is: + +```ts +const GITHUB_PULL_REQUEST_URL_PATTERN = + /^https:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; +``` + +Host-agnostic by shape (`/owner/repo/pull/N`), which is correct because the function only +normalizes a reference string for `gh pr view` — and `gh` resolves the host from the repository. +The pattern stays ordered after the Azure DevOps and GitLab patterns; those use `/_git/… +/pullrequest/` and `/-/merge_requests/` and cannot collide with `/pull/`, but the ordering keeps +the intent legible. + +## Testing + +Unit tests, following the existing per-file `.test.ts` convention: + +- `packages/shared` — `detectSourceControlProviderFromRemoteUrl` over `github.com`, + `acme.ghe.com`, `github.acme.com`, `git.corp.com`, and SSH-form remotes; the + `github.acme.com` reclassification asserted explicitly as intended behavior +- `GitHubSourceControlProvider.test.ts` — `refineUnknownGitHubRemote` returns enterprise for an + authenticated matching host and `null` for unauthenticated or non-matching hosts; + `expandInstances` over a multi-host `gh auth status --json hosts` fixture covering zero, one, + and two enterprise hosts, plus `gh` missing +- `GitHubCli.test.ts` — `GH_HOST` present when `host` is passed and absent otherwise; enterprise + clone-url fallback derives the enterprise host, not `github.com` +- `SourceControlRepositoryService.test.ts` — `github-enterprise` without a host is rejected; with + a host it routes and forwards correctly +- `SourceControlProviderRegistry.test.ts` — flattened multi-item discovery; registration with an + absent discovery spec +- `pullRequestReference` — enterprise PR URL accepted; non-PR URLs still rejected + +## Risks + +Widening the PR URL pattern makes it structurally permissive: any `https://host/a/b/pull/N` now +parses. The function's contract is normalization, not validation — `gh pr view` rejects a +reference it cannot resolve — so the blast radius is a clearer downstream error rather than a +wrong action. Tests pin the non-PR rejection cases. + +Detection for arbitrary-hostname GHES depends on `gh auth status` succeeding. If `gh` is +installed but the host is not logged in, the remote stays `unknown` and no PR features appear. +This is the same failure mode GitLab self-hosted has today, and the settings row makes the cause +visible. From 80b2dcff3665fd031ed1e356c8867a0d5f059444 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 20:06:14 +0200 Subject: [PATCH 02/34] docs: add GitHub Enterprise connector implementation plan Twelve TDD tasks from contract through mobile, each ending in a committed, independently testable deliverable. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-07-30-github-enterprise-connector.md | 1776 +++++++++++++++++ 1 file changed, 1776 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-30-github-enterprise-connector.md diff --git a/docs/superpowers/plans/2026-07-30-github-enterprise-connector.md b/docs/superpowers/plans/2026-07-30-github-enterprise-connector.md new file mode 100644 index 00000000000..6ff9f0046e8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-github-enterprise-connector.md @@ -0,0 +1,1776 @@ +# GitHub Enterprise Connector Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `github-enterprise` source control provider kind that supports any number of GitHub Enterprise hosts simultaneously, at full parity with the existing GitHub connector. + +**Architecture:** Enterprise hosts are derived from `gh auth status --json hosts` — no new settings schema. The single `gh` discovery spec fans out into one `github` row plus one `github-enterprise` row per authenticated non-`github.com` host. Remote-URL detection classifies `*.ghe.com` and `github.*` synchronously, and falls back to a CLI probe (the mechanism GitLab already uses) for arbitrary hostnames. All command execution reuses the existing `GitHubCli` service; only repo-less operations need host targeting, via `GH_HOST`. + +**Tech Stack:** TypeScript, Effect (effect-smol), Effect/Schema contracts, React (web), React Native (mobile), `@effect/vitest`, vite-plus (`vp`). + +## Global Constraints + +- Spec: `docs/superpowers/specs/2026-07-30-github-enterprise-connector-design.md`. Read it before starting. +- Effect-heavy server code: read `.repos/effect-smol/LLMS.md` before writing Effect code. Never import from `.repos/`. +- Run only targeted checks: `vp test run ` for tests you touched, plus targeted lint/typecheck. **Never** run `vp check`, `vp run -r test`, or `vp run -r typecheck`. +- Test style is `@effect/vitest`: `import { describe, expect, it } from "@effect/vitest"`, `it.effect` for Effect-returning tests, `Layer.mock(Service)({...})` for fakes. Match the surrounding file. +- `packages/contracts` holds Effect/Schema contracts plus small derived helpers — no heavy runtime logic. `packages/shared` has subpath exports and no barrel. +- Inferred types over annotations. `any` is the enemy. +- Branch is `feat/github-enterprise-connector`, already created and checked out. Commit after each task. +- Provider kind is never persisted — it is recomputed per request from the git remote. No migrations anywhere in this plan. + +--- + +## File Structure + +**Contracts** +- Modify `packages/contracts/src/sourceControl.ts` — new kind literal; `id`/`host` on the discovery item; optional `host` on three inputs. + +**Shared** +- Modify `packages/shared/src/sourceControl.ts` — enterprise host classification, enterprise presentation. +- Create `packages/shared/src/sourceControl.test.ts` — detection + presentation tests (no test file exists for this module today). + +**Server — discovery plumbing** +- Modify `apps/server/src/sourceControl/SourceControlProviderDiscovery.ts` — `expandInstances` hook; probe returns an array. +- Modify `apps/server/src/sourceControl/SourceControlProviderRegistry.ts` — optional discovery spec; flatten discovery; register `github-enterprise`. + +**Server — GitHub** +- Modify `apps/server/src/sourceControl/GitHubSourceControlProvider.ts` — `refineUnknownRemote`, `expandInstances`, kind-parametrized provider factory. +- Modify `apps/server/src/sourceControl/GitHubCli.ts` — optional `host` → `GH_HOST`; host-aware clone-url fallback. +- Modify `apps/server/src/sourceControl/SourceControlRepositoryService.ts` — thread `host`; reject hostless `github-enterprise`. + +**Web** +- Modify `apps/web/src/pullRequestReference.ts` — host-agnostic PR URL pattern. +- Modify `apps/web/src/components/settings/SourceControlSettings.tsx` — key rows on `id`, render `host`, enterprise icon. +- Modify `apps/web/src/components/GitActionsControl.tsx` — enterprise entries in the publish picker. + +**Client runtime + mobile** +- Modify `packages/client-runtime/src/operations/projects.ts` — dynamic add-project sources keyed by discovery `id`. +- Modify `apps/mobile/src/features/projects/AddProjectScreen.tsx` and `AddProjectRepositoryRoute.tsx` — accept enterprise sources. + +- Modify `apps/mobile/src/components/SourceControlIcon.tsx` — accept `github-enterprise`. + +`apps/web/src/sourceControlPresentation.ts` switches on `presentation.icon`, not on kind. Because the enterprise presentation reuses `icon: "github"`, that file needs **no changes**. Do not edit it. + +`SourceControlIcon` is different: `AddProjectScreen.tsx:389` passes the raw source (`kind={props.source}`), not `presentation.icon`, so its `SourceControlIconKind` union does need the new member. Task 12 covers it. + +--- + +### Task 1: Contract — new kind and discovery identity + +**Files:** +- Modify: `packages/contracts/src/sourceControl.ts:5-11`, `:52-104`, `:140-145` + +**Interfaces:** +- Consumes: nothing. +- Produces: `SourceControlProviderKind` now includes `"github-enterprise"`. `SourceControlProviderDiscoveryItem` gains `id: string` and `host: Option.Option`. `SourceControlRepositoryLookupInput`, `SourceControlCloneRepositoryInput`, and `SourceControlPublishRepositoryInput` each gain `host?: string`. + +- [ ] **Step 1: Add the kind literal** + +In `packages/contracts/src/sourceControl.ts`, replace the `SourceControlProviderKind` declaration: + +```ts +export const SourceControlProviderKind = Schema.Literals([ + "github", + "github-enterprise", + "gitlab", + "azure-devops", + "bitbucket", + "unknown", +]); +``` + +- [ ] **Step 2: Add identity fields to the discovery item** + +Replace the `SourceControlProviderDiscoveryItem` declaration: + +```ts +export const SourceControlProviderDiscoveryItem = Schema.Struct({ + kind: SourceControlProviderKind, + id: TrimmedNonEmptyString, + host: Schema.Option(TrimmedNonEmptyString), + ...SourceControlDiscoverySharedFields, + auth: SourceControlProviderAuth, +}); +``` + +`VcsDiscoveryItem` is untouched — it has no `id`, and consumers discriminate the two via the existing `isProviderDiscoveryItem` guard. + +- [ ] **Step 3: Add `host` to the three repository inputs** + +Add `host: Schema.optional(TrimmedNonEmptyString),` as a field to each of `SourceControlRepositoryLookupInput`, `SourceControlCloneRepositoryInput`, and `SourceControlPublishRepositoryInput`. For example: + +```ts +export const SourceControlRepositoryLookupInput = Schema.Struct({ + provider: SourceControlProviderKind, + repository: TrimmedNonEmptyString, + host: Schema.optional(TrimmedNonEmptyString), + cwd: Schema.optional(TrimmedNonEmptyString), +}); +``` + +- [ ] **Step 4: Typecheck the contracts package** + +Run: `vp run --filter @t3tools/contracts typecheck` +Expected: PASS. Contracts are self-contained; downstream packages will not typecheck until later tasks and that is expected. + +- [ ] **Step 5: Commit** + +```bash +git add packages/contracts/src/sourceControl.ts +git commit -m "feat(contracts): add github-enterprise kind and discovery identity" +``` + +--- + +### Task 2: Shared — enterprise host detection and presentation + +**Files:** +- Modify: `packages/shared/src/sourceControl.ts:24-33`, `:77-93`, `:170-232` +- Create: `packages/shared/src/sourceControl.test.ts` + +**Interfaces:** +- Consumes: `SourceControlProviderKind` from Task 1. +- Produces: `detectSourceControlProviderFromRemoteUrl` returns `kind: "github-enterprise"` for `*.ghe.com` and `github.*` hosts. `resolveChangeRequestPresentation` handles the new kind, returning `icon: "github"`. + +- [ ] **Step 1: Write the failing test** + +Create `packages/shared/src/sourceControl.test.ts`: + +```ts +import { describe, expect, it } from "@effect/vitest"; + +import { + detectSourceControlProviderFromRemoteUrl, + resolveChangeRequestPresentation, +} from "./sourceControl.ts"; + +describe("detectSourceControlProviderFromRemoteUrl", () => { + it("classifies github.com as github", () => { + expect(detectSourceControlProviderFromRemoteUrl("https://github.com/owner/repo.git")).toEqual({ + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }); + }); + + it("classifies a ghe.com tenant as github-enterprise", () => { + expect(detectSourceControlProviderFromRemoteUrl("https://acme.ghe.com/owner/repo.git")).toEqual({ + kind: "github-enterprise", + name: "acme.ghe.com", + baseUrl: "https://acme.ghe.com", + }); + }); + + // Previously classified as kind "github" / name "GitHub Self-Hosted". + // Reclassification is intended; nothing persists the kind. + it("classifies a github-prefixed corporate host as github-enterprise", () => { + expect(detectSourceControlProviderFromRemoteUrl("git@github.acme.com:owner/repo.git")).toEqual({ + kind: "github-enterprise", + name: "github.acme.com", + baseUrl: "https://github.acme.com", + }); + }); + + it("leaves an arbitrary GHES hostname unknown for CLI refinement", () => { + expect(detectSourceControlProviderFromRemoteUrl("https://git.corp.com/owner/repo.git")).toEqual({ + kind: "unknown", + name: "git.corp.com", + baseUrl: "https://git.corp.com", + }); + }); + + it("still classifies gitlab hosts as gitlab", () => { + expect( + detectSourceControlProviderFromRemoteUrl("https://gitlab.acme.com/group/project.git")?.kind, + ).toBe("gitlab"); + }); +}); + +describe("resolveChangeRequestPresentation", () => { + it("presents github-enterprise with GitHub PR terminology and icon", () => { + const presentation = resolveChangeRequestPresentation({ + kind: "github-enterprise", + name: "git.corp.com", + baseUrl: "https://git.corp.com", + }); + + expect(presentation.icon).toBe("github"); + expect(presentation.shortName).toBe("PR"); + expect(presentation.longName).toBe("pull request"); + expect(presentation.providerName).toBe("GitHub Enterprise"); + expect(presentation.checkoutCommandExample).toBe("gh pr checkout 123"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `vp test run packages/shared/src/sourceControl.test.ts` +Expected: FAIL — enterprise cases return `kind: "github"` or `"unknown"`, and `resolveChangeRequestPresentation` has no `github-enterprise` case. + +- [ ] **Step 3: Add the enterprise presentation** + +In `packages/shared/src/sourceControl.ts`, after `GITHUB_CHANGE_REQUEST_PRESENTATION`: + +```ts +const GITHUB_ENTERPRISE_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "github", + providerName: "GitHub Enterprise", + shortName: "PR", + longName: "pull request", + pluralLongName: "pull requests", + providerLongName: "GitHub Enterprise pull request", + checkoutCommandExample: "gh pr checkout 123", + urlExample: "https://git.company.com/owner/repo/pull/42", +}; +``` + +Add the case to `resolveChangeRequestPresentation`, immediately after the `"github"` / `undefined` case: + +```ts + case "github-enterprise": + return GITHUB_ENTERPRISE_CHANGE_REQUEST_PRESENTATION; +``` + +- [ ] **Step 4: Add enterprise host classification** + +Replace `isGitHubHost` and the GitHub branch of `detectSourceControlProviderFromRemoteUrl`: + +```ts +function isGitHubHost(host: string): boolean { + return host === "github.com"; +} + +function isGitHubEnterpriseHost(host: string): boolean { + return host !== "github.com" && (host.endsWith(".ghe.com") || host.includes("github")); +} +``` + +In `detectSourceControlProviderFromRemoteUrl`, replace the existing GitHub block with: + +```ts + if (isGitHubHost(hostname)) { + return { + kind: "github", + name: "GitHub", + baseUrl: toBaseUrl(host), + }; + } + + if (isGitHubEnterpriseHost(hostname)) { + return { + kind: "github-enterprise", + name: hostname, + baseUrl: toBaseUrl(host), + }; + } +``` + +Leave the GitLab, Azure DevOps, Bitbucket, and `unknown` branches exactly as they are. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `vp test run packages/shared/src/sourceControl.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add packages/shared/src/sourceControl.ts packages/shared/src/sourceControl.test.ts +git commit -m "feat(shared): detect and present GitHub Enterprise hosts" +``` + +--- + +### Task 3: Discovery plumbing — one spec, many rows + +**Files:** +- Modify: `apps/server/src/sourceControl/SourceControlProviderDiscovery.ts:31-40`, `:203-268` +- Modify: `apps/server/src/sourceControl/SourceControlProviderRegistry.ts:31-35`, `:196-284` +- Test: `apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts` + +**Interfaces:** +- Consumes: `SourceControlProviderDiscoveryItem` from Task 1. +- Produces: `SourceControlCliDiscoverySpec` gains optional `expandInstances`. `probeSourceControlProvider` returns `Effect.Effect>` (was a single item). `SourceControlProviderRegistration.discovery` becomes optional. `SourceControlProviderRegistry.discover` stays `Effect.Effect>`, now flattened. + +`expandInstances` has this exact shape: + +```ts +export interface SourceControlDiscoveryInstance { + readonly kind: SourceControlProviderKind; + readonly id: string; + readonly host: string | null; + readonly label: string; + readonly auth: SourceControlProviderAuth; +} +``` + +- [ ] **Step 1: Write the failing test** + +Append to `apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts`. Read the file's existing imports and helpers first and reuse them rather than duplicating; this test needs `Effect`, `Layer.mock`, `ChildProcessSpawner`, `VcsProcess`, `VcsDriverRegistry`, and `ServerConfig` wired the same way the existing tests do. + +```ts +describe("SourceControlProviderRegistry.discover", () => { + it.effect("flattens a spec that expands into multiple instances", () => + Effect.gen(function* () { + const spec = { + type: "cli", + kind: "github", + label: "GitHub", + executable: "gh", + versionArgs: ["--version"], + authArgs: ["auth", "status"], + parseAuth: () => providerAuth({ status: "unknown" }), + expandInstances: () => [ + { + kind: "github" as const, + id: "github", + host: "github.com", + label: "GitHub", + auth: providerAuth({ status: "authenticated", account: "octocat", host: "github.com" }), + }, + { + kind: "github-enterprise" as const, + id: "github-enterprise:git.corp.com", + host: "git.corp.com", + label: "git.corp.com", + auth: providerAuth({ status: "authenticated", account: "dev", host: "git.corp.com" }), + }, + ], + installHint: "Install gh.", + } satisfies SourceControlCliDiscoverySpec; + + const registry = yield* SourceControlProviderRegistry.makeWithProviders([ + { kind: "github", provider: stubProvider("github"), discovery: spec }, + { kind: "github-enterprise", provider: stubProvider("github-enterprise") }, + ]); + + const items = yield* registry.discover; + + expect(items.map((item) => item.id)).toEqual(["github", "github-enterprise:git.corp.com"]); + expect(items.map((item) => item.kind)).toEqual(["github", "github-enterprise"]); + expect(Option.getOrNull(items[1]!.host)).toBe("git.corp.com"); + expect(items[1]!.label).toBe("git.corp.com"); + }), + ); +}); +``` + +Add a local `stubProvider` helper in the test file if one does not already exist: + +```ts +const stubProvider = (kind: SourceControlProviderKind) => + SourceControlProvider.SourceControlProvider.of({ + kind, + listChangeRequests: () => Effect.succeed([]), + getChangeRequest: () => Effect.die("unused"), + createChangeRequest: () => Effect.die("unused"), + getRepositoryCloneUrls: () => Effect.die("unused"), + createRepository: () => Effect.die("unused"), + getDefaultBranch: () => Effect.succeed(null), + checkoutChangeRequest: () => Effect.die("unused"), + }); +``` + +The mocked `VcsProcess.run` must succeed for both the `--version` probe and the `auth status` probe so the expansion path runs. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `vp test run apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts` +Expected: FAIL — `expandInstances` is not a known spec field, `discovery` is required on a registration, and `discover` yields one item per spec. + +- [ ] **Step 3: Add the expansion hook to the spec type** + +In `SourceControlProviderDiscovery.ts`, add the instance interface above `SourceControlDiscoverySpecBase`: + +```ts +export interface SourceControlDiscoveryInstance { + readonly kind: SourceControlProviderKind; + readonly id: string; + readonly host: string | null; + readonly label: string; + readonly auth: SourceControlProviderAuth; +} +``` + +Add the optional field to `SourceControlCliDiscoverySpec`, alongside `refineUnknownRemote`: + +```ts + readonly expandInstances?: ( + input: SourceControlAuthProbeInput, + ) => ReadonlyArray; +``` + +- [ ] **Step 4: Return arrays from the probe** + +Change `probeSourceControlProvider`'s return type to `Effect.Effect>`. + +The `api` branch wraps its single item in an array and sets identity fields: + +```ts + if (input.spec.type === "api") { + return input.spec.probeAuth.pipe( + Effect.map((auth) => [ + { + kind: input.spec.kind, + id: input.spec.kind, + host: Option.none(), + label: input.spec.label, + status: "available" as const, + version: Option.none(), + installHint: input.spec.installHint, + detail: Option.none(), + auth, + } satisfies SourceControlProviderDiscoveryItem, + ]), + ); + } +``` + +In the `cli` branch, the missing-executable path returns a one-element array with `id: spec.kind` and `host: Option.none()`. On the auth-probe success path, consult `expandInstances`: + +```ts + return input.process + .run({ /* unchanged auth probe arguments */ }) + .pipe( + Effect.map((result) => { + const instances = spec.expandInstances?.(result); + if (instances) { + return instances.map( + (instance) => + ({ + ...item, + kind: instance.kind, + id: instance.id, + host: instance.host === null ? Option.none() : Option.some(instance.host), + label: instance.label, + auth: instance.auth, + }) satisfies SourceControlProviderDiscoveryItem, + ); + } + return [ + { + ...item, + id: spec.kind, + host: Option.none(), + auth: spec.parseAuth(result), + } satisfies SourceControlProviderDiscoveryItem, + ]; + }), + Effect.catch((cause) => + Effect.succeed([ + { + ...item, + id: spec.kind, + host: Option.none(), + auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), + } satisfies SourceControlProviderDiscoveryItem, + ]), + ), + ); +``` + +`DiscoveryProbeResult` (the internal `probeCli` result) is unchanged — identity fields are attached here, not there. + +- [ ] **Step 5: Make the registration's discovery spec optional and flatten** + +In `SourceControlProviderRegistry.ts`, change the registration interface: + +```ts +export interface SourceControlProviderRegistration { + readonly kind: SourceControlProviderKind; + readonly provider: SourceControlProvider.SourceControlProvider["Service"]; + readonly discovery?: SourceControlProviderDiscoverySpec; +} +``` + +Filter out registrations without a spec: + +```ts + const discoverySpecs = registrations.flatMap((registration) => + registration.discovery ? [registration.discovery] : [], + ); +``` + +Flatten the discovery result: + +```ts + discover: Effect.all( + discoverySpecs.map((spec) => + probeSourceControlProvider({ + spec, + process, + cwd: config.cwd, + }), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.map((results) => results.flat())), +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `vp test run apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts apps/server/src/sourceControl/SourceControlDiscovery.test.ts` +Expected: PASS. If `SourceControlDiscovery.test.ts` asserts on discovery items, update its fixtures to include `id` and `host`. + +- [ ] **Step 7: Commit** + +```bash +git add apps/server/src/sourceControl/SourceControlProviderDiscovery.ts apps/server/src/sourceControl/SourceControlProviderRegistry.ts apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts apps/server/src/sourceControl/SourceControlDiscovery.test.ts +git commit -m "feat(server): let a discovery spec expand into multiple provider rows" +``` + +--- + +### Task 4: GitHub spec — enterprise expansion and unknown-remote refinement + +**Files:** +- Modify: `apps/server/src/sourceControl/GitHubSourceControlProvider.ts:85-95` +- Test: `apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts` + +**Interfaces:** +- Consumes: `SourceControlDiscoveryInstance` and the `expandInstances` hook from Task 3; `parseGitHubAuthStatus` / `findAuthenticatedGitHubAccount` from `./gitHubAuthStatus.ts` (unchanged). +- Produces: `discovery` gains `expandInstances: expandGitHubInstances` and `refineUnknownRemote: refineUnknownGitHubRemote`. Both are module-local functions, exported for test access. + +- [ ] **Step 1: Write the failing test** + +Append to `apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts`: + +```ts +const authStatusJson = ( + hosts: Record>, +) => + JSON.stringify({ + hosts: Object.fromEntries( + Object.entries(hosts).map(([host, accounts]) => [ + host, + accounts.map((account) => ({ ...account, host })), + ]), + ), + }); + +const probe = (stdout: string) => ({ + stdout, + stderr: "", + exitCode: ChildProcessSpawner.ExitCode(0), +}); + +describe("expandGitHubInstances", () => { + it("emits only a github row when no enterprise host is logged in", () => { + const instances = GitHubSourceControlProvider.expandGitHubInstances( + probe(authStatusJson({ "github.com": [{ login: "octocat", state: "success", active: true }] })), + ); + + expect(instances.map((instance) => instance.id)).toEqual(["github"]); + expect(instances[0]!.kind).toBe("github"); + expect(instances[0]!.auth.status).toBe("authenticated"); + }); + + it("emits one enterprise row per non-github.com host", () => { + const instances = GitHubSourceControlProvider.expandGitHubInstances( + probe( + authStatusJson({ + "github.com": [{ login: "octocat", state: "success", active: true }], + "git.corp.com": [{ login: "dev", state: "success", active: false }], + "acme.ghe.com": [{ login: "dev2", state: "success", active: false }], + }), + ), + ); + + expect(instances.map((instance) => instance.id)).toEqual([ + "github", + "github-enterprise:acme.ghe.com", + "github-enterprise:git.corp.com", + ]); + expect(instances[1]!.kind).toBe("github-enterprise"); + expect(instances[1]!.label).toBe("acme.ghe.com"); + expect(instances[1]!.host).toBe("acme.ghe.com"); + expect(Option.getOrNull(instances[1]!.auth.account)).toBe("dev2"); + }); + + it("still emits a github row when github.com is not logged in", () => { + const instances = GitHubSourceControlProvider.expandGitHubInstances( + probe(authStatusJson({ "git.corp.com": [{ login: "dev", state: "success", active: true }] })), + ); + + expect(instances[0]!.id).toBe("github"); + expect(instances[0]!.auth.status).toBe("unauthenticated"); + expect(instances).toHaveLength(2); + }); + + it("emits only an unauthenticated github row when output is unparseable", () => { + const instances = GitHubSourceControlProvider.expandGitHubInstances(probe("not json")); + + expect(instances).toHaveLength(1); + expect(instances[0]!.id).toBe("github"); + }); +}); + +describe("refineUnknownGitHubRemote", () => { + const context = { + provider: { kind: "unknown" as const, name: "git.corp.com", baseUrl: "https://git.corp.com" }, + remoteName: "origin", + remoteUrl: "https://git.corp.com/owner/repo.git", + }; + + it("claims a remote whose host is authenticated in gh", () => { + const refined = GitHubSourceControlProvider.refineUnknownGitHubRemote({ + cwd: "/repo", + context, + auth: probe(authStatusJson({ "git.corp.com": [{ login: "dev", state: "success", active: true }] })), + }); + + expect(refined).toEqual({ + kind: "github-enterprise", + name: "git.corp.com", + baseUrl: "https://git.corp.com", + }); + }); + + it("does not claim a host that failed authentication", () => { + expect( + GitHubSourceControlProvider.refineUnknownGitHubRemote({ + cwd: "/repo", + context, + auth: probe( + authStatusJson({ "git.corp.com": [{ login: "dev", state: "error", active: true }] }), + ), + }), + ).toBeNull(); + }); + + it("does not claim a host absent from gh auth status", () => { + expect( + GitHubSourceControlProvider.refineUnknownGitHubRemote({ + cwd: "/repo", + context, + auth: probe( + authStatusJson({ "github.com": [{ login: "octocat", state: "success", active: true }] }), + ), + }), + ).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `vp test run apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts` +Expected: FAIL with "expandGitHubInstances is not a function". + +- [ ] **Step 3: Implement expansion and refinement** + +In `GitHubSourceControlProvider.ts`, add above the `discovery` export. Note `parseGitHubAuthStatus` already lowercases hosts (`gitHubAuthStatus.ts:53`), so no extra normalization is needed on parsed accounts. + +```ts +function githubComAuth(status: ReturnType) { + const accounts = status.accounts.filter((account) => account.host === "github.com"); + const authenticated = findAuthenticatedGitHubAccount(accounts); + if (authenticated) { + return providerAuth({ + status: "authenticated", + account: authenticated.account, + host: "github.com", + }); + } + return providerAuth({ + status: "unauthenticated", + host: "github.com", + detail: + accounts[0]?.error ?? "Run `gh auth login` to authenticate GitHub CLI with an active account.", + }); +} + +export function expandGitHubInstances( + input: SourceControlAuthProbeInput, +): ReadonlyArray { + const status = parseGitHubAuthStatus(input.stdout); + if (!status.parsed) { + return [ + { + kind: "github", + id: "github", + host: "github.com", + label: "GitHub", + auth: parseGitHubAuth(input), + }, + ]; + } + + const enterpriseHosts = [ + ...new Set( + status.accounts.map((account) => account.host).filter((host) => host !== "github.com"), + ), + ].sort(); + + return [ + { + kind: "github", + id: "github", + host: "github.com", + label: "GitHub", + auth: githubComAuth(status), + }, + ...enterpriseHosts.map((host) => { + const accounts = status.accounts.filter((account) => account.host === host); + const authenticated = findAuthenticatedGitHubAccount(accounts); + return { + kind: "github-enterprise" as const, + id: `github-enterprise:${host}`, + host, + label: host, + auth: authenticated + ? providerAuth({ status: "authenticated", account: authenticated.account, host }) + : providerAuth({ + status: "unauthenticated", + host, + detail: + accounts[0]?.error ?? `Run \`gh auth login --hostname ${host}\` to authenticate.`, + }), + }; + }), + ]; +} + +export function refineUnknownGitHubRemote(input: SourceControlUnknownRemoteRefinementInput) { + const host = input.context.provider.name.toLowerCase(); + const authenticated = parseGitHubAuthStatus(input.auth.stdout).accounts.some( + (account) => account.host === host && account.authenticated, + ); + + if (!authenticated) { + return null; + } + + return { + kind: "github-enterprise", + name: host, + baseUrl: input.context.provider.baseUrl, + } as const; +} +``` + +Add `type SourceControlDiscoveryInstance` and `type SourceControlUnknownRemoteRefinementInput` to the existing import from `./SourceControlProviderDiscovery.ts`. + +- [ ] **Step 4: Wire both hooks into the discovery spec** + +```ts +export const discovery = { + type: "cli", + kind: "github", + label: "GitHub", + executable: "gh", + versionArgs: ["--version"], + authArgs: ["auth", "status", "--json", "hosts"], + parseAuth: parseGitHubAuth, + expandInstances: expandGitHubInstances, + refineUnknownRemote: refineUnknownGitHubRemote, + installHint: + "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", +} satisfies SourceControlCliDiscoverySpec; +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `vp test run apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/src/sourceControl/GitHubSourceControlProvider.ts apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +git commit -m "feat(server): expand gh auth hosts into enterprise connections" +``` + +--- + +### Task 5: GitHubCli — host targeting via `GH_HOST` + +**Files:** +- Modify: `apps/server/src/sourceControl/GitHubCli.ts:199-248`, `:269-304`, `:306-453` +- Test: `apps/server/src/sourceControl/GitHubCli.test.ts` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `GitHubCli.execute`, `getRepositoryCloneUrls`, and `createRepository` accept an optional `host?: string`. When present, the spawned process env is `{ ...process.env, GH_HOST: host }`; when absent, no `env` key is passed at all. `deriveRepositoryCloneUrlsFromCreateOutput(stdout, repository, host)` takes the fallback host as its third parameter. + +- [ ] **Step 1: Write the failing test** + +Append to `apps/server/src/sourceControl/GitHubCli.test.ts` (reuse the file's existing `mockRun`, `layer`, and `processOutput` helpers): + +```ts +describe("GitHubCli host targeting", () => { + it.effect("sets GH_HOST when a host is supplied", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + nameWithOwner: "owner/repo", + url: "https://git.corp.com/owner/repo", + sshUrl: "git@git.corp.com:owner/repo.git", + }), + ), + ), + ); + + const cli = yield* GitHubCli.GitHubCli; + yield* cli.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "owner/repo", + host: "git.corp.com", + }); + + expect(mockRun.mock.calls[0]![0].env?.GH_HOST).toBe("git.corp.com"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("omits env entirely when no host is supplied", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + nameWithOwner: "owner/repo", + url: "https://github.com/owner/repo", + sshUrl: "git@github.com:owner/repo.git", + }), + ), + ), + ); + + const cli = yield* GitHubCli.GitHubCli; + yield* cli.getRepositoryCloneUrls({ cwd: "/repo", repository: "owner/repo" }); + + expect(mockRun.mock.calls[0]![0]).not.toHaveProperty("env"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("derives enterprise clone urls when repo create prints no url", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const cli = yield* GitHubCli.GitHubCli; + const urls = yield* cli.createRepository({ + cwd: "/repo", + repository: "owner/repo", + visibility: "private", + host: "git.corp.com", + }); + + expect(urls).toEqual({ + nameWithOwner: "owner/repo", + url: "https://git.corp.com/owner/repo", + sshUrl: "git@git.corp.com:owner/repo.git", + }); + }).pipe(Effect.provide(layer)), + ); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `vp test run apps/server/src/sourceControl/GitHubCli.test.ts` +Expected: FAIL — `host` is not an accepted property, and the create fallback yields `github.com` URLs. + +- [ ] **Step 3: Thread `host` through the service type** + +In the `GitHubCli` service declaration, add `readonly host?: string;` to the input objects of `execute`, `getRepositoryCloneUrls`, and `createRepository`. Leave the in-repo operations (`listOpenPullRequests`, `getPullRequest`, `createPullRequest`, `getDefaultBranch`, `checkoutPullRequest`) unchanged — `gh` resolves the host from the repository's git remote for those. + +- [ ] **Step 4: Set `GH_HOST` in `execute`** + +```ts + const execute: GitHubCli["Service"]["execute"] = (input) => + process + .run({ + operation: "GitHubCli.execute", + command: "gh", + args: input.args, + cwd: input.cwd, + ...(input.host ? { env: { ...globalThis.process.env, GH_HOST: input.host } } : {}), + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }) + .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); +``` + +`globalThis.process` is required because `process` is shadowed by the `VcsProcess` service binding in this scope. + +- [ ] **Step 5: Forward `host` from the two host-aware operations** + +In `getRepositoryCloneUrls` and `createRepository`, add `...(input.host ? { host: input.host } : {})` to their `execute({...})` calls. + +- [ ] **Step 6: Make the create fallback host-aware** + +```ts +function deriveRepositoryCloneUrlsFromCreateOutput( + stdout: string, + repository: string, + host: string = "github.com", +): GitHubRepositoryCloneUrls { + const match = stdout.match(/https?:\/\/[^\s]+/); + // ...unchanged URL-parsing block... + return { + nameWithOwner: repository, + url: `https://${host}/${repository}`, + sshUrl: `git@${host}:${repository}.git`, + }; +} +``` + +Remove the `const fallbackHost = "github.com";` line and update the call site in `createRepository` to pass `input.host`. + +- [ ] **Step 7: Run test to verify it passes** + +Run: `vp test run apps/server/src/sourceControl/GitHubCli.test.ts` +Expected: PASS + +- [ ] **Step 8: Commit** + +```bash +git add apps/server/src/sourceControl/GitHubCli.ts apps/server/src/sourceControl/GitHubCli.test.ts +git commit -m "feat(server): target enterprise hosts with GH_HOST in GitHubCli" +``` + +--- + +### Task 6: Kind-parametrized GitHub provider and registration + +**Files:** +- Modify: `apps/server/src/sourceControl/GitHubSourceControlProvider.ts:23-43`, `:97-301` +- Modify: `apps/server/src/sourceControl/SourceControlProviderRegistry.ts:286-314` +- Test: `apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts` + +**Interfaces:** +- Consumes: `host` on `GitHubCli` from Task 5; optional `discovery` on registrations from Task 3. +- Produces: `makeProvider(kind: "github" | "github-enterprise")` returns the provider effect. `make` is kept as `makeProvider("github")` so existing importers (including `layer`) keep working. `SourceControlProvider.getRepositoryCloneUrls` and `createRepository` accept an optional `host?: string` in their input, forwarded to `GitHubCli`. + +- [ ] **Step 1: Write the failing test** + +Append to `apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts`: + +```ts +describe("makeProvider", () => { + it.effect("tags change requests and errors with the enterprise kind", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 7, + title: "Add widget", + url: "https://git.corp.com/owner/repo/pull/7", + baseRefName: "main", + headRefName: "feature", + state: "OPEN", + }, + ]), + ), + ), + ); + + const provider = yield* GitHubSourceControlProvider.makeProvider("github-enterprise"); + const requests = yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature", + state: "open", + }); + + expect(provider.kind).toBe("github-enterprise"); + expect(requests[0]!.provider).toBe("github-enterprise"); + }).pipe(Effect.provide(cliLayer)), + ); +}); +``` + +Reuse the file's existing `GitHubCli` mock layer; name it `cliLayer` if the file does not already expose one. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `vp test run apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts` +Expected: FAIL with "makeProvider is not a function". + +- [ ] **Step 3: Parametrize the provider factory** + +Introduce a kind alias and thread it through. Change `toChangeRequest` to take the kind: + +```ts +type GitHubProviderKind = "github" | "github-enterprise"; + +function toChangeRequest( + kind: GitHubProviderKind, + summary: GitHubCli.GitHubPullRequestSummary, +): ChangeRequest { + return { + provider: kind, + // ...remaining fields unchanged... + }; +} +``` + +Rename `export const make = Effect.gen(function* () { ... })` to: + +```ts +export const makeProvider = (kind: GitHubProviderKind) => + Effect.gen(function* () { + // ...existing body... + }); + +export const make = makeProvider("github"); +``` + +Inside the body, replace every literal `provider: "github"` in `SourceControlProviderError` constructions with `provider: kind`, replace `kind: "github"` in `SourceControlProvider.SourceControlProvider.of({...})` with `kind`, and update the two `toChangeRequest` call sites plus the `.map(toChangeRequest)` to `.map((summary) => toChangeRequest(kind, summary))`. The `GitHubChangeRequestListDecodeError` keeps `command: "gh"` — that field names the executable, not the provider. + +Forward `host` in the two host-aware operations: + +```ts + getRepositoryCloneUrls: (input) => + github + .getRepositoryCloneUrls({ + cwd: input.cwd, + repository: input.repository, + ...(input.host ? { host: input.host } : {}), + }) + .pipe(/* unchanged error mapping */), +``` + +and the same pattern for `createRepository`. + +- [ ] **Step 4: Add `host` to the provider interface** + +In `apps/server/src/sourceControl/SourceControlProvider.ts`, add `readonly host?: string;` to the input types of `getRepositoryCloneUrls` and `createRepository`. `bindProviderContext` in the registry spreads `...input`, so `host` flows through untouched. + +- [ ] **Step 5: Register the enterprise kind** + +In `SourceControlProviderRegistry.ts`'s `make`: + +```ts +export const make = Effect.gen(function* () { + const github = yield* GitHubSourceControlProvider.makeProvider("github"); + const githubEnterprise = yield* GitHubSourceControlProvider.makeProvider("github-enterprise"); + const gitlab = yield* GitLabSourceControlProvider.make; + const bitbucket = yield* BitbucketSourceControlProvider.make; + const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery; + const azureDevOps = yield* AzureDevOpsSourceControlProvider.make; + return yield* makeWithProviders([ + { + kind: "github", + provider: github, + discovery: GitHubSourceControlProvider.discovery, + }, + { + // Rows come from the `gh` spec's expandInstances; no spec of its own. + kind: "github-enterprise", + provider: githubEnterprise, + }, + // ...gitlab, azure-devops, bitbucket unchanged... + ]); +}); +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `vp test run apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add apps/server/src/sourceControl/GitHubSourceControlProvider.ts apps/server/src/sourceControl/SourceControlProvider.ts apps/server/src/sourceControl/SourceControlProviderRegistry.ts apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +git commit -m "feat(server): register github-enterprise provider on the gh CLI" +``` + +--- + +### Task 7: Repository service — host threading and validation + +**Files:** +- Modify: `apps/server/src/sourceControl/SourceControlRepositoryService.ts:97-127`, `:180-232` +- Test: `apps/server/src/sourceControl/SourceControlRepositoryService.test.ts` + +**Interfaces:** +- Consumes: `host` on the three contract inputs (Task 1); `host` on provider operations (Task 6). +- Produces: `lookupRepository`, `cloneRepository`, and `publishRepository` forward `input.host`. `ensureConcreteProvider` gains a `host` parameter and fails when `provider === "github-enterprise"` and no host is present. + +- [ ] **Step 1: Write the failing test** + +Append to `apps/server/src/sourceControl/SourceControlRepositoryService.test.ts` (reuse the file's existing service layer and provider mocks): + +```ts +describe("github-enterprise host requirement", () => { + it.effect("rejects an enterprise lookup with no host", () => + Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const error = yield* service + .lookupRepository({ provider: "github-enterprise", repository: "owner/repo" }) + .pipe(Effect.flip); + + expect(error.detail).toBe( + "Choose a GitHub Enterprise host before continuing.", + ); + }), + ); + + it.effect("forwards the host to the provider", () => + Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + yield* service.lookupRepository({ + provider: "github-enterprise", + repository: "owner/repo", + host: "git.corp.com", + }); + + expect(getRepositoryCloneUrls.mock.calls[0]![0].host).toBe("git.corp.com"); + }), + ); +}); +``` + +`getRepositoryCloneUrls` is the `vi.fn` backing the mocked provider; add it to the file's existing mock setup if it is not already a spy. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `vp test run apps/server/src/sourceControl/SourceControlRepositoryService.test.ts` +Expected: FAIL — the hostless lookup succeeds instead of failing, and `host` never reaches the provider. + +- [ ] **Step 3: Validate the host** + +```ts + const ensureConcreteProvider = (input: { + readonly operation: string; + readonly provider: SourceControlProviderKind; + readonly host?: string | undefined; + }) => { + if (input.provider === "unknown") { + return Effect.fail( + new SourceControlRepositoryError({ + operation: input.operation, + provider: input.provider, + detail: "Choose a source control provider before continuing.", + }), + ); + } + + if (input.provider === "github-enterprise" && !input.host?.trim()) { + return Effect.fail( + new SourceControlRepositoryError({ + operation: input.operation, + provider: input.provider, + detail: "Choose a GitHub Enterprise host before continuing.", + }), + ); + } + + return Effect.succeed(input.provider); + }; +``` + +- [ ] **Step 4: Forward the host at all three call sites** + +In `lookupRepository`: + +```ts + const providerKind = yield* ensureConcreteProvider({ + operation: "lookupRepository", + provider: input.provider, + host: input.host, + }); + const provider = yield* providers.get(providerKind); + const urls = yield* provider.getRepositoryCloneUrls({ + cwd: input.cwd ?? config.cwd, + repository: input.repository.trim(), + ...(input.host ? { host: input.host } : {}), + }); +``` + +In `cloneRepository`, add `...(input.host ? { host: input.host } : {})` to the inner `lookupRepository({...})` call. + +In `publishRepository`, pass `host: input.host` to `ensureConcreteProvider` and add `...(input.host ? { host: input.host } : {})` to the `provider.createRepository({...})` call. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `vp test run apps/server/src/sourceControl/SourceControlRepositoryService.test.ts` +Expected: PASS + +- [ ] **Step 6: Typecheck the server** + +Run: `vp run --filter t3-server typecheck` (confirm the package name with `grep '"name"' apps/server/package.json` and use whatever it reports) +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add apps/server/src/sourceControl/SourceControlRepositoryService.ts apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +git commit -m "feat(server): route repository operations to an enterprise host" +``` + +--- + +### Task 8: Web — accept enterprise pull request URLs + +**Files:** +- Modify: `apps/web/src/pullRequestReference.ts:1-2` +- Test: `apps/web/src/pullRequestReference.test.ts` (create if absent) + +**Interfaces:** +- Consumes: nothing. +- Produces: `parsePullRequestReference` accepts `https://///pull/`. + +- [ ] **Step 1: Write the failing test** + +Add to `apps/web/src/pullRequestReference.test.ts`, creating the file with `import { describe, expect, it } from "@effect/vitest";` and `import { parsePullRequestReference } from "./pullRequestReference.ts";` if it does not exist: + +```ts +describe("parsePullRequestReference enterprise hosts", () => { + it("accepts an enterprise pull request url", () => { + expect(parsePullRequestReference("https://git.corp.com/owner/repo/pull/42")).toBe( + "https://git.corp.com/owner/repo/pull/42", + ); + }); + + it("accepts a ghe.com pull request url", () => { + expect(parsePullRequestReference("https://acme.ghe.com/owner/repo/pull/7")).toBe( + "https://acme.ghe.com/owner/repo/pull/7", + ); + }); + + it("still accepts github.com urls and bare numbers", () => { + expect(parsePullRequestReference("https://github.com/owner/repo/pull/1")).toBe( + "https://github.com/owner/repo/pull/1", + ); + expect(parsePullRequestReference("#12")).toBe("12"); + }); + + it("still rejects a non pull request url", () => { + expect(parsePullRequestReference("https://git.corp.com/owner/repo/issues/42")).toBeNull(); + expect(parsePullRequestReference("https://git.corp.com/owner/repo")).toBeNull(); + }); + + it("still unwraps a gh cli checkout command", () => { + expect(parsePullRequestReference("gh pr checkout https://git.corp.com/owner/repo/pull/9")).toBe( + "https://git.corp.com/owner/repo/pull/9", + ); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `vp test run apps/web/src/pullRequestReference.test.ts` +Expected: FAIL — enterprise URLs return `null`. + +- [ ] **Step 3: Widen the pattern** + +```ts +const GITHUB_PULL_REQUEST_URL_PATTERN = + /^https:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; +``` + +Leave the GitLab and Azure DevOps patterns and the evaluation order in `parsePullRequestReference` exactly as they are. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `vp test run apps/web/src/pullRequestReference.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/pullRequestReference.ts apps/web/src/pullRequestReference.test.ts +git commit -m "fix(web): accept enterprise pull request urls" +``` + +--- + +### Task 9: Web settings — one row per connection + +**Files:** +- Modify: `apps/web/src/components/settings/SourceControlSettings.tsx:64-69`, `:565-575`, `:266-300` + +**Interfaces:** +- Consumes: `id` and `host` on `SourceControlProviderDiscoveryItem` (Task 1). +- Produces: no exports; UI only. + +- [ ] **Step 1: Map the enterprise icon** + +```ts +const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { + github: GitHubIcon, + "github-enterprise": GitHubIcon, + gitlab: GitLabIcon, + "azure-devops": AzureDevOpsIcon, + bitbucket: BitbucketIcon, +}; +``` + +- [ ] **Step 2: Key rows on the item id** + +```tsx + {result.sourceControlProviders.map((item) => ( + + ))} +``` + +Two enterprise connections previously collided on `kind` and React would have warned about duplicate keys. + +- [ ] **Step 3: Show the host beside the label** + +In `DiscoveryItemRow`, next to the `{item.label}` span, render the host when the item is a provider item, has a host, and the host differs from the label (so the `github` row does not read "GitHub github.com" and an enterprise row does not repeat its own hostname): + +```tsx + const host = isProviderDiscoveryItem(item) ? optionLabel(item.host) : null; +``` + +```tsx + {host && host !== item.label ? ( + {host} + ) : null} +``` + +- [ ] **Step 4: Typecheck the web app** + +Run: `vp run --filter @t3tools/web typecheck` (confirm the package name with `grep '"name"' apps/web/package.json`) +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/components/settings/SourceControlSettings.tsx +git commit -m "feat(web): list each GitHub Enterprise connection in settings" +``` + +--- + +### Task 10: Web — publish to an enterprise host + +**Files:** +- Modify: `apps/web/src/components/GitActionsControl.tsx:158-198` and the publish submit path + +**Interfaces:** +- Consumes: discovery `id`/`host` (Task 1); `host` on `SourceControlPublishRepositoryInput` (Task 1); server-side validation (Task 7). +- Produces: no exports; UI only. + +- [ ] **Step 1: Make the option list discovery-aware** + +Keep `PUBLISH_PROVIDER_OPTIONS` as the static list of the four hosted providers, then derive the rendered list. Add an `id` and `host` to the option shape: + +```ts +interface PublishProviderOption { + readonly id: string; + readonly value: PublishProviderKind; + readonly label: string; + readonly description: string; + readonly host: string; + readonly pathPlaceholder: string; + readonly Icon: typeof GitHubIcon; +} +``` + +Give each static entry an `id` equal to its `value` (`"github"`, `"gitlab"`, `"bitbucket"`, `"azure-devops"`), matching the discovery ids from Task 4. + +- [ ] **Step 2: Append one entry per enterprise connection** + +`GitActionsControl.tsx:379` already runs `sourceControlEnvironment.discovery({ environmentId, input: {} })` via `useEnvironmentQuery` — reuse that result rather than adding a second query. Insert enterprise entries directly after the `github` entry so related options stay adjacent: + +```ts +const enterpriseOptions: ReadonlyArray = discovery.sourceControlProviders + .filter((item) => item.kind === "github-enterprise" && item.status === "available") + .flatMap((item) => { + const host = Option.getOrNull(item.host); + if (!host) return []; + return [ + { + id: item.id, + value: "github-enterprise" as const, + label: "GitHub Enterprise", + description: host, + host, + pathPlaceholder: "owner/repo", + Icon: GitHubIcon, + }, + ]; + }); +``` + +Select the active option by `id`, not by `value` — two enterprise entries share the same `value`. + +- [ ] **Step 3: Send the host on publish** + +Where the publish command is dispatched, include the selected option's host for enterprise only: + +```ts + provider: selectedOption.value, + ...(selectedOption.value === "github-enterprise" ? { host: selectedOption.host } : {}), +``` + +- [ ] **Step 4: Widen `PublishProviderKind`** + +Find its declaration (`grep -rn "PublishProviderKind" apps/web/src packages`) and add `"github-enterprise"` so the union covers the new value. + +- [ ] **Step 5: Typecheck and lint the web app** + +Run: `vp run --filter @t3tools/web typecheck && vp lint apps/web/src/components/GitActionsControl.tsx` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/components/GitActionsControl.tsx +git commit -m "feat(web): publish repositories to a GitHub Enterprise host" +``` + +--- + +### Task 11: Client runtime — add-project sources per connection + +**Files:** +- Modify: `packages/client-runtime/src/operations/projects.ts:25-172` +- Test: `packages/client-runtime/src/operations/projects.test.ts` + +**Interfaces:** +- Consumes: discovery `id`/`host` (Task 1), enterprise discovery rows (Task 4). +- Produces: + - `AddProjectRemoteSource` stays `AddProjectRemoteProviderKind | "url"`, unchanged. + - New `AddProjectRemoteTarget = { readonly id: string; readonly source: AddProjectRemoteSource; readonly host: string | null }`. + - New `buildAddProjectRemoteTargets(discovery: SourceControlDiscoveryResult | null): ReadonlyArray` — always includes `{ id: "url", source: "url", host: null }` first, then one target per discovery provider row, enterprise rows carrying their host. + - `AddProjectRemoteSourceReadiness` becomes `ReadonlyMap` keyed by target `id`. + - `sortAddProjectProviderSources(readiness, targets)` returns `ReadonlyArray` excluding the `url` target. + - `addProjectRemoteTargetLabel(target)` returns the existing static labels, or the host for an enterprise target. + +- [ ] **Step 1: Write the failing test** + +Append to `packages/client-runtime/src/operations/projects.test.ts` (reuse the file's existing discovery fixture builder; if it builds items inline, extend it with `id` and `host`): + +```ts +describe("buildAddProjectRemoteTargets", () => { + it("returns url plus one target per discovered connection", () => { + const targets = buildAddProjectRemoteTargets( + discoveryResult([ + providerItem({ kind: "github", id: "github", host: "github.com" }), + providerItem({ + kind: "github-enterprise", + id: "github-enterprise:git.corp.com", + host: "git.corp.com", + label: "git.corp.com", + }), + ]), + ); + + expect(targets.map((target) => target.id)).toEqual([ + "url", + "github", + "github-enterprise:git.corp.com", + ]); + expect(targets[2]!.host).toBe("git.corp.com"); + expect(targets[2]!.source).toBe("github-enterprise"); + }); + + it("labels an enterprise target with its host", () => { + expect( + addProjectRemoteTargetLabel({ + id: "github-enterprise:git.corp.com", + source: "github-enterprise", + host: "git.corp.com", + }), + ).toBe("git.corp.com"); + }); + + it("keys readiness by target id", () => { + const discovery = discoveryResult([ + providerItem({ + kind: "github-enterprise", + id: "github-enterprise:git.corp.com", + host: "git.corp.com", + auth: { status: "unauthenticated", detail: "Run gh auth login." }, + }), + ]); + + const readiness = buildAddProjectRemoteSourceReadiness(discovery); + + expect(readiness.get("github-enterprise:git.corp.com")).toEqual({ + ready: false, + hint: "Run gh auth login.", + }); + expect(readiness.get("url")).toEqual({ ready: true, hint: null }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `vp test run packages/client-runtime/src/operations/projects.test.ts` +Expected: FAIL with "buildAddProjectRemoteTargets is not defined". + +- [ ] **Step 3: Add the target type and builder** + +```ts +export type AddProjectRemoteProviderKind = Extract< + SourceControlProviderKind, + "github" | "github-enterprise" | "gitlab" | "bitbucket" | "azure-devops" +>; +export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; + +export interface AddProjectRemoteTarget { + readonly id: string; + readonly source: AddProjectRemoteSource; + readonly host: string | null; +} + +const URL_TARGET: AddProjectRemoteTarget = { id: "url", source: "url", host: null }; + +export function buildAddProjectRemoteTargets( + discovery: SourceControlDiscoveryResult | null, +): ReadonlyArray { + if (!discovery) return [URL_TARGET]; + return [ + URL_TARGET, + ...discovery.sourceControlProviders.flatMap((provider) => + provider.kind === "unknown" + ? [] + : [{ id: provider.id, source: provider.kind, host: Option.getOrNull(provider.host) }], + ), + ]; +} +``` + +- [ ] **Step 4: Add the enterprise label and path hint** + +Add `case "github-enterprise": return "GitHub Enterprise";` to `addProjectRemoteSourceLabel` and `case "github-enterprise": return "owner/repo";` to `addProjectRemoteSourcePathHint`. Then add the target-aware label: + +```ts +export function addProjectRemoteTargetLabel(target: AddProjectRemoteTarget): string { + if (target.source === "github-enterprise" && target.host) { + return target.host; + } + return addProjectRemoteSourceLabel(target.source); +} +``` + +- [ ] **Step 5: Rekey readiness by target id** + +```ts +export type AddProjectRemoteSourceReadiness = ReadonlyMap< + string, + { readonly ready: boolean; readonly hint: string | null } +>; + +export function buildAddProjectRemoteSourceReadiness( + discovery: SourceControlDiscoveryResult | null, +): AddProjectRemoteSourceReadiness { + const readiness = new Map([ + ["url", { ready: true, hint: null }], + ]); + if (!discovery) return readiness; + + for (const provider of discovery.sourceControlProviders) { + if (provider.kind === "unknown") continue; + if (provider.status !== "available") { + readiness.set(provider.id, { ready: false, hint: provider.installHint }); + continue; + } + if (provider.auth.status === "unauthenticated") { + readiness.set(provider.id, { + ready: false, + hint: + Option.getOrNull(provider.auth.detail) ?? + `${provider.label} is not authenticated. Open Source Control settings for setup guidance.`, + }); + continue; + } + readiness.set(provider.id, { ready: true, hint: null }); + } + return readiness; +} +``` + +The previous "Provider status unavailable" fallback now applies to any target id missing from the map; consumers read it via a helper: + +```ts +export function addProjectRemoteTargetReadiness( + readiness: AddProjectRemoteSourceReadiness, + targetId: string, +): { readonly ready: boolean; readonly hint: string | null } { + return ( + readiness.get(targetId) ?? { + ready: false, + hint: "Provider status unavailable. Open Source Control settings and rescan.", + } + ); +} +``` + +- [ ] **Step 6: Sort targets instead of source literals** + +```ts +export function sortAddProjectProviderSources( + readinessBySource: AddProjectRemoteSourceReadiness, + targets: ReadonlyArray, +): ReadonlyArray { + return Arr.sort( + targets.filter((target) => target.source !== "url"), + Order.mapInput( + Order.Struct({ + ready: Order.flip(Order.Boolean), + label: Order.String, + }), + (target: AddProjectRemoteTarget) => ({ + ready: addProjectRemoteTargetReadiness(readinessBySource, target.id).ready, + label: addProjectRemoteTargetLabel(target), + }), + ), + ); +} +``` + +Delete `ADD_PROJECT_REMOTE_SOURCES` and `ADD_PROJECT_REMOTE_PROVIDER_SOURCES` — targets now come from discovery. + +- [ ] **Step 7: Update the existing tests in the file** + +The existing assertion `expect(sortAddProjectProviderSources(readiness)[0]).toBe("github")` (line 99) now needs the targets argument and compares `.id`: + +```ts +expect(sortAddProjectProviderSources(readiness, targets)[0]!.id).toBe("github"); +``` + +Update every other call site in the test file the same way. + +- [ ] **Step 8: Run test to verify it passes** + +Run: `vp test run packages/client-runtime/src/operations/projects.test.ts` +Expected: PASS + +- [ ] **Step 9: Commit** + +```bash +git add packages/client-runtime/src/operations/projects.ts packages/client-runtime/src/operations/projects.test.ts +git commit -m "feat(client-runtime): derive add-project sources from discovered connections" +``` + +--- + +### Task 12: Consumers — web command palette and mobile add project + +**Files:** +- Modify: `apps/web/src/components/CommandPalette.tsx:179-230` +- Modify: `apps/mobile/src/features/projects/AddProjectScreen.tsx:95-106`, `:371-390`, `:505-515`, `:605-640` +- Modify: `apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx:15-24` +- Modify: `apps/mobile/src/components/SourceControlIcon.tsx:3-16` + +**Interfaces:** +- Consumes: `AddProjectRemoteTarget`, `buildAddProjectRemoteTargets`, `addProjectRemoteTargetLabel`, `addProjectRemoteTargetReadiness`, and the new `sortAddProjectProviderSources` signature from Task 11. +- Produces: no exports; UI only. + +- [ ] **Step 1: Update the web command palette** + +`CommandPalette.tsx` declares its own local `AddProjectRemoteSource` alias and a static `REMOTE_PROJECT_SOURCES` array. Replace both with targets from `buildAddProjectRemoteTargets(discovery)`, render `addProjectRemoteTargetLabel(target)`, use `addProjectRemoteSourcePathHint(target.source)` for the hint, and carry `target.host` into the navigation params it dispatches. + +- [ ] **Step 2: Carry host through mobile route params** + +In `AddProjectRepositoryRoute.tsx`, add `host` to `AddProjectRepositoryRouteParams` and replace the hardcoded source check with a target-aware title: + +```tsx + const source = Array.isArray(params.source) ? params.source[0] : params.source; + const host = Array.isArray(params.host) ? params.host[0] : params.host; + const title = + source === "github" || + source === "github-enterprise" || + source === "gitlab" || + source === "bitbucket" || + source === "azure-devops" + ? addProjectRemoteTargetLabel({ id: source, source, host: host ?? null }) + : "Git URL"; +``` + +- [ ] **Step 3: Accept the enterprise source in mobile param parsing** + +In `AddProjectScreen.tsx`, add `source === "github-enterprise" ||` to the `sourceFromParam` guard, and thread a `host: string | null` alongside `source` through the screen's props and navigation calls. + +- [ ] **Step 4: Render targets in the mobile picker** + +Replace the `["url", ...sortAddProjectProviderSources(readiness)]` mapping at line 509 with targets: + +```tsx +{[ + { id: "url", source: "url", host: null } as AddProjectRemoteTarget, + ...sortAddProjectProviderSources(readiness, targets), +].map((target) => ( + // key on target.id, label with addProjectRemoteTargetLabel(target), + // readiness via addProjectRemoteTargetReadiness(readiness, target.id) +))} +``` + +- [ ] **Step 5: Send the host on lookup** + +In `lookupRepository` (line ~608), include the host: + +```ts + const result = await lookupRepositoryQuery({ + environmentId: environment.environmentId, + input: { + provider, + repository: repositoryInput.trim(), + ...(host ? { host } : {}), + }, + }); +``` + +- [ ] **Step 6: Accept the enterprise kind in the mobile icon** + +`AddProjectScreen.tsx:389` renders ``, passing the raw source rather than a presentation icon, so the union must widen. In `apps/mobile/src/components/SourceControlIcon.tsx`: + +```tsx +export type SourceControlIconKind = + | "github" + | "github-enterprise" + | "gitlab" + | "bitbucket" + | "azure-devops"; +``` + +Then make the existing GitHub arm serve both by replacing `case "github":` with: + +```tsx + case "github": + case "github-enterprise": +``` + +Leave the SVG body untouched — enterprise reuses the GitHub mark. + +- [ ] **Step 7: Typecheck web and mobile** + +Run: `vp run --filter @t3tools/web typecheck && vp run --filter @t3tools/mobile typecheck` (confirm both package names from their `package.json` files) +Expected: PASS + +- [ ] **Step 8: Run the touched test files** + +Run: `vp test run packages/client-runtime/src/operations/projects.test.ts packages/shared/src/sourceControl.test.ts apps/web/src/pullRequestReference.test.ts apps/server/src/sourceControl/` +Expected: PASS + +- [ ] **Step 9: Commit** + +```bash +git add apps/web/src/components/CommandPalette.tsx apps/mobile/src/features/projects/AddProjectScreen.tsx apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx apps/mobile/src/components/SourceControlIcon.tsx +git commit -m "feat(web,mobile): offer GitHub Enterprise connections when adding a project" +``` + +--- + +## Manual verification + +After Task 12, on a machine with `gh` authenticated against an enterprise host (`gh auth login --hostname `): + +1. Open Source Control settings. Expect a `GitHub` row plus one row per enterprise host, each showing its own account. +2. Open a repository cloned from the enterprise host. Expect PR list, PR view, and create-PR to work and to say "pull request". +3. Paste an enterprise PR URL into the PR reference field. Expect it to be accepted. +4. Add Project → expect the enterprise host listed as its own source; look up `owner/repo` against it. + +Ask before launching a dev server or browser. If asked to verify in a real client, use the `test-t3-app` skill for web and `test-t3-mobile` for mobile. + +## Notes for the implementer + +- `parseGitHubAuthStatus` lowercases hosts already (`gitHubAuthStatus.ts:53`). Compare hosts lowercased everywhere else. +- `gh auth status --json hosts` is only available on newer `gh`. The existing code already tolerates unparseable output by falling back to `parseGitHubAuth`; Task 4's `status.parsed` check preserves that path — do not remove it. +- Provider-context detection is cached for 5 seconds (`SourceControlProviderRegistry.ts:29`). When manually testing detection changes, wait that long or restart the server. From 096d0a94d4d7801cae9a2a66880489ae683f115a Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 20:13:20 +0200 Subject: [PATCH 03/34] feat(contracts): add github-enterprise kind and discovery identity --- packages/contracts/src/sourceControl.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 104aadd9161..d1007a103a4 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -4,6 +4,7 @@ import { VcsDriverKind } from "./vcs.ts"; export const SourceControlProviderKind = Schema.Literals([ "github", + "github-enterprise", "gitlab", "azure-devops", "bitbucket", @@ -60,6 +61,7 @@ export type SourceControlRepositoryInfo = typeof SourceControlRepositoryInfo.Typ export const SourceControlRepositoryLookupInput = Schema.Struct({ provider: SourceControlProviderKind, repository: TrimmedNonEmptyString, + host: Schema.optional(TrimmedNonEmptyString), cwd: Schema.optional(TrimmedNonEmptyString), }); export type SourceControlRepositoryLookupInput = typeof SourceControlRepositoryLookupInput.Type; @@ -69,6 +71,7 @@ export const SourceControlCloneRepositoryInput = Schema.Struct({ repository: Schema.optional(TrimmedNonEmptyString), remoteUrl: Schema.optional(TrimmedNonEmptyString), destinationPath: TrimmedNonEmptyString, + host: Schema.optional(TrimmedNonEmptyString), protocol: Schema.optional(SourceControlCloneProtocol), }); export type SourceControlCloneRepositoryInput = typeof SourceControlCloneRepositoryInput.Type; @@ -86,6 +89,7 @@ export const SourceControlPublishRepositoryInput = Schema.Struct({ repository: TrimmedNonEmptyString, visibility: SourceControlRepositoryVisibility, remoteName: Schema.optional(TrimmedNonEmptyString), + host: Schema.optional(TrimmedNonEmptyString), protocol: Schema.optional(SourceControlCloneProtocol), }); export type SourceControlPublishRepositoryInput = typeof SourceControlPublishRepositoryInput.Type; @@ -139,6 +143,8 @@ export type VcsDiscoveryItem = typeof VcsDiscoveryItem.Type; export const SourceControlProviderDiscoveryItem = Schema.Struct({ kind: SourceControlProviderKind, + id: TrimmedNonEmptyString, + host: Schema.Option(TrimmedNonEmptyString), ...SourceControlDiscoverySharedFields, auth: SourceControlProviderAuth, }); From 1932ee263e96e9ce5010b9c2bb22380a25275137 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 20:23:14 +0200 Subject: [PATCH 04/34] docs: correct test-file assumptions in the enterprise connector plan Every test file the plan touches already exists, and the repo mixes @effect/vitest with vite-plus/test per file. Tasks 2 and 8 said to create files that are already there. --- .../2026-07-30-github-enterprise-connector.md | 374 ++++++++++-------- 1 file changed, 211 insertions(+), 163 deletions(-) diff --git a/docs/superpowers/plans/2026-07-30-github-enterprise-connector.md b/docs/superpowers/plans/2026-07-30-github-enterprise-connector.md index 6ff9f0046e8..491e041ff91 100644 --- a/docs/superpowers/plans/2026-07-30-github-enterprise-connector.md +++ b/docs/superpowers/plans/2026-07-30-github-enterprise-connector.md @@ -13,7 +13,8 @@ - Spec: `docs/superpowers/specs/2026-07-30-github-enterprise-connector-design.md`. Read it before starting. - Effect-heavy server code: read `.repos/effect-smol/LLMS.md` before writing Effect code. Never import from `.repos/`. - Run only targeted checks: `vp test run ` for tests you touched, plus targeted lint/typecheck. **Never** run `vp check`, `vp run -r test`, or `vp run -r typecheck`. -- Test style is `@effect/vitest`: `import { describe, expect, it } from "@effect/vitest"`, `it.effect` for Effect-returning tests, `Layer.mock(Service)({...})` for fakes. Match the surrounding file. +- Test style varies by file and the file you are editing wins. `apps/server/src/sourceControl/*.test.ts` uses `@effect/vitest` (`it.effect` for Effect-returning tests, `Layer.mock(Service)({...})` for fakes). `packages/shared/src/sourceControl.test.ts`, `packages/client-runtime/src/operations/projects.test.ts`, and `apps/web/src/pullRequestReference.test.ts` use `vite-plus/test`. Check the first line of the file before writing; for a genuinely new file, copy its nearest sibling. +- **Every test file this plan touches already exists.** Merge new cases into the existing `describe` blocks — never overwrite a file, never add a parallel duplicate block, never delete existing coverage. - `packages/contracts` holds Effect/Schema contracts plus small derived helpers — no heavy runtime logic. `packages/shared` has subpath exports and no barrel. - Inferred types over annotations. `any` is the enemy. - Branch is `feat/github-enterprise-connector`, already created and checked out. Commit after each task. @@ -24,27 +25,33 @@ ## File Structure **Contracts** + - Modify `packages/contracts/src/sourceControl.ts` — new kind literal; `id`/`host` on the discovery item; optional `host` on three inputs. **Shared** + - Modify `packages/shared/src/sourceControl.ts` — enterprise host classification, enterprise presentation. - Create `packages/shared/src/sourceControl.test.ts` — detection + presentation tests (no test file exists for this module today). **Server — discovery plumbing** + - Modify `apps/server/src/sourceControl/SourceControlProviderDiscovery.ts` — `expandInstances` hook; probe returns an array. - Modify `apps/server/src/sourceControl/SourceControlProviderRegistry.ts` — optional discovery spec; flatten discovery; register `github-enterprise`. **Server — GitHub** + - Modify `apps/server/src/sourceControl/GitHubSourceControlProvider.ts` — `refineUnknownRemote`, `expandInstances`, kind-parametrized provider factory. - Modify `apps/server/src/sourceControl/GitHubCli.ts` — optional `host` → `GH_HOST`; host-aware clone-url fallback. - Modify `apps/server/src/sourceControl/SourceControlRepositoryService.ts` — thread `host`; reject hostless `github-enterprise`. **Web** + - Modify `apps/web/src/pullRequestReference.ts` — host-agnostic PR URL pattern. - Modify `apps/web/src/components/settings/SourceControlSettings.tsx` — key rows on `id`, render `host`, enterprise icon. - Modify `apps/web/src/components/GitActionsControl.tsx` — enterprise entries in the publish picker. **Client runtime + mobile** + - Modify `packages/client-runtime/src/operations/projects.ts` — dynamic add-project sources keyed by discovery `id`. - Modify `apps/mobile/src/features/projects/AddProjectScreen.tsx` and `AddProjectRepositoryRoute.tsx` — accept enterprise sources. @@ -59,9 +66,11 @@ ### Task 1: Contract — new kind and discovery identity **Files:** + - Modify: `packages/contracts/src/sourceControl.ts:5-11`, `:52-104`, `:140-145` **Interfaces:** + - Consumes: nothing. - Produces: `SourceControlProviderKind` now includes `"github-enterprise"`. `SourceControlProviderDiscoveryItem` gains `id: string` and `host: Option.Option`. `SourceControlRepositoryLookupInput`, `SourceControlCloneRepositoryInput`, and `SourceControlPublishRepositoryInput` each gain `host?: string`. @@ -126,10 +135,12 @@ git commit -m "feat(contracts): add github-enterprise kind and discovery identit ### Task 2: Shared — enterprise host detection and presentation **Files:** + - Modify: `packages/shared/src/sourceControl.ts:24-33`, `:77-93`, `:170-232` -- Create: `packages/shared/src/sourceControl.test.ts` +- Modify: `packages/shared/src/sourceControl.test.ts` (exists; uses `vite-plus/test`) **Interfaces:** + - Consumes: `SourceControlProviderKind` from Task 1. - Produces: `detectSourceControlProviderFromRemoteUrl` returns `kind: "github-enterprise"` for `*.ghe.com` and `github.*` hosts. `resolveChangeRequestPresentation` handles the new kind, returning `icon: "github"`. @@ -155,11 +166,13 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { }); it("classifies a ghe.com tenant as github-enterprise", () => { - expect(detectSourceControlProviderFromRemoteUrl("https://acme.ghe.com/owner/repo.git")).toEqual({ - kind: "github-enterprise", - name: "acme.ghe.com", - baseUrl: "https://acme.ghe.com", - }); + expect(detectSourceControlProviderFromRemoteUrl("https://acme.ghe.com/owner/repo.git")).toEqual( + { + kind: "github-enterprise", + name: "acme.ghe.com", + baseUrl: "https://acme.ghe.com", + }, + ); }); // Previously classified as kind "github" / name "GitHub Self-Hosted". @@ -173,11 +186,13 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { }); it("leaves an arbitrary GHES hostname unknown for CLI refinement", () => { - expect(detectSourceControlProviderFromRemoteUrl("https://git.corp.com/owner/repo.git")).toEqual({ - kind: "unknown", - name: "git.corp.com", - baseUrl: "https://git.corp.com", - }); + expect(detectSourceControlProviderFromRemoteUrl("https://git.corp.com/owner/repo.git")).toEqual( + { + kind: "unknown", + name: "git.corp.com", + baseUrl: "https://git.corp.com", + }, + ); }); it("still classifies gitlab hosts as gitlab", () => { @@ -250,21 +265,21 @@ function isGitHubEnterpriseHost(host: string): boolean { In `detectSourceControlProviderFromRemoteUrl`, replace the existing GitHub block with: ```ts - if (isGitHubHost(hostname)) { - return { - kind: "github", - name: "GitHub", - baseUrl: toBaseUrl(host), - }; - } +if (isGitHubHost(hostname)) { + return { + kind: "github", + name: "GitHub", + baseUrl: toBaseUrl(host), + }; +} - if (isGitHubEnterpriseHost(hostname)) { - return { - kind: "github-enterprise", - name: hostname, - baseUrl: toBaseUrl(host), - }; - } +if (isGitHubEnterpriseHost(hostname)) { + return { + kind: "github-enterprise", + name: hostname, + baseUrl: toBaseUrl(host), + }; +} ``` Leave the GitLab, Azure DevOps, Bitbucket, and `unknown` branches exactly as they are. @@ -286,11 +301,13 @@ git commit -m "feat(shared): detect and present GitHub Enterprise hosts" ### Task 3: Discovery plumbing — one spec, many rows **Files:** + - Modify: `apps/server/src/sourceControl/SourceControlProviderDiscovery.ts:31-40`, `:203-268` - Modify: `apps/server/src/sourceControl/SourceControlProviderRegistry.ts:31-35`, `:196-284` - Test: `apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts` **Interfaces:** + - Consumes: `SourceControlProviderDiscoveryItem` from Task 1. - Produces: `SourceControlCliDiscoverySpec` gains optional `expandInstances`. `probeSourceControlProvider` returns `Effect.Effect>` (was a single item). `SourceControlProviderRegistration.discovery` becomes optional. `SourceControlProviderRegistry.discover` stays `Effect.Effect>`, now flattened. @@ -409,66 +426,68 @@ Change `probeSourceControlProvider`'s return type to `Effect.Effect [ - { - kind: input.spec.kind, - id: input.spec.kind, - host: Option.none(), - label: input.spec.label, - status: "available" as const, - version: Option.none(), - installHint: input.spec.installHint, - detail: Option.none(), - auth, - } satisfies SourceControlProviderDiscoveryItem, - ]), - ); - } +if (input.spec.type === "api") { + return input.spec.probeAuth.pipe( + Effect.map((auth) => [ + { + kind: input.spec.kind, + id: input.spec.kind, + host: Option.none(), + label: input.spec.label, + status: "available" as const, + version: Option.none(), + installHint: input.spec.installHint, + detail: Option.none(), + auth, + } satisfies SourceControlProviderDiscoveryItem, + ]), + ); +} ``` In the `cli` branch, the missing-executable path returns a one-element array with `id: spec.kind` and `host: Option.none()`. On the auth-probe success path, consult `expandInstances`: ```ts - return input.process - .run({ /* unchanged auth probe arguments */ }) - .pipe( - Effect.map((result) => { - const instances = spec.expandInstances?.(result); - if (instances) { - return instances.map( - (instance) => - ({ - ...item, - kind: instance.kind, - id: instance.id, - host: instance.host === null ? Option.none() : Option.some(instance.host), - label: instance.label, - auth: instance.auth, - }) satisfies SourceControlProviderDiscoveryItem, - ); - } - return [ - { - ...item, - id: spec.kind, - host: Option.none(), - auth: spec.parseAuth(result), - } satisfies SourceControlProviderDiscoveryItem, - ]; - }), - Effect.catch((cause) => - Effect.succeed([ - { - ...item, - id: spec.kind, - host: Option.none(), - auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), - } satisfies SourceControlProviderDiscoveryItem, - ]), - ), +return input.process + .run({ + /* unchanged auth probe arguments */ + }) + .pipe( + Effect.map((result) => { + const instances = spec.expandInstances?.(result); + if (instances) { + return instances.map( + (instance) => + ({ + ...item, + kind: instance.kind, + id: instance.id, + host: instance.host === null ? Option.none() : Option.some(instance.host), + label: instance.label, + auth: instance.auth, + }) satisfies SourceControlProviderDiscoveryItem, ); + } + return [ + { + ...item, + id: spec.kind, + host: Option.none(), + auth: spec.parseAuth(result), + } satisfies SourceControlProviderDiscoveryItem, + ]; + }), + Effect.catch((cause) => + Effect.succeed([ + { + ...item, + id: spec.kind, + host: Option.none(), + auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), + } satisfies SourceControlProviderDiscoveryItem, + ]), + ), + ); ``` `DiscoveryProbeResult` (the internal `probeCli` result) is unchanged — identity fields are attached here, not there. @@ -488,9 +507,9 @@ export interface SourceControlProviderRegistration { Filter out registrations without a spec: ```ts - const discoverySpecs = registrations.flatMap((registration) => - registration.discovery ? [registration.discovery] : [], - ); +const discoverySpecs = registrations.flatMap((registration) => + registration.discovery ? [registration.discovery] : [], +); ``` Flatten the discovery result: @@ -525,10 +544,12 @@ git commit -m "feat(server): let a discovery spec expand into multiple provider ### Task 4: GitHub spec — enterprise expansion and unknown-remote refinement **Files:** + - Modify: `apps/server/src/sourceControl/GitHubSourceControlProvider.ts:85-95` - Test: `apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts` **Interfaces:** + - Consumes: `SourceControlDiscoveryInstance` and the `expandInstances` hook from Task 3; `parseGitHubAuthStatus` / `findAuthenticatedGitHubAccount` from `./gitHubAuthStatus.ts` (unchanged). - Produces: `discovery` gains `expandInstances: expandGitHubInstances` and `refineUnknownRemote: refineUnknownGitHubRemote`. Both are module-local functions, exported for test access. @@ -558,7 +579,9 @@ const probe = (stdout: string) => ({ describe("expandGitHubInstances", () => { it("emits only a github row when no enterprise host is logged in", () => { const instances = GitHubSourceControlProvider.expandGitHubInstances( - probe(authStatusJson({ "github.com": [{ login: "octocat", state: "success", active: true }] })), + probe( + authStatusJson({ "github.com": [{ login: "octocat", state: "success", active: true }] }), + ), ); expect(instances.map((instance) => instance.id)).toEqual(["github"]); @@ -617,7 +640,9 @@ describe("refineUnknownGitHubRemote", () => { const refined = GitHubSourceControlProvider.refineUnknownGitHubRemote({ cwd: "/repo", context, - auth: probe(authStatusJson({ "git.corp.com": [{ login: "dev", state: "success", active: true }] })), + auth: probe( + authStatusJson({ "git.corp.com": [{ login: "dev", state: "success", active: true }] }), + ), }); expect(refined).toEqual({ @@ -677,7 +702,8 @@ function githubComAuth(status: ReturnType) { status: "unauthenticated", host: "github.com", detail: - accounts[0]?.error ?? "Run `gh auth login` to authenticate GitHub CLI with an active account.", + accounts[0]?.error ?? + "Run `gh auth login` to authenticate GitHub CLI with an active account.", }); } @@ -787,10 +813,12 @@ git commit -m "feat(server): expand gh auth hosts into enterprise connections" ### Task 5: GitHubCli — host targeting via `GH_HOST` **Files:** + - Modify: `apps/server/src/sourceControl/GitHubCli.ts:199-248`, `:269-304`, `:306-453` - Test: `apps/server/src/sourceControl/GitHubCli.test.ts` **Interfaces:** + - Consumes: nothing from earlier tasks. - Produces: `GitHubCli.execute`, `getRepositoryCloneUrls`, and `createRepository` accept an optional `host?: string`. When present, the spawned process env is `{ ...process.env, GH_HOST: host }`; when absent, no `env` key is passed at all. `deriveRepositoryCloneUrlsFromCreateOutput(stdout, repository, host)` takes the fallback host as its third parameter. @@ -882,17 +910,17 @@ In the `GitHubCli` service declaration, add `readonly host?: string;` to the inp - [ ] **Step 4: Set `GH_HOST` in `execute`** ```ts - const execute: GitHubCli["Service"]["execute"] = (input) => - process - .run({ - operation: "GitHubCli.execute", - command: "gh", - args: input.args, - cwd: input.cwd, - ...(input.host ? { env: { ...globalThis.process.env, GH_HOST: input.host } } : {}), - timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, - }) - .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); +const execute: GitHubCli["Service"]["execute"] = (input) => + process + .run({ + operation: "GitHubCli.execute", + command: "gh", + args: input.args, + cwd: input.cwd, + ...(input.host ? { env: { ...globalThis.process.env, GH_HOST: input.host } } : {}), + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }) + .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); ``` `globalThis.process` is required because `process` is shadowed by the `VcsProcess` service binding in this scope. @@ -938,11 +966,13 @@ git commit -m "feat(server): target enterprise hosts with GH_HOST in GitHubCli" ### Task 6: Kind-parametrized GitHub provider and registration **Files:** + - Modify: `apps/server/src/sourceControl/GitHubSourceControlProvider.ts:23-43`, `:97-301` - Modify: `apps/server/src/sourceControl/SourceControlProviderRegistry.ts:286-314` - Test: `apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts` **Interfaces:** + - Consumes: `host` on `GitHubCli` from Task 5; optional `discovery` on registrations from Task 3. - Produces: `makeProvider(kind: "github" | "github-enterprise")` returns the provider effect. `make` is kept as `makeProvider("github")` so existing importers (including `layer`) keep working. `SourceControlProvider.getRepositoryCloneUrls` and `createRepository` accept an optional `host?: string` in their input, forwarded to `GitHubCli`. @@ -1088,10 +1118,12 @@ git commit -m "feat(server): register github-enterprise provider on the gh CLI" ### Task 7: Repository service — host threading and validation **Files:** + - Modify: `apps/server/src/sourceControl/SourceControlRepositoryService.ts:97-127`, `:180-232` - Test: `apps/server/src/sourceControl/SourceControlRepositoryService.test.ts` **Interfaces:** + - Consumes: `host` on the three contract inputs (Task 1); `host` on provider operations (Task 6). - Produces: `lookupRepository`, `cloneRepository`, and `publishRepository` forward `input.host`. `ensureConcreteProvider` gains a `host` parameter and fails when `provider === "github-enterprise"` and no host is present. @@ -1108,9 +1140,7 @@ describe("github-enterprise host requirement", () => { .lookupRepository({ provider: "github-enterprise", repository: "owner/repo" }) .pipe(Effect.flip); - expect(error.detail).toBe( - "Choose a GitHub Enterprise host before continuing.", - ); + expect(error.detail).toBe("Choose a GitHub Enterprise host before continuing."); }), ); @@ -1139,33 +1169,33 @@ Expected: FAIL — the hostless lookup succeeds instead of failing, and `host` n - [ ] **Step 3: Validate the host** ```ts - const ensureConcreteProvider = (input: { - readonly operation: string; - readonly provider: SourceControlProviderKind; - readonly host?: string | undefined; - }) => { - if (input.provider === "unknown") { - return Effect.fail( - new SourceControlRepositoryError({ - operation: input.operation, - provider: input.provider, - detail: "Choose a source control provider before continuing.", - }), - ); - } +const ensureConcreteProvider = (input: { + readonly operation: string; + readonly provider: SourceControlProviderKind; + readonly host?: string | undefined; +}) => { + if (input.provider === "unknown") { + return Effect.fail( + new SourceControlRepositoryError({ + operation: input.operation, + provider: input.provider, + detail: "Choose a source control provider before continuing.", + }), + ); + } - if (input.provider === "github-enterprise" && !input.host?.trim()) { - return Effect.fail( - new SourceControlRepositoryError({ - operation: input.operation, - provider: input.provider, - detail: "Choose a GitHub Enterprise host before continuing.", - }), - ); - } + if (input.provider === "github-enterprise" && !input.host?.trim()) { + return Effect.fail( + new SourceControlRepositoryError({ + operation: input.operation, + provider: input.provider, + detail: "Choose a GitHub Enterprise host before continuing.", + }), + ); + } - return Effect.succeed(input.provider); - }; + return Effect.succeed(input.provider); +}; ``` - [ ] **Step 4: Forward the host at all three call sites** @@ -1173,17 +1203,21 @@ Expected: FAIL — the hostless lookup succeeds instead of failing, and `host` n In `lookupRepository`: ```ts - const providerKind = yield* ensureConcreteProvider({ - operation: "lookupRepository", - provider: input.provider, - host: input.host, - }); - const provider = yield* providers.get(providerKind); - const urls = yield* provider.getRepositoryCloneUrls({ - cwd: input.cwd ?? config.cwd, - repository: input.repository.trim(), - ...(input.host ? { host: input.host } : {}), - }); +const providerKind = + yield * + ensureConcreteProvider({ + operation: "lookupRepository", + provider: input.provider, + host: input.host, + }); +const provider = yield * providers.get(providerKind); +const urls = + yield * + provider.getRepositoryCloneUrls({ + cwd: input.cwd ?? config.cwd, + repository: input.repository.trim(), + ...(input.host ? { host: input.host } : {}), + }); ``` In `cloneRepository`, add `...(input.host ? { host: input.host } : {})` to the inner `lookupRepository({...})` call. @@ -1212,16 +1246,18 @@ git commit -m "feat(server): route repository operations to an enterprise host" ### Task 8: Web — accept enterprise pull request URLs **Files:** + - Modify: `apps/web/src/pullRequestReference.ts:1-2` -- Test: `apps/web/src/pullRequestReference.test.ts` (create if absent) +- Test: `apps/web/src/pullRequestReference.test.ts` (exists; uses `vite-plus/test`) **Interfaces:** + - Consumes: nothing. - Produces: `parsePullRequestReference` accepts `https://///pull/`. - [ ] **Step 1: Write the failing test** -Add to `apps/web/src/pullRequestReference.test.ts`, creating the file with `import { describe, expect, it } from "@effect/vitest";` and `import { parsePullRequestReference } from "./pullRequestReference.ts";` if it does not exist: +Merge these cases into the existing `apps/web/src/pullRequestReference.test.ts` (it already imports `describe`/`expect`/`it` from `vite-plus/test` and `parsePullRequestReference` from `./pullRequestReference`). Fold them into the existing describe blocks where one fits; do not overwrite the file or duplicate existing assertions: ```ts describe("parsePullRequestReference enterprise hosts", () => { @@ -1288,9 +1324,11 @@ git commit -m "fix(web): accept enterprise pull request urls" ### Task 9: Web settings — one row per connection **Files:** + - Modify: `apps/web/src/components/settings/SourceControlSettings.tsx:64-69`, `:565-575`, `:266-300` **Interfaces:** + - Consumes: `id` and `host` on `SourceControlProviderDiscoveryItem` (Task 1). - Produces: no exports; UI only. @@ -1309,9 +1347,11 @@ const SOURCE_CONTROL_PROVIDER_ICONS: Partial ( - - ))} +{ + result.sourceControlProviders.map((item) => ( + + )); +} ``` Two enterprise connections previously collided on `kind` and React would have warned about duplicate keys. @@ -1321,13 +1361,15 @@ Two enterprise connections previously collided on `kind` and React would have wa In `DiscoveryItemRow`, next to the `{item.label}` span, render the host when the item is a provider item, has a host, and the host differs from the label (so the `github` row does not read "GitHub github.com" and an enterprise row does not repeat its own hostname): ```tsx - const host = isProviderDiscoveryItem(item) ? optionLabel(item.host) : null; +const host = isProviderDiscoveryItem(item) ? optionLabel(item.host) : null; ``` ```tsx - {host && host !== item.label ? ( - {host} - ) : null} +{ + host && host !== item.label ? ( + {host} + ) : null; +} ``` - [ ] **Step 4: Typecheck the web app** @@ -1347,9 +1389,11 @@ git commit -m "feat(web): list each GitHub Enterprise connection in settings" ### Task 10: Web — publish to an enterprise host **Files:** + - Modify: `apps/web/src/components/GitActionsControl.tsx:158-198` and the publish submit path **Interfaces:** + - Consumes: discovery `id`/`host` (Task 1); `host` on `SourceControlPublishRepositoryInput` (Task 1); server-side validation (Task 7). - Produces: no exports; UI only. @@ -1427,10 +1471,12 @@ git commit -m "feat(web): publish repositories to a GitHub Enterprise host" ### Task 11: Client runtime — add-project sources per connection **Files:** + - Modify: `packages/client-runtime/src/operations/projects.ts:25-172` - Test: `packages/client-runtime/src/operations/projects.test.ts` **Interfaces:** + - Consumes: discovery `id`/`host` (Task 1), enterprise discovery rows (Task 4). - Produces: - `AddProjectRemoteSource` stays `AddProjectRemoteProviderKind | "url"`, unchanged. @@ -1654,12 +1700,14 @@ git commit -m "feat(client-runtime): derive add-project sources from discovered ### Task 12: Consumers — web command palette and mobile add project **Files:** + - Modify: `apps/web/src/components/CommandPalette.tsx:179-230` - Modify: `apps/mobile/src/features/projects/AddProjectScreen.tsx:95-106`, `:371-390`, `:505-515`, `:605-640` - Modify: `apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx:15-24` - Modify: `apps/mobile/src/components/SourceControlIcon.tsx:3-16` **Interfaces:** + - Consumes: `AddProjectRemoteTarget`, `buildAddProjectRemoteTargets`, `addProjectRemoteTargetLabel`, `addProjectRemoteTargetReadiness`, and the new `sortAddProjectProviderSources` signature from Task 11. - Produces: no exports; UI only. @@ -1672,16 +1720,16 @@ git commit -m "feat(client-runtime): derive add-project sources from discovered In `AddProjectRepositoryRoute.tsx`, add `host` to `AddProjectRepositoryRouteParams` and replace the hardcoded source check with a target-aware title: ```tsx - const source = Array.isArray(params.source) ? params.source[0] : params.source; - const host = Array.isArray(params.host) ? params.host[0] : params.host; - const title = - source === "github" || - source === "github-enterprise" || - source === "gitlab" || - source === "bitbucket" || - source === "azure-devops" - ? addProjectRemoteTargetLabel({ id: source, source, host: host ?? null }) - : "Git URL"; +const source = Array.isArray(params.source) ? params.source[0] : params.source; +const host = Array.isArray(params.host) ? params.host[0] : params.host; +const title = + source === "github" || + source === "github-enterprise" || + source === "gitlab" || + source === "bitbucket" || + source === "azure-devops" + ? addProjectRemoteTargetLabel({ id: source, source, host: host ?? null }) + : "Git URL"; ``` - [ ] **Step 3: Accept the enterprise source in mobile param parsing** @@ -1707,14 +1755,14 @@ Replace the `["url", ...sortAddProjectProviderSources(readiness)]` mapping at li In `lookupRepository` (line ~608), include the host: ```ts - const result = await lookupRepositoryQuery({ - environmentId: environment.environmentId, - input: { - provider, - repository: repositoryInput.trim(), - ...(host ? { host } : {}), - }, - }); +const result = await lookupRepositoryQuery({ + environmentId: environment.environmentId, + input: { + provider, + repository: repositoryInput.trim(), + ...(host ? { host } : {}), + }, +}); ``` - [ ] **Step 6: Accept the enterprise kind in the mobile icon** From 456f569f3c999ae9ac621320f376c3d1b39fd17b Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 20:23:55 +0200 Subject: [PATCH 05/34] feat(shared): detect and present GitHub Enterprise hosts --- packages/shared/src/sourceControl.test.ts | 58 +++++++++++++++++++++++ packages/shared/src/sourceControl.ts | 29 +++++++++++- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index 368e8387ee6..6ec0cbb05db 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -39,6 +39,20 @@ describe("source control presentation", () => { }), ); }); + + it("presents github-enterprise with GitHub PR terminology and icon", () => { + const presentation = resolveChangeRequestPresentation({ + kind: "github-enterprise", + name: "git.corp.com", + baseUrl: "https://git.corp.com", + }); + + expect(presentation.icon).toBe("github"); + expect(presentation.shortName).toBe("PR"); + expect(presentation.longName).toBe("pull request"); + expect(presentation.providerName).toBe("GitHub Enterprise"); + expect(presentation.checkoutCommandExample).toBe("gh pr checkout 123"); + }); }); describe("detectSourceControlProviderFromRemoteUrl", () => { @@ -75,4 +89,48 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { baseUrl: "https://self-hosted.example.test:8443", }); }); + + it("classifies github.com as github", () => { + expect(detectSourceControlProviderFromRemoteUrl("https://github.com/owner/repo.git")).toEqual({ + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }); + }); + + it("classifies a ghe.com tenant as github-enterprise", () => { + expect(detectSourceControlProviderFromRemoteUrl("https://acme.ghe.com/owner/repo.git")).toEqual( + { + kind: "github-enterprise", + name: "acme.ghe.com", + baseUrl: "https://acme.ghe.com", + }, + ); + }); + + // Previously classified as kind "github" / name "GitHub Self-Hosted". + // Reclassification is intended; nothing persists the kind. + it("classifies a github-prefixed corporate host as github-enterprise", () => { + expect(detectSourceControlProviderFromRemoteUrl("git@github.acme.com:owner/repo.git")).toEqual({ + kind: "github-enterprise", + name: "github.acme.com", + baseUrl: "https://github.acme.com", + }); + }); + + it("leaves an arbitrary GHES hostname unknown for CLI refinement", () => { + expect(detectSourceControlProviderFromRemoteUrl("https://git.corp.com/owner/repo.git")).toEqual( + { + kind: "unknown", + name: "git.corp.com", + baseUrl: "https://git.corp.com", + }, + ); + }); + + it("still classifies gitlab hosts as gitlab", () => { + expect( + detectSourceControlProviderFromRemoteUrl("https://gitlab.acme.com/group/project.git")?.kind, + ).toBe("gitlab"); + }); }); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 15a98dc7355..cb1edec2cbb 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -32,6 +32,17 @@ const GITHUB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { urlExample: "https://github.com/owner/repo/pull/42", }; +const GITHUB_ENTERPRISE_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "github", + providerName: "GitHub Enterprise", + shortName: "PR", + longName: "pull request", + pluralLongName: "pull requests", + providerLongName: "GitHub Enterprise pull request", + checkoutCommandExample: "gh pr checkout 123", + urlExample: "https://git.company.com/owner/repo/pull/42", +}; + const GITLAB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { icon: "gitlab", providerName: "GitLab", @@ -81,6 +92,8 @@ export function resolveChangeRequestPresentation( case "github": case undefined: return GITHUB_CHANGE_REQUEST_PRESENTATION; + case "github-enterprise": + return GITHUB_ENTERPRISE_CHANGE_REQUEST_PRESENTATION; case "gitlab": return GITLAB_CHANGE_REQUEST_PRESENTATION; case "azure-devops": @@ -168,7 +181,11 @@ function toBaseUrl(host: string): string { } function isGitHubHost(host: string): boolean { - return host === "github.com" || host.includes("github"); + return host === "github.com"; +} + +function isGitHubEnterpriseHost(host: string): boolean { + return host !== "github.com" && (host.endsWith(".ghe.com") || host.includes("github")); } function isGitLabHost(host: string): boolean { @@ -195,7 +212,15 @@ export function detectSourceControlProviderFromRemoteUrl( if (isGitHubHost(hostname)) { return { kind: "github", - name: hostname === "github.com" ? "GitHub" : "GitHub Self-Hosted", + name: "GitHub", + baseUrl: toBaseUrl(host), + }; + } + + if (isGitHubEnterpriseHost(hostname)) { + return { + kind: "github-enterprise", + name: hostname, baseUrl: toBaseUrl(host), }; } From 025331c960bc1f4a4a8fbcde9df0e035aec3480a Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 20:32:06 +0200 Subject: [PATCH 06/34] feat(server): let a discovery spec expand into multiple provider rows --- .../SourceControlProviderDiscovery.ts | 90 +++++++++++++------ .../SourceControlProviderRegistry.test.ts | 77 +++++++++++++++- .../SourceControlProviderRegistry.ts | 8 +- 3 files changed, 145 insertions(+), 30 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index e3a6bd1fb20..48c8fceffd7 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -28,6 +28,14 @@ interface SourceControlDiscoverySpecBase { readonly installHint: string; } +export interface SourceControlDiscoveryInstance { + readonly kind: SourceControlProviderKind; + readonly id: string; + readonly host: string | null; + readonly label: string; + readonly auth: SourceControlProviderAuth; +} + export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly type: "cli"; readonly executable: string; @@ -37,6 +45,9 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly refineUnknownRemote?: ( input: SourceControlUnknownRemoteRefinementInput, ) => SourceControlProviderInfo | null; + readonly expandInstances?: ( + input: SourceControlAuthProbeInput, + ) => ReadonlyArray; }; export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & { @@ -204,21 +215,22 @@ export function probeSourceControlProvider(input: { readonly spec: SourceControlProviderDiscoverySpec; readonly process: VcsProcess.VcsProcess["Service"]; readonly cwd: string; -}): Effect.Effect { +}): Effect.Effect> { if (input.spec.type === "api") { return input.spec.probeAuth.pipe( - Effect.map( - (auth) => - ({ - kind: input.spec.kind, - label: input.spec.label, - status: "available" as const, - version: Option.none(), - installHint: input.spec.installHint, - detail: Option.none(), - auth, - }) satisfies SourceControlProviderDiscoveryItem, - ), + Effect.map((auth) => [ + { + kind: input.spec.kind, + id: input.spec.kind, + host: Option.none(), + label: input.spec.label, + status: "available" as const, + version: Option.none(), + installHint: input.spec.installHint, + detail: Option.none(), + auth, + } satisfies SourceControlProviderDiscoveryItem, + ]), ); } @@ -231,10 +243,14 @@ export function probeSourceControlProvider(input: { }).pipe( Effect.flatMap((item) => { if (item.status !== "available") { - return Effect.succeed({ - ...item, - auth: unknownAuth("Hosting integration command was not found on the server PATH."), - } satisfies SourceControlProviderDiscoveryItem); + return Effect.succeed([ + { + ...item, + id: spec.kind, + host: Option.none(), + auth: unknownAuth("Hosting integration command was not found on the server PATH."), + } satisfies SourceControlProviderDiscoveryItem, + ]); } return input.process @@ -249,18 +265,40 @@ export function probeSourceControlProvider(input: { appendTruncationMarker: true, }) .pipe( - Effect.map( - (result) => - ({ + Effect.map((result) => { + const instances = spec.expandInstances?.(result); + if (instances) { + return instances.map( + (instance) => + ({ + ...item, + kind: instance.kind, + id: instance.id, + host: + instance.host === null ? Option.none() : Option.some(instance.host), + label: instance.label, + auth: instance.auth, + }) satisfies SourceControlProviderDiscoveryItem, + ); + } + return [ + { ...item, + id: spec.kind, + host: Option.none(), auth: spec.parseAuth(result), - }) satisfies SourceControlProviderDiscoveryItem, - ), + } satisfies SourceControlProviderDiscoveryItem, + ]; + }), Effect.catch((cause) => - Effect.succeed({ - ...item, - auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), - } satisfies SourceControlProviderDiscoveryItem), + Effect.succeed([ + { + ...item, + id: spec.kind, + host: Option.none(), + auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), + } satisfies SourceControlProviderDiscoveryItem, + ]), ), ); }), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 5c4d27e46f9..639715b650f 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -1,10 +1,11 @@ -import { assert, it } from "@effect/vitest"; +import { assert, describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; +import type { SourceControlProviderKind } from "@t3tools/contracts"; import { VcsRepositoryDetectionError } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; @@ -15,8 +16,25 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubCli from "./GitHubCli.ts"; import * as GitLabCli from "./GitLabCli.ts"; +import { + providerAuth, + type SourceControlCliDiscoverySpec, +} from "./SourceControlProviderDiscovery.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; +const stubProvider = (kind: SourceControlProviderKind) => + SourceControlProvider.SourceControlProvider.of({ + kind, + listChangeRequests: () => Effect.succeed([]), + getChangeRequest: () => Effect.die("unused"), + createChangeRequest: () => Effect.die("unused"), + getRepositoryCloneUrls: () => Effect.die("unused"), + createRepository: () => Effect.die("unused"), + getDefaultBranch: () => Effect.succeed(null), + checkoutChangeRequest: () => Effect.die("unused"), + }); + const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); const processOutput = ( @@ -261,3 +279,60 @@ it.effect("falls back to a non-origin remote when origin is not configured", () assert.strictEqual(provider.kind, "azure-devops"); }), ); + +describe("SourceControlProviderRegistry.discover", () => { + it.effect("flattens a spec that expands into multiple instances", () => + Effect.gen(function* () { + const spec = { + type: "cli", + kind: "github", + label: "GitHub", + executable: "gh", + versionArgs: ["--version"], + authArgs: ["auth", "status"], + parseAuth: () => providerAuth({ status: "unknown" }), + expandInstances: () => [ + { + kind: "github" as const, + id: "github", + host: "github.com", + label: "GitHub", + auth: providerAuth({ status: "authenticated", account: "octocat", host: "github.com" }), + }, + { + kind: "github-enterprise" as const, + id: "github-enterprise:git.corp.com", + host: "git.corp.com", + label: "git.corp.com", + auth: providerAuth({ status: "authenticated", account: "dev", host: "git.corp.com" }), + }, + ], + installHint: "Install gh.", + } satisfies SourceControlCliDiscoverySpec; + + const registry = yield* SourceControlProviderRegistry.makeWithProviders([ + { kind: "github", provider: stubProvider("github"), discovery: spec }, + { kind: "github-enterprise", provider: stubProvider("github-enterprise") }, + ]).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), + Layer.mock(VcsProcess.VcsProcess)({ + run: () => Effect.succeed(processOutput("")), + }), + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-registry-expand-test-", + }).pipe(Layer.provide(NodeServices.layer)), + ), + ), + ); + + const items = yield* registry.discover; + + expect(items.map((item) => item.id)).toEqual(["github", "github-enterprise:git.corp.com"]); + expect(items.map((item) => item.kind)).toEqual(["github", "github-enterprise"]); + expect(Option.getOrNull(items[1]!.host)).toBe("git.corp.com"); + expect(items[1]!.label).toBe("git.corp.com"); + }), + ); +}); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index fb70d677e43..6252000ee9c 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -31,7 +31,7 @@ const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); export interface SourceControlProviderRegistration { readonly kind: SourceControlProviderKind; readonly provider: SourceControlProvider.SourceControlProvider["Service"]; - readonly discovery: SourceControlProviderDiscoverySpec; + readonly discovery?: SourceControlProviderDiscoverySpec; } export interface SourceControlProviderHandle { @@ -202,7 +202,9 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit SourceControlProviderKind, SourceControlProvider.SourceControlProvider["Service"] >(registrations.map((registration) => [registration.kind, registration.provider])); - const discoverySpecs = registrations.map((registration) => registration.discovery); + const discoverySpecs = registrations.flatMap((registration) => + registration.discovery ? [registration.discovery] : [], + ); const get: SourceControlProviderRegistry["Service"]["get"] = (kind) => Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind)); @@ -278,7 +280,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit }), ), { concurrency: "unbounded" }, - ), + ).pipe(Effect.map((results) => results.flat())), }); }, ); From 13f22a21dcbda1a9d0ddfa016baf6e2392a2cee2 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 20:39:33 +0200 Subject: [PATCH 07/34] test(server): assert discovery id/host defaults on all three probe paths --- .../SourceControlDiscovery.test.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 9e4702af04c..aa493cb7ffc 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -166,6 +166,24 @@ it.effect("reports implemented tools separately from locally available executabl const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); assert.ok(bitbucket); assert.strictEqual(bitbucket.executable, undefined); + + // github is "available" with no expandInstances defined yet, so it takes the + // non-expanding success fallback; gitlab/azure-devops are "missing" (no executable). + // Both defaulting paths should stamp id = the spec's own kind and an absent host. + const github = result.sourceControlProviders.find((item) => item.kind === "github"); + assert.ok(github); + assert.strictEqual(github.id, "github"); + assert.deepStrictEqual(github.host, Option.none()); + + const gitlab = result.sourceControlProviders.find((item) => item.kind === "gitlab"); + assert.ok(gitlab); + assert.strictEqual(gitlab.id, "gitlab"); + assert.deepStrictEqual(gitlab.host, Option.none()); + + const azureDevOps = result.sourceControlProviders.find((item) => item.kind === "azure-devops"); + assert.ok(azureDevOps); + assert.strictEqual(azureDevOps.id, "azure-devops"); + assert.deepStrictEqual(azureDevOps.host, Option.none()); }).pipe(Effect.provide(testLayer)); }); @@ -281,3 +299,79 @@ Logged in to gitlab.com as gitlab-user ); }).pipe(Effect.provide(testLayer)); }); + +it.effect("defaults identity fields when the auth probe itself fails", () => { + const processMock = { + run: (input: VcsProcess.VcsProcessInput) => { + if (input.args[0] === "--version") { + return Effect.succeed(processOutput(`${input.command} version test\n`)); + } + if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") { + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error("gh auth status crashed"), + }), + ); + } + if (input.command === "glab" && input.args.join(" ") === "auth status") { + return Effect.succeed( + processOutput(`gitlab.com\nLogged in to gitlab.com as gitlab-user\n`), + ); + } + if ( + input.command === "az" && + input.args.join(" ") === "account show --query user.name -o tsv" + ) { + return Effect.succeed(processOutput("azure-user@example.com\n")); + } + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error(`${input.command} not found`), + }), + ); + }, + } satisfies Partial; + const testLayer = SourceControlDiscovery.layer.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-auth-error-discovery-", + }), + ), + Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), + Layer.provide( + sourceControlProviderRegistryTestLayer({ + process: processMock, + bitbucket: { + probeAuth: Effect.succeed({ + status: "authenticated", + account: Option.some("bitbucket-user"), + host: Option.some("bitbucket.org"), + detail: Option.none(), + }), + }, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; + const result = yield* discovery.discover; + + // gh's --version succeeds ("available") but its auth probe throws, so this + // exercises the Effect.catch defaulting path distinct from the missing-executable + // and non-expanding-success paths covered by the tests above. + const github = result.sourceControlProviders.find((item) => item.kind === "github"); + assert.ok(github); + assert.strictEqual(github.status, "available"); + assert.strictEqual(github.auth.status, "unknown"); + assert.strictEqual(github.id, "github"); + assert.deepStrictEqual(github.host, Option.none()); + }).pipe(Effect.provide(testLayer)); +}); From 5e653f4c08f8974540726d28da66cfb68fa46b89 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 20:43:58 +0200 Subject: [PATCH 08/34] feat(server): expand gh auth hosts into enterprise connections --- .../GitHubSourceControlProvider.test.ts | 121 +++++++++++++++++- .../GitHubSourceControlProvider.ts | 91 +++++++++++++ 2 files changed, 211 insertions(+), 1 deletion(-) diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 9e8a6829566..2392eb7110c 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -1,4 +1,4 @@ -import { assert, it } from "@effect/vitest"; +import { assert, describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -381,3 +381,122 @@ it("reports unauthenticated when GitHub JSON has accounts but none are valid", ( }, ); }); + +const authStatusJson = ( + hosts: Record>, +) => + JSON.stringify({ + hosts: Object.fromEntries( + Object.entries(hosts).map(([host, accounts]) => [ + host, + accounts.map((account) => ({ ...account, host })), + ]), + ), + }); + +const probe = (stdout: string) => ({ + stdout, + stderr: "", + exitCode: ChildProcessSpawner.ExitCode(0), +}); + +describe("expandGitHubInstances", () => { + it("emits only a github row when no enterprise host is logged in", () => { + const instances = GitHubSourceControlProvider.expandGitHubInstances( + probe( + authStatusJson({ "github.com": [{ login: "octocat", state: "success", active: true }] }), + ), + ); + + expect(instances.map((instance) => instance.id)).toEqual(["github"]); + expect(instances[0]!.kind).toBe("github"); + expect(instances[0]!.auth.status).toBe("authenticated"); + }); + + it("emits one enterprise row per non-github.com host", () => { + const instances = GitHubSourceControlProvider.expandGitHubInstances( + probe( + authStatusJson({ + "github.com": [{ login: "octocat", state: "success", active: true }], + "git.corp.com": [{ login: "dev", state: "success", active: false }], + "acme.ghe.com": [{ login: "dev2", state: "success", active: false }], + }), + ), + ); + + expect(instances.map((instance) => instance.id)).toEqual([ + "github", + "github-enterprise:acme.ghe.com", + "github-enterprise:git.corp.com", + ]); + expect(instances[1]!.kind).toBe("github-enterprise"); + expect(instances[1]!.label).toBe("acme.ghe.com"); + expect(instances[1]!.host).toBe("acme.ghe.com"); + expect(Option.getOrNull(instances[1]!.auth.account)).toBe("dev2"); + }); + + it("still emits a github row when github.com is not logged in", () => { + const instances = GitHubSourceControlProvider.expandGitHubInstances( + probe(authStatusJson({ "git.corp.com": [{ login: "dev", state: "success", active: true }] })), + ); + + expect(instances[0]!.id).toBe("github"); + expect(instances[0]!.auth.status).toBe("unauthenticated"); + expect(instances).toHaveLength(2); + }); + + it("emits only an unauthenticated github row when output is unparseable", () => { + const instances = GitHubSourceControlProvider.expandGitHubInstances(probe("not json")); + + expect(instances).toHaveLength(1); + expect(instances[0]!.id).toBe("github"); + }); +}); + +describe("refineUnknownGitHubRemote", () => { + const context = { + provider: { kind: "unknown" as const, name: "git.corp.com", baseUrl: "https://git.corp.com" }, + remoteName: "origin", + remoteUrl: "https://git.corp.com/owner/repo.git", + }; + + it("claims a remote whose host is authenticated in gh", () => { + const refined = GitHubSourceControlProvider.refineUnknownGitHubRemote({ + cwd: "/repo", + context, + auth: probe( + authStatusJson({ "git.corp.com": [{ login: "dev", state: "success", active: true }] }), + ), + }); + + expect(refined).toEqual({ + kind: "github-enterprise", + name: "git.corp.com", + baseUrl: "https://git.corp.com", + }); + }); + + it("does not claim a host that failed authentication", () => { + expect( + GitHubSourceControlProvider.refineUnknownGitHubRemote({ + cwd: "/repo", + context, + auth: probe( + authStatusJson({ "git.corp.com": [{ login: "dev", state: "error", active: true }] }), + ), + }), + ).toBeNull(); + }); + + it("does not claim a host absent from gh auth status", () => { + expect( + GitHubSourceControlProvider.refineUnknownGitHubRemote({ + cwd: "/repo", + context, + auth: probe( + authStatusJson({ "github.com": [{ login: "octocat", state: "success", active: true }] }), + ), + }), + ).toBeNull(); + }); +}); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index b5d5d3a55f8..3a467890761 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -18,6 +18,8 @@ import { providerAuth, type SourceControlAuthProbeInput, type SourceControlCliDiscoverySpec, + type SourceControlDiscoveryInstance, + type SourceControlUnknownRemoteRefinementInput, } from "./SourceControlProviderDiscovery.ts"; function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { @@ -82,6 +84,93 @@ function parseGitHubAuth(input: SourceControlAuthProbeInput) { }); } +function githubComAuth(status: ReturnType) { + const accounts = status.accounts.filter((account) => account.host === "github.com"); + const authenticated = findAuthenticatedGitHubAccount(accounts); + if (authenticated) { + return providerAuth({ + status: "authenticated", + account: authenticated.account, + host: "github.com", + }); + } + return providerAuth({ + status: "unauthenticated", + host: "github.com", + detail: + accounts[0]?.error ?? + "Run `gh auth login` to authenticate GitHub CLI with an active account.", + }); +} + +export function expandGitHubInstances( + input: SourceControlAuthProbeInput, +): ReadonlyArray { + const status = parseGitHubAuthStatus(input.stdout); + if (!status.parsed) { + return [ + { + kind: "github", + id: "github", + host: "github.com", + label: "GitHub", + auth: parseGitHubAuth(input), + }, + ]; + } + + const enterpriseHosts = [ + ...new Set( + status.accounts.map((account) => account.host).filter((host) => host !== "github.com"), + ), + ].sort(); + + return [ + { + kind: "github", + id: "github", + host: "github.com", + label: "GitHub", + auth: githubComAuth(status), + }, + ...enterpriseHosts.map((host) => { + const accounts = status.accounts.filter((account) => account.host === host); + const authenticated = findAuthenticatedGitHubAccount(accounts); + return { + kind: "github-enterprise" as const, + id: `github-enterprise:${host}`, + host, + label: host, + auth: authenticated + ? providerAuth({ status: "authenticated", account: authenticated.account, host }) + : providerAuth({ + status: "unauthenticated", + host, + detail: + accounts[0]?.error ?? `Run \`gh auth login --hostname ${host}\` to authenticate.`, + }), + }; + }), + ]; +} + +export function refineUnknownGitHubRemote(input: SourceControlUnknownRemoteRefinementInput) { + const host = input.context.provider.name.toLowerCase(); + const authenticated = parseGitHubAuthStatus(input.auth.stdout).accounts.some( + (account) => account.host === host && account.authenticated, + ); + + if (!authenticated) { + return null; + } + + return { + kind: "github-enterprise", + name: host, + baseUrl: input.context.provider.baseUrl, + } as const; +} + export const discovery = { type: "cli", kind: "github", @@ -90,6 +179,8 @@ export const discovery = { versionArgs: ["--version"], authArgs: ["auth", "status", "--json", "hosts"], parseAuth: parseGitHubAuth, + expandInstances: expandGitHubInstances, + refineUnknownRemote: refineUnknownGitHubRemote, installHint: "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", } satisfies SourceControlCliDiscoverySpec; From 48f0e9ebed5ad84a2f1fb925f1ad633f1d016789 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 20:49:55 +0200 Subject: [PATCH 09/34] test(server): pin dedup of duplicate accounts on one enterprise host --- .../GitHubSourceControlProvider.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 2392eb7110c..7668eb081d5 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -435,6 +435,26 @@ describe("expandGitHubInstances", () => { expect(Option.getOrNull(instances[1]!.auth.account)).toBe("dev2"); }); + it("collapses two authenticated accounts on the same enterprise host into one row", () => { + const instances = GitHubSourceControlProvider.expandGitHubInstances( + probe( + authStatusJson({ + "github.com": [{ login: "octocat", state: "success", active: true }], + "git.corp.com": [ + { login: "dev", state: "success", active: true }, + { login: "dev2", state: "success", active: false }, + ], + }), + ), + ); + + expect(instances.map((instance) => instance.id)).toEqual([ + "github", + "github-enterprise:git.corp.com", + ]); + expect(instances).toHaveLength(2); + }); + it("still emits a github row when github.com is not logged in", () => { const instances = GitHubSourceControlProvider.expandGitHubInstances( probe(authStatusJson({ "git.corp.com": [{ login: "dev", state: "success", active: true }] })), From 6a4a5cfeffc0ba3880bd59070a4e6c392557fcaf Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 20:52:29 +0200 Subject: [PATCH 10/34] feat(server): target enterprise hosts with GH_HOST in GitHubCli --- .../src/sourceControl/GitHubCli.test.ts | 70 +++++++++++++++++++ apps/server/src/sourceControl/GitHubCli.ts | 14 ++-- 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5daf7676d60..717ca12f990 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -374,3 +374,73 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); }); + +describe("GitHubCli host targeting", () => { + it.effect("sets GH_HOST when a host is supplied", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + nameWithOwner: "owner/repo", + url: "https://git.corp.com/owner/repo", + sshUrl: "git@git.corp.com:owner/repo.git", + }), + ), + ), + ); + + const cli = yield* GitHubCli.GitHubCli; + yield* cli.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "owner/repo", + host: "git.corp.com", + }); + + expect(mockRun.mock.calls[0]![0].env?.GH_HOST).toBe("git.corp.com"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("omits env entirely when no host is supplied", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + nameWithOwner: "owner/repo", + url: "https://github.com/owner/repo", + sshUrl: "git@github.com:owner/repo.git", + }), + ), + ), + ); + + const cli = yield* GitHubCli.GitHubCli; + yield* cli.getRepositoryCloneUrls({ cwd: "/repo", repository: "owner/repo" }); + + expect(mockRun.mock.calls[0]![0]).not.toHaveProperty("env"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("derives enterprise clone urls when repo create prints no url", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const cli = yield* GitHubCli.GitHubCli; + const urls = yield* cli.createRepository({ + cwd: "/repo", + repository: "owner/repo", + visibility: "private", + host: "git.corp.com", + }); + + expect(urls).toEqual({ + nameWithOwner: "owner/repo", + url: "https://git.corp.com/owner/repo", + sshUrl: "git@git.corp.com:owner/repo.git", + }); + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index bf3f27378b5..7de5a80e93f 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -203,6 +203,7 @@ export class GitHubCli extends Context.Service< readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; + readonly host?: string; }) => Effect.Effect; readonly listOpenPullRequests: (input: { @@ -219,12 +220,14 @@ export class GitHubCli extends Context.Service< readonly getRepositoryCloneUrls: (input: { readonly cwd: string; readonly repository: string; + readonly host?: string; }) => Effect.Effect; readonly createRepository: (input: { readonly cwd: string; readonly repository: string; readonly visibility: SourceControlRepositoryVisibility; + readonly host?: string; }) => Effect.Effect; readonly createPullRequest: (input: { @@ -275,8 +278,8 @@ function normalizeRepositoryCloneUrls( function deriveRepositoryCloneUrlsFromCreateOutput( stdout: string, repository: string, + host: string = "github.com", ): GitHubRepositoryCloneUrls { - const fallbackHost = "github.com"; const match = stdout.match(/https?:\/\/[^\s]+/); if (match) { const cleaned = match[0].replace(/\.git$/, ""); @@ -298,8 +301,8 @@ function deriveRepositoryCloneUrlsFromCreateOutput( } return { nameWithOwner: repository, - url: `https://${fallbackHost}/${repository}`, - sshUrl: `git@${fallbackHost}:${repository}.git`, + url: `https://${host}/${repository}`, + sshUrl: `git@${host}:${repository}.git`, }; } @@ -313,6 +316,7 @@ export const make = Effect.gen(function* () { command: "gh", args: input.args, cwd: input.cwd, + ...(input.host ? { env: { ...globalThis.process.env, GH_HOST: input.host } } : {}), timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); @@ -394,6 +398,7 @@ export const make = Effect.gen(function* () { execute({ cwd: input.cwd, args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], + ...(input.host ? { host: input.host } : {}), }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => @@ -414,9 +419,10 @@ export const make = Effect.gen(function* () { execute({ cwd: input.cwd, args: ["repo", "create", input.repository, `--${input.visibility}`], + ...(input.host ? { host: input.host } : {}), }).pipe( Effect.map((result) => - deriveRepositoryCloneUrlsFromCreateOutput(result.stdout, input.repository), + deriveRepositoryCloneUrlsFromCreateOutput(result.stdout, input.repository, input.host), ), ), createPullRequest: (input) => From e9a555ebd3c8985f2b538a420ff7a763f979f882 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 20:59:28 +0200 Subject: [PATCH 11/34] feat(server): register github-enterprise provider on the gh CLI --- .../GitHubSourceControlProvider.test.ts | 50 ++- .../GitHubSourceControlProvider.ts | 341 ++++++++++-------- .../sourceControl/SourceControlProvider.ts | 2 + .../SourceControlProviderRegistry.ts | 8 +- 4 files changed, 239 insertions(+), 162 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 7668eb081d5..4869bfb6fa9 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -1,4 +1,4 @@ -import { assert, describe, expect, it } from "@effect/vitest"; +import { afterEach, assert, describe, expect, it, vi } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -30,6 +30,20 @@ function makeProvider(github: Partial) { ); } +const mockRun = vi.fn(); + +const cliLayer = GitHubCli.layer.pipe( + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: mockRun, + }), + ), +); + +afterEach(() => { + mockRun.mockReset(); +}); + it.effect("maps GitHub PR summaries into provider-neutral change requests", () => Effect.gen(function* () { const provider = yield* makeProvider({ @@ -520,3 +534,37 @@ describe("refineUnknownGitHubRemote", () => { ).toBeNull(); }); }); + +describe("makeProvider", () => { + it.effect("tags change requests and errors with the enterprise kind", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processResult( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 7, + title: "Add widget", + url: "https://git.corp.com/owner/repo/pull/7", + baseRefName: "main", + headRefName: "feature", + state: "OPEN", + }, + ]), + ), + ), + ); + + const provider = yield* GitHubSourceControlProvider.makeProvider("github-enterprise"); + const requests = yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature", + state: "open", + }); + + expect(provider.kind).toBe("github-enterprise"); + expect(requests[0]!.provider).toBe("github-enterprise"); + }).pipe(Effect.provide(cliLayer)), + ); +}); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 3a467890761..7277c5cf1c6 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -22,9 +22,14 @@ import { type SourceControlUnknownRemoteRefinementInput, } from "./SourceControlProviderDiscovery.ts"; -function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { +type GitHubProviderKind = "github" | "github-enterprise"; + +function toChangeRequest( + kind: GitHubProviderKind, + summary: GitHubCli.GitHubPullRequestSummary, +): ChangeRequest { return { - provider: "github", + provider: kind, number: summary.number, title: summary.title, url: summary.url, @@ -185,24 +190,84 @@ export const discovery = { "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", } satisfies SourceControlCliDiscoverySpec; -export const make = Effect.gen(function* () { - const github = yield* GitHubCli.GitHubCli; +export const makeProvider = (kind: GitHubProviderKind) => + Effect.gen(function* () { + const github = yield* GitHubCli.GitHubCli; + + const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = + (input) => { + if (input.state === "open") { + return github + .listOpenPullRequests({ + cwd: input.cwd, + headSelector: input.headSelector, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }) + .pipe( + Effect.map((items) => items.map((summary) => toChangeRequest(kind, summary))), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: kind, + operation: "listChangeRequests", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), + ); + } - const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = - (input) => { - if (input.state === "open") { + const stateArg: ChangeRequestState | "all" = input.state; return github - .listOpenPullRequests({ + .execute({ cwd: input.cwd, - headSelector: input.headSelector, - ...(input.limit !== undefined ? { limit: input.limit } : {}), + args: [ + "pr", + "list", + "--head", + input.headSelector, + "--state", + stateArg, + "--limit", + String(input.limit ?? 20), + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], }) .pipe( - Effect.map((items) => items.map(toChangeRequest)), + Effect.flatMap((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed([]); + } + return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => + Result.isSuccess(decoded) + ? Effect.succeed( + decoded.success.map((item) => ({ + ...toChangeRequest(kind, item), + updatedAt: item.updatedAt, + })), + ) + : Effect.fail( + new GitHubCli.GitHubChangeRequestListDecodeError({ + command: "gh", + cwd: input.cwd, + cause: decoded.failure, + }), + ), + ), + ); + }), Effect.mapError( (error) => new SourceControlProviderError({ - provider: "github", + provider: kind, operation: "listChangeRequests", command: error.command, cwd: input.cwd, @@ -214,179 +279,135 @@ export const make = Effect.gen(function* () { }), ), ); - } + }; - const stateArg: ChangeRequestState | "all" = input.state; - return github - .execute({ - cwd: input.cwd, - args: [ - "pr", - "list", - "--head", - input.headSelector, - "--state", - stateArg, - "--limit", - String(input.limit ?? 20), - "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }) - .pipe( - Effect.flatMap((result) => { - const raw = result.stdout.trim(); - if (raw.length === 0) { - return Effect.succeed([]); - } - return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( - Effect.flatMap((decoded) => - Result.isSuccess(decoded) - ? Effect.succeed( - decoded.success.map((item) => ({ - ...toChangeRequest(item), - updatedAt: item.updatedAt, - })), - ) - : Effect.fail( - new GitHubCli.GitHubChangeRequestListDecodeError({ - command: "gh", - cwd: input.cwd, - cause: decoded.failure, - }), - ), - ), - ); - }), + return SourceControlProvider.SourceControlProvider.of({ + kind, + listChangeRequests, + getChangeRequest: (input) => + github.getPullRequest(input).pipe( + Effect.map((summary) => toChangeRequest(kind, summary)), Effect.mapError( (error) => new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", + provider: kind, + operation: "getChangeRequest", command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, + input.reference, ), detail: error.detail, cause: error, }), ), - ); - }; - - return SourceControlProvider.SourceControlProvider.of({ - kind: "github", - listChangeRequests, - getChangeRequest: (input) => - github.getPullRequest(input).pipe( - Effect.map(toChangeRequest), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.detail, - cause: error, - }), ), - ), - createChangeRequest: (input) => - github - .createPullRequest({ - cwd: input.cwd, - baseBranch: input.baseRefName, - headSelector: input.headSelector, - title: input.title, - bodyFile: input.bodyFile, - }) - .pipe( + createChangeRequest: (input) => + github + .createPullRequest({ + cwd: input.cwd, + baseBranch: input.baseRefName, + headSelector: input.headSelector, + title: input.title, + bodyFile: input.bodyFile, + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: kind, + operation: "createChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), + ), + getRepositoryCloneUrls: (input) => + github + .getRepositoryCloneUrls({ + cwd: input.cwd, + repository: input.repository, + ...(input.host ? { host: input.host } : {}), + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: kind, + operation: "getRepositoryCloneUrls", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), + createRepository: (input) => + github + .createRepository({ + cwd: input.cwd, + repository: input.repository, + visibility: input.visibility, + ...(input.host ? { host: input.host } : {}), + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: kind, + operation: "createRepository", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), + getDefaultBranch: (input) => + github.getDefaultBranch(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: kind, + operation: "getDefaultBranch", + command: error.command, + cwd: input.cwd, + detail: error.detail, + cause: error, + }), + ), + ), + checkoutChangeRequest: (input) => + github.checkoutPullRequest(input).pipe( Effect.mapError( (error) => new SourceControlProviderError({ - provider: "github", - operation: "createChangeRequest", + provider: kind, + operation: "checkoutChangeRequest", command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, + input.reference, ), detail: error.detail, cause: error, }), ), ), - getRepositoryCloneUrls: (input) => - github.getRepositoryCloneUrls(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getRepositoryCloneUrls", - command: error.command, - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.detail, - cause: error, - }), - ), - ), - createRepository: (input) => - github.createRepository(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "createRepository", - command: error.command, - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.detail, - cause: error, - }), - ), - ), - getDefaultBranch: (input) => - github.getDefaultBranch(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getDefaultBranch", - command: error.command, - cwd: input.cwd, - detail: error.detail, - cause: error, - }), - ), - ), - checkoutChangeRequest: (input) => - github.checkoutPullRequest(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "checkoutChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.detail, - cause: error, - }), - ), - ), + }); }); -}); + +export const make = makeProvider("github"); export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index 5f93dbcaa42..72339491890 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -110,11 +110,13 @@ export class SourceControlProvider extends Context.Service< readonly cwd: string; readonly context?: SourceControlProviderContext; readonly repository: string; + readonly host?: string; }) => Effect.Effect; readonly createRepository: (input: { readonly cwd: string; readonly repository: string; readonly visibility: SourceControlRepositoryVisibility; + readonly host?: string; }) => Effect.Effect; readonly getDefaultBranch: (input: { readonly cwd: string; diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 6252000ee9c..72ddec09d7f 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -286,7 +286,8 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit ); export const make = Effect.gen(function* () { - const github = yield* GitHubSourceControlProvider.make; + const github = yield* GitHubSourceControlProvider.makeProvider("github"); + const githubEnterprise = yield* GitHubSourceControlProvider.makeProvider("github-enterprise"); const gitlab = yield* GitLabSourceControlProvider.make; const bitbucket = yield* BitbucketSourceControlProvider.make; const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery; @@ -297,6 +298,11 @@ export const make = Effect.gen(function* () { provider: github, discovery: GitHubSourceControlProvider.discovery, }, + { + // Rows come from the `gh` spec's expandInstances; no spec of its own. + kind: "github-enterprise", + provider: githubEnterprise, + }, { kind: "gitlab", provider: gitlab, From c1eb013b8cb5462fc7b44cf42bc7f594deb6a5b4 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 21:08:21 +0200 Subject: [PATCH 12/34] feat(server): route repository operations to an enterprise host --- .../SourceControlRepositoryService.test.ts | 38 ++++++++++++++++++- .../SourceControlRepositoryService.ts | 34 ++++++++++++----- 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 861da9a10e0..afb075e0d0e 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -1,6 +1,6 @@ import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { afterEach, assert, describe, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -21,6 +21,14 @@ const CLONE_URLS = { sshUrl: "git@github.com:octocat/t3code.git", }; +const getRepositoryCloneUrls = vi.fn< + SourceControlProvider.SourceControlProvider["Service"]["getRepositoryCloneUrls"] +>(() => Effect.succeed(CLONE_URLS)); + +afterEach(() => { + getRepositoryCloneUrls.mockClear(); +}); + function makeProvider( overrides: Partial = {}, ): SourceControlProvider.SourceControlProvider["Service"] { @@ -35,7 +43,7 @@ function makeProvider( listChangeRequests: () => unsupported("listChangeRequests"), getChangeRequest: () => unsupported("getChangeRequest"), createChangeRequest: () => unsupported("createChangeRequest"), - getRepositoryCloneUrls: () => Effect.succeed(CLONE_URLS), + getRepositoryCloneUrls, createRepository: () => Effect.succeed(CLONE_URLS), getDefaultBranch: () => Effect.succeed(null), checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), @@ -397,3 +405,29 @@ it.effect("publish succeeds with status remote_added when the local repo has no ), ); }); + +describe("github-enterprise host requirement", () => { + it.effect("rejects an enterprise lookup with no host", () => + Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const error = yield* service + .lookupRepository({ provider: "github-enterprise", repository: "owner/repo" }) + .pipe(Effect.flip); + + expect(error.detail).toBe("Choose a GitHub Enterprise host before continuing."); + }).pipe(Effect.provide(makeLayer({}))), + ); + + it.effect("forwards the host to the provider", () => + Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + yield* service.lookupRepository({ + provider: "github-enterprise", + repository: "owner/repo", + host: "git.corp.com", + }); + + expect(getRepositoryCloneUrls.mock.calls[0]![0].host).toBe("git.corp.com"); + }).pipe(Effect.provide(makeLayer({}))), + ); +}); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 1b46369e25c..f44a653b697 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -97,18 +97,29 @@ export const make = Effect.gen(function* () { const ensureConcreteProvider = (input: { readonly operation: string; readonly provider: SourceControlProviderKind; + readonly host?: string | undefined; }) => { - if (input.provider !== "unknown") { - return Effect.succeed(input.provider); + if (input.provider === "unknown") { + return Effect.fail( + new SourceControlRepositoryError({ + operation: input.operation, + provider: input.provider, + detail: "Choose a source control provider before continuing.", + }), + ); } - return Effect.fail( - new SourceControlRepositoryError({ - operation: input.operation, - provider: input.provider, - detail: "Choose a source control provider before continuing.", - }), - ); + if (input.provider === "github-enterprise" && !input.host?.trim()) { + return Effect.fail( + new SourceControlRepositoryError({ + operation: input.operation, + provider: input.provider, + detail: "Choose a GitHub Enterprise host before continuing.", + }), + ); + } + + return Effect.succeed(input.provider); }; const lookupRepository = Effect.fn("SourceControlRepositoryService.lookupRepository")(function* ( @@ -117,11 +128,13 @@ export const make = Effect.gen(function* () { const providerKind = yield* ensureConcreteProvider({ operation: "lookupRepository", provider: input.provider, + host: input.host, }); const provider = yield* providers.get(providerKind); const urls = yield* provider.getRepositoryCloneUrls({ cwd: input.cwd ?? config.cwd, repository: input.repository.trim(), + ...(input.host ? { host: input.host } : {}), }); return toRepositoryInfo(providerKind, urls); }); @@ -190,6 +203,7 @@ export const make = Effect.gen(function* () { provider: input.provider, repository: input.repository, cwd: preparedDestination.parentPath, + ...(input.host ? { host: input.host } : {}), }); remoteUrl = selectRemoteUrl(repository, input.protocol); provider = input.provider; @@ -223,12 +237,14 @@ export const make = Effect.gen(function* () { const providerKind = yield* ensureConcreteProvider({ operation: "publishRepository", provider: input.provider, + host: input.host, }); const provider = yield* providers.get(providerKind); const urls = yield* provider.createRepository({ cwd: input.cwd, repository: input.repository.trim(), visibility: input.visibility, + ...(input.host ? { host: input.host } : {}), }); const remoteUrl = selectRemoteUrl(urls, input.protocol); const remoteName = yield* git.ensureRemote({ From 5a085b11df304cbc3cfc7b3149da66bc7abf40a7 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 21:12:48 +0200 Subject: [PATCH 13/34] fix(web): accept enterprise pull request urls --- apps/web/src/pullRequestReference.test.ts | 30 +++++++++++++++++++++++ apps/web/src/pullRequestReference.ts | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pullRequestReference.test.ts b/apps/web/src/pullRequestReference.test.ts index 5e534af0a0b..175ca137653 100644 --- a/apps/web/src/pullRequestReference.test.ts +++ b/apps/web/src/pullRequestReference.test.ts @@ -70,4 +70,34 @@ describe("parsePullRequestReference", () => { it("rejects non-pull-request input", () => { expect(parsePullRequestReference("feature/my-branch")).toBeNull(); }); + + it("accepts an enterprise pull request url", () => { + expect(parsePullRequestReference("https://git.corp.com/owner/repo/pull/42")).toBe( + "https://git.corp.com/owner/repo/pull/42", + ); + }); + + it("accepts a ghe.com pull request url", () => { + expect(parsePullRequestReference("https://acme.ghe.com/owner/repo/pull/7")).toBe( + "https://acme.ghe.com/owner/repo/pull/7", + ); + }); + + it("still accepts github.com urls and bare numbers", () => { + expect(parsePullRequestReference("https://github.com/owner/repo/pull/1")).toBe( + "https://github.com/owner/repo/pull/1", + ); + expect(parsePullRequestReference("#12")).toBe("12"); + }); + + it("still rejects a non pull request url", () => { + expect(parsePullRequestReference("https://git.corp.com/owner/repo/issues/42")).toBeNull(); + expect(parsePullRequestReference("https://git.corp.com/owner/repo")).toBeNull(); + }); + + it("still unwraps a gh cli checkout command", () => { + expect(parsePullRequestReference("gh pr checkout https://git.corp.com/owner/repo/pull/9")).toBe( + "https://git.corp.com/owner/repo/pull/9", + ); + }); }); diff --git a/apps/web/src/pullRequestReference.ts b/apps/web/src/pullRequestReference.ts index b919e736cc0..0fe41ca71d9 100644 --- a/apps/web/src/pullRequestReference.ts +++ b/apps/web/src/pullRequestReference.ts @@ -1,5 +1,5 @@ const GITHUB_PULL_REQUEST_URL_PATTERN = - /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; + /^https:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; const GITLAB_MERGE_REQUEST_URL_PATTERN = /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; const AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN = From f4d1dca0c6d59162aa06debd268595fd94b72bd6 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 21:17:17 +0200 Subject: [PATCH 14/34] feat(web): list each GitHub Enterprise connection in settings --- apps/web/src/components/settings/SourceControlSettings.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index cf6da231cdf..da57997f81b 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -63,6 +63,7 @@ const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { github: GitHubIcon, + "github-enterprise": GitHubIcon, gitlab: GitLabIcon, "azure-devops": AzureDevOpsIcon, bitbucket: BitbucketIcon, @@ -272,6 +273,7 @@ function DiscoveryItemRow({ readonly children?: ReactNode; }) { const version = optionLabel(item.version); + const host = isProviderDiscoveryItem(item) ? optionLabel(item.host) : null; const enabled = isProviderDiscoveryItem(item) ? item.status === "available" && item.auth.status === "authenticated" : item.status === "available" && item.implemented; @@ -297,6 +299,9 @@ function DiscoveryItemRow({ {item.label} {version ? {version} : null} + {host && host !== item.label ? ( + {host} + ) : null} {isVcsNotReady(item) ? ( Coming Soon @@ -568,7 +573,7 @@ export function SourceControlSettingsPanel() { headerAction={result.versionControlSystems.length === 0 ? scanButton : null} > {result.sourceControlProviders.map((item) => ( - + ))} ) : null} From d91c2439e7ef5692800917ed9ee7062f6434f450 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 21:33:31 +0200 Subject: [PATCH 15/34] feat(web): publish repositories to a GitHub Enterprise host --- apps/web/src/components/GitActionsControl.tsx | 151 +++++++++++------- apps/web/src/state/sourceControlActions.ts | 3 +- 2 files changed, 98 insertions(+), 56 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 4f2bd19952d..87ca30fd42a 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -108,7 +108,7 @@ interface PendingDefaultBranchAction { type PublishProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "bitbucket" | "azure-devops" | "github-enterprise" >; type GitActionToastId = ReturnType; @@ -155,8 +155,19 @@ function requestVcsStatusRefresh( } const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; +interface PublishProviderOption { + readonly id: string; + readonly value: PublishProviderKind; + readonly label: string; + readonly description: string; + readonly host: string; + readonly pathPlaceholder: string; + readonly Icon: typeof GitHubIcon; +} + const PUBLISH_PROVIDER_OPTIONS = [ { + id: "github", value: "github", label: "GitHub", description: "github.com", @@ -165,6 +176,7 @@ const PUBLISH_PROVIDER_OPTIONS = [ Icon: GitHubIcon, }, { + id: "gitlab", value: "gitlab", label: "GitLab", description: "gitlab.com", @@ -173,6 +185,7 @@ const PUBLISH_PROVIDER_OPTIONS = [ Icon: GitLabIcon, }, { + id: "bitbucket", value: "bitbucket", label: "Bitbucket", description: "bitbucket.org", @@ -181,6 +194,7 @@ const PUBLISH_PROVIDER_OPTIONS = [ Icon: BitbucketIcon, }, { + id: "azure-devops", value: "azure-devops", label: "Azure DevOps", description: "dev.azure.com", @@ -188,25 +202,13 @@ const PUBLISH_PROVIDER_OPTIONS = [ pathPlaceholder: "project/repository", Icon: AzureDevOpsIcon, }, -] as const satisfies ReadonlyArray<{ - readonly value: PublishProviderKind; - readonly label: string; - readonly description: string; - readonly host: string; - readonly pathPlaceholder: string; - readonly Icon: typeof GitHubIcon; -}>; +] as const satisfies ReadonlyArray; -function publishProviderOption(provider: PublishProviderKind) { - return ( - PUBLISH_PROVIDER_OPTIONS.find((option) => option.value === provider) ?? - PUBLISH_PROVIDER_OPTIONS[0] - ); -} +type StaticPublishProviderKind = Exclude; function isPublishProviderKind( provider: SourceControlProviderKind, -): provider is PublishProviderKind { +): provider is StaticPublishProviderKind { return PUBLISH_PROVIDER_OPTIONS.some((option) => option.value === provider); } @@ -381,8 +383,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { input: {}, }), ); - const [selectedPublishProvider, setSelectedPublishProvider] = - useState(null); + const [selectedPublishProviderId, setSelectedPublishProviderId] = useState(null); const [publishRepositoryOverride, setPublishRepositoryOverride] = useState(null); const [publishVisibility, setPublishVisibility] = useState("private"); @@ -402,8 +403,35 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { [props.environmentId, props.gitCwd], ); const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope); + const enterprisePublishProviderOptions = useMemo>(() => { + const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? []; + return sourceControlProviders + .filter((item) => item.kind === "github-enterprise" && item.status === "available") + .flatMap((item) => { + const host = Option.getOrNull(item.host); + if (!host) return []; + return [ + { + id: item.id, + value: "github-enterprise" as const, + label: "GitHub Enterprise", + description: host, + host, + pathPlaceholder: "owner/repo", + Icon: GitHubIcon, + }, + ]; + }); + }, [sourceControlDiscovery.data]); + const publishProviderOptions = useMemo>( + () => + PUBLISH_PROVIDER_OPTIONS.flatMap((option) => + option.value === "github" ? [option, ...enterprisePublishProviderOptions] : [option], + ), + [enterprisePublishProviderOptions], + ); const publishAccountByProvider = useMemo(() => { - const accounts: Record = { + const accounts: Record = { github: null, gitlab: null, bitbucket: null, @@ -416,47 +444,58 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { } return accounts; }, [sourceControlDiscovery.data]); - const publishProviderReadiness = useMemo(() => { + const publishProviderReadinessById = useMemo(() => { const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? []; - return Object.fromEntries( - PUBLISH_PROVIDER_OPTIONS.map((option) => [ - option.value, - getPublishProviderReadiness({ - provider: option.value, - sourceControlProviders, - }), - ]), - ) as Record; - }, [sourceControlDiscovery.data]); + return new Map( + publishProviderOptions.map((option) => { + const value = option.value; + const readiness = + value === "github-enterprise" + ? { ready: true, hint: null } + : getPublishProviderReadiness({ provider: value, sourceControlProviders }); + return [option.id, readiness] as const; + }), + ); + }, [publishProviderOptions, sourceControlDiscovery.data]); + const readinessForOption = useCallback( + (id: string) => publishProviderReadinessById.get(id) ?? { ready: false, hint: null }, + [publishProviderReadinessById], + ); const hasReadyPublishProvider = useMemo( - () => PUBLISH_PROVIDER_OPTIONS.some((option) => publishProviderReadiness[option.value].ready), - [publishProviderReadiness], + () => publishProviderOptions.some((option) => readinessForOption(option.id).ready), + [publishProviderOptions, readinessForOption], ); const sortedPublishProviderOptions = useMemo( () => - PUBLISH_PROVIDER_OPTIONS.toSorted((left, right) => { - const leftReady = publishProviderReadiness[left.value].ready; - const rightReady = publishProviderReadiness[right.value].ready; + publishProviderOptions.toSorted((left, right) => { + const leftReady = readinessForOption(left.id).ready; + const rightReady = readinessForOption(right.id).ready; if (leftReady !== rightReady) { return leftReady ? -1 : 1; } return left.label.localeCompare(right.label); }), - [publishProviderReadiness], + [publishProviderOptions, readinessForOption], ); - const firstReadyPublishProvider = sortedPublishProviderOptions.find( - (option) => publishProviderReadiness[option.value].ready, - )?.value; - const publishProvider = - selectedPublishProvider !== null && publishProviderReadiness[selectedPublishProvider].ready - ? selectedPublishProvider - : (firstReadyPublishProvider ?? selectedPublishProvider ?? "github"); - const selectedPublishProviderReadiness = publishProviderReadiness[publishProvider]; - const publishRepositoryPrefill = publishAccountByProvider[publishProvider] - ? `${publishAccountByProvider[publishProvider]}/` - : ""; + const firstReadyPublishProviderId = sortedPublishProviderOptions.find( + (option) => readinessForOption(option.id).ready, + )?.id; + const publishProviderId = + selectedPublishProviderId !== null && readinessForOption(selectedPublishProviderId).ready + ? selectedPublishProviderId + : (firstReadyPublishProviderId ?? selectedPublishProviderId ?? "github"); + const selectedPublishProviderReadiness = readinessForOption(publishProviderId); + const currentPublishProvider = + publishProviderOptions.find((option) => option.id === publishProviderId) ?? + PUBLISH_PROVIDER_OPTIONS[0]; + const publishProvider = currentPublishProvider.value; + const publishRepositoryPrefill = + publishProvider === "github-enterprise" + ? "" + : publishAccountByProvider[publishProvider] + ? `${publishAccountByProvider[publishProvider]}/` + : ""; const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill; - const currentPublishProvider = publishProviderOption(publishProvider); const publishHost = currentPublishProvider.host; const publishPathPlaceholder = currentPublishProvider.pathPlaceholder; const publishProviderLabel = currentPublishProvider.label; @@ -487,6 +526,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { void (async () => { const result = await publishRepositoryAction.run({ provider: publishProvider, + ...(publishProvider === "github-enterprise" ? { host: currentPublishProvider.host } : {}), repository: publishRepository.trim(), visibility: publishVisibility, remoteName: publishRemoteName.trim() || "origin", @@ -508,6 +548,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { })(); }, [ canSubmitPublishRepository, + currentPublishProvider, props.environmentId, props.gitCwd, publishProtocol, @@ -612,21 +653,21 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { Provider { - setSelectedPublishProvider(value as PublishProviderKind); + setSelectedPublishProviderId(value as string); setPublishRepositoryOverride(null); }} aria-labelledby="publish-provider-cards-label" className="grid grid-cols-2 gap-2.5" > {sortedPublishProviderOptions.map((option) => { - const readiness = publishProviderReadiness[option.value]; - const isSelected = publishProvider === option.value && readiness.ready; + const readiness = readinessForOption(option.id); + const isSelected = publishProviderId === option.id && readiness.ready; if (!readiness.ready) { return (
Date: Thu, 30 Jul 2026 23:03:09 +0200 Subject: [PATCH 16/34] fix(web): collapse enterprise publish picker to one card with a host row Review feedback: fan one card per enterprise host was rejected in favor of a single "GitHub Enterprise" card plus a secondary host radio row, matching the existing card idiom instead of introducing a dropdown. --- .../GitActionsControl.logic.test.ts | 35 +++++++ .../src/components/GitActionsControl.logic.ts | 10 ++ apps/web/src/components/GitActionsControl.tsx | 97 +++++++++++++++---- 3 files changed, 125 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index f302e976ca7..bb8377b073e 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -8,6 +8,7 @@ import { resolveDefaultBranchActionDialogCopy, resolveLiveThreadBranchUpdate, resolveQuickAction, + resolveSelectedEnterpriseHost, resolveThreadBranchUpdate, resolveThreadBranchMetadataPatch, } from "./GitActionsControl.logic"; @@ -1153,3 +1154,37 @@ describe("resolveAutoFeatureBranchName", () => { assert.equal(ref, "feature/update"); }); }); + +describe("resolveSelectedEnterpriseHost", () => { + it("keeps the selected host when it is still available", () => { + const host = resolveSelectedEnterpriseHost({ + selectedHost: "git.corp.com", + availableHosts: ["acme.ghe.com", "git.corp.com"], + }); + assert.equal(host, "git.corp.com"); + }); + + it("defaults to the first available host when nothing is selected yet", () => { + const host = resolveSelectedEnterpriseHost({ + selectedHost: null, + availableHosts: ["acme.ghe.com", "git.corp.com"], + }); + assert.equal(host, "acme.ghe.com"); + }); + + it("falls back to the first available host when the selected host disappears", () => { + const host = resolveSelectedEnterpriseHost({ + selectedHost: "git.corp.com", + availableHosts: ["acme.ghe.com"], + }); + assert.equal(host, "acme.ghe.com"); + }); + + it("returns null when no hosts are available", () => { + const host = resolveSelectedEnterpriseHost({ + selectedHost: "git.corp.com", + availableHosts: [], + }); + assert.equal(host, null); + }); +}); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 96f7af794ac..36e4308aeee 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -413,5 +413,15 @@ export function resolveLiveThreadBranchUpdate(input: { }; } +export function resolveSelectedEnterpriseHost(input: { + selectedHost: string | null; + availableHosts: ReadonlyArray; +}): string | null { + if (input.selectedHost !== null && input.availableHosts.includes(input.selectedHost)) { + return input.selectedHost; + } + return input.availableHosts[0] ?? null; +} + // Re-export from shared for backwards compatibility in this module's exports export { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 87ca30fd42a..aabe9a92043 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -45,6 +45,7 @@ import { requiresDefaultBranchConfirmation, resolveDefaultBranchActionDialogCopy, resolveLiveThreadBranchUpdate, + resolveSelectedEnterpriseHost, resolveThreadBranchMetadataPatch, resolveQuickAction, resolveThreadBranchUpdate, @@ -403,26 +404,35 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { [props.environmentId, props.gitCwd], ); const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope); - const enterprisePublishProviderOptions = useMemo>(() => { + const [selectedEnterpriseHost, setSelectedEnterpriseHost] = useState(null); + const enterpriseHosts = useMemo(() => { const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? []; return sourceControlProviders .filter((item) => item.kind === "github-enterprise" && item.status === "available") .flatMap((item) => { const host = Option.getOrNull(item.host); - if (!host) return []; - return [ - { - id: item.id, - value: "github-enterprise" as const, - label: "GitHub Enterprise", - description: host, - host, - pathPlaceholder: "owner/repo", - Icon: GitHubIcon, - }, - ]; - }); + return host ? [host] : []; + }) + .toSorted((left, right) => left.localeCompare(right)); }, [sourceControlDiscovery.data]); + const activeEnterpriseHost = resolveSelectedEnterpriseHost({ + selectedHost: selectedEnterpriseHost, + availableHosts: enterpriseHosts, + }); + const enterprisePublishProviderOptions = useMemo>(() => { + if (enterpriseHosts.length === 0 || activeEnterpriseHost === null) return []; + return [ + { + id: "github-enterprise", + value: "github-enterprise" as const, + label: "GitHub Enterprise", + description: activeEnterpriseHost, + host: activeEnterpriseHost, + pathPlaceholder: "owner/repo", + Icon: GitHubIcon, + }, + ]; + }, [enterpriseHosts, activeEnterpriseHost]); const publishProviderOptions = useMemo>( () => PUBLISH_PROVIDER_OPTIONS.flatMap((option) => @@ -716,13 +726,66 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { )} > - - {option.label} - + {option.value === "github-enterprise" ? ( + + + {option.label} + + + {option.description} + + + ) : ( + + {option.label} + + )} ); })} + + {publishProvider === "github-enterprise" && enterpriseHosts.length > 0 ? ( +
+ + Host + + { + setSelectedEnterpriseHost(value as string); + setPublishRepositoryOverride(null); + }} + aria-labelledby="publish-enterprise-host-label" + className="grid grid-cols-2 gap-2" + > + {enterpriseHosts.map((host) => { + const isSelected = activeEnterpriseHost === host; + return ( + + + + {host} + + + ); + })} + +
+ ) : null}
From 59a602e3ba2a9e72e721f2361b316b7821d7a333 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 23:11:29 +0200 Subject: [PATCH 17/34] feat(client-runtime): derive add-project sources from discovered connections --- .../src/operations/projects.test.ts | 167 ++++++++++++++---- .../client-runtime/src/operations/projects.ts | 117 ++++++------ 2 files changed, 194 insertions(+), 90 deletions(-) diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index 4cca703c145..5a3ae89a3b6 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -4,11 +4,17 @@ import { ProjectId, CommandId, SourceControlDiscoveryResult, + SourceControlProviderAuthStatus, + SourceControlProviderDiscoveryItem, + SourceControlProviderKind, } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { + addProjectRemoteTargetLabel, + addProjectRemoteTargetReadiness, buildAddProjectRemoteSourceReadiness, + buildAddProjectRemoteTargets, buildProjectCreateCommand, canCreateProjectInEnvironment, findExistingAddProject, @@ -18,6 +24,44 @@ import { } from "./projects.ts"; import type { EnvironmentProject } from "../state/models.ts"; +function providerItem(overrides: { + readonly kind: SourceControlProviderKind; + readonly id: string; + readonly host?: string; + readonly label?: string; + readonly status?: "available" | "missing"; + readonly installHint?: string; + readonly auth?: { + readonly status?: SourceControlProviderAuthStatus; + readonly account?: string; + readonly host?: string; + readonly detail?: string; + }; +}): SourceControlProviderDiscoveryItem { + return { + kind: overrides.kind, + id: overrides.id, + host: overrides.host ? Option.some(overrides.host) : Option.none(), + label: overrides.label ?? overrides.kind, + status: overrides.status ?? "available", + version: Option.none(), + installHint: overrides.installHint ?? "Install", + detail: Option.none(), + auth: { + status: overrides.auth?.status ?? "authenticated", + account: overrides.auth?.account ? Option.some(overrides.auth.account) : Option.none(), + host: overrides.auth?.host ? Option.some(overrides.auth.host) : Option.none(), + detail: overrides.auth?.detail ? Option.some(overrides.auth.detail) : Option.none(), + }, + }; +} + +function discoveryResult( + providers: ReadonlyArray, +): SourceControlDiscoveryResult { + return { versionControlSystems: [], sourceControlProviders: providers }; +} + describe("add project shared logic", () => { it("only allows project creation in connected environments", () => { expect(canCreateProjectInEnvironment("connected")).toBe(true); @@ -58,45 +102,31 @@ describe("add project shared logic", () => { }); it("marks authenticated source control providers as ready", () => { - const discovery: SourceControlDiscoveryResult = { - versionControlSystems: [], - sourceControlProviders: [ - { - kind: "github", - label: "GitHub", - status: "available", - installHint: "Install gh", - version: Option.some("1.0.0"), - detail: Option.none(), - auth: { - status: "authenticated", - account: Option.some("octo"), - host: Option.some("github.com"), - detail: Option.none(), - }, - }, - { - kind: "gitlab", - label: "GitLab", - status: "available", - installHint: "Install glab", - version: Option.some("1.0.0"), - detail: Option.none(), - auth: { - status: "unauthenticated", - account: Option.none(), - host: Option.none(), - detail: Option.some("Run glab auth login"), - }, - }, - ], - }; + const discovery = discoveryResult([ + providerItem({ + kind: "github", + id: "github", + host: "github.com", + label: "GitHub", + installHint: "Install gh", + auth: { status: "authenticated", account: "octo", host: "github.com" }, + }), + providerItem({ + kind: "gitlab", + id: "gitlab", + host: "gitlab.com", + label: "GitLab", + installHint: "Install glab", + auth: { status: "unauthenticated", detail: "Run glab auth login" }, + }), + ]); const readiness = buildAddProjectRemoteSourceReadiness(discovery); - expect(readiness.url.ready).toBe(true); - expect(readiness.github.ready).toBe(true); - expect(readiness.gitlab).toEqual({ ready: false, hint: "Run glab auth login" }); - expect(sortAddProjectProviderSources(readiness)[0]).toBe("github"); + const targets = buildAddProjectRemoteTargets(discovery); + expect(readiness.get("url")).toEqual({ ready: true, hint: null }); + expect(readiness.get("github")).toEqual({ ready: true, hint: null }); + expect(readiness.get("gitlab")).toEqual({ ready: false, hint: "Run glab auth login" }); + expect(sortAddProjectProviderSources(readiness, targets)[0]!.id).toBe("github"); }); it("finds existing projects by normalized path in the target environment", () => { @@ -151,3 +181,66 @@ describe("add project shared logic", () => { }); }); }); + +describe("buildAddProjectRemoteTargets", () => { + it("returns url plus one target per discovered connection", () => { + const targets = buildAddProjectRemoteTargets( + discoveryResult([ + providerItem({ kind: "github", id: "github", host: "github.com" }), + providerItem({ + kind: "github-enterprise", + id: "github-enterprise:git.corp.com", + host: "git.corp.com", + label: "git.corp.com", + }), + ]), + ); + + expect(targets.map((target) => target.id)).toEqual([ + "url", + "github", + "github-enterprise:git.corp.com", + ]); + expect(targets[2]!.host).toBe("git.corp.com"); + expect(targets[2]!.source).toBe("github-enterprise"); + }); + + it("labels an enterprise target with its host", () => { + expect( + addProjectRemoteTargetLabel({ + id: "github-enterprise:git.corp.com", + source: "github-enterprise", + host: "git.corp.com", + }), + ).toBe("git.corp.com"); + }); + + it("keys readiness by target id", () => { + const discovery = discoveryResult([ + providerItem({ + kind: "github-enterprise", + id: "github-enterprise:git.corp.com", + host: "git.corp.com", + auth: { status: "unauthenticated", detail: "Run gh auth login." }, + }), + ]); + + const readiness = buildAddProjectRemoteSourceReadiness(discovery); + + expect(readiness.get("github-enterprise:git.corp.com")).toEqual({ + ready: false, + hint: "Run gh auth login.", + }); + expect(readiness.get("url")).toEqual({ ready: true, hint: null }); + }); + + it("falls back to the url target and an unavailable hint when discovery is unavailable", () => { + expect(buildAddProjectRemoteTargets(null)).toEqual([{ id: "url", source: "url", host: null }]); + + const readiness = buildAddProjectRemoteSourceReadiness(null); + expect(addProjectRemoteTargetReadiness(readiness, "github")).toEqual({ + ready: false, + hint: "Provider status unavailable. Open Source Control settings and rescan.", + }); + }); +}); diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 056f96b21de..577d45010f9 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -24,18 +24,24 @@ import type { EnvironmentProject } from "../state/models.ts"; export type AddProjectRemoteProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "bitbucket" | "azure-devops" + "github" | "github-enterprise" | "gitlab" | "bitbucket" | "azure-devops" >; export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; +export interface AddProjectRemoteTarget { + readonly id: string; + readonly source: AddProjectRemoteSource; + readonly host: string | null; +} + export function canCreateProjectInEnvironment( connectionPhase: EnvironmentConnectionPhase | null | undefined, ): boolean { return connectionPhase === "connected"; } -export type AddProjectRemoteSourceReadiness = Record< - AddProjectRemoteSource, +export type AddProjectRemoteSourceReadiness = ReadonlyMap< + string, { readonly ready: boolean; readonly hint: string | null } >; @@ -54,25 +60,12 @@ export type AddProjectCloneFlow = readonly remoteUrl: string; }; -const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = [ - "url", - "github", - "gitlab", - "bitbucket", - "azure-devops", -]; - -const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray = [ - "github", - "gitlab", - "bitbucket", - "azure-devops", -]; - export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): string { switch (source) { case "github": return "GitHub"; + case "github-enterprise": + return "GitHub Enterprise"; case "gitlab": return "GitLab"; case "bitbucket": @@ -88,6 +81,8 @@ export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): switch (source) { case "github": return "owner/repo"; + case "github-enterprise": + return "owner/repo"; case "gitlab": return "group/project"; case "bitbucket": @@ -105,19 +100,43 @@ export function addProjectRemoteSourceProvider( return source === "url" ? null : source; } +const URL_TARGET: AddProjectRemoteTarget = { id: "url", source: "url", host: null }; + +export function buildAddProjectRemoteTargets( + discovery: SourceControlDiscoveryResult | null, +): ReadonlyArray { + if (!discovery) return [URL_TARGET]; + return [ + URL_TARGET, + ...discovery.sourceControlProviders.flatMap((provider) => + provider.kind === "unknown" + ? [] + : [{ id: provider.id, source: provider.kind, host: Option.getOrNull(provider.host) }], + ), + ]; +} + +export function addProjectRemoteTargetLabel(target: AddProjectRemoteTarget): string { + if (target.source === "github-enterprise" && target.host) { + return target.host; + } + return addProjectRemoteSourceLabel(target.source); +} + export function sortAddProjectProviderSources( readinessBySource: AddProjectRemoteSourceReadiness, -): ReadonlyArray { + targets: ReadonlyArray, +): ReadonlyArray { return Arr.sort( - ADD_PROJECT_REMOTE_PROVIDER_SOURCES, + targets.filter((target) => target.source !== "url"), Order.mapInput( Order.Struct({ ready: Order.flip(Order.Boolean), label: Order.String, }), - (source: AddProjectRemoteProviderKind) => ({ - ready: readinessBySource[source].ready, - label: addProjectRemoteSourceLabel(source), + (target: AddProjectRemoteTarget) => ({ + ready: addProjectRemoteTargetReadiness(readinessBySource, target.id).ready, + label: addProjectRemoteTargetLabel(target), }), ), ); @@ -126,51 +145,43 @@ export function sortAddProjectProviderSources( export function buildAddProjectRemoteSourceReadiness( discovery: SourceControlDiscoveryResult | null, ): AddProjectRemoteSourceReadiness { - const unavailable = { - ready: false, - hint: "Provider status unavailable. Open Source Control settings and rescan.", - } as const; - const readiness: AddProjectRemoteSourceReadiness = { - url: { ready: true, hint: null }, - github: unavailable, - gitlab: unavailable, - bitbucket: unavailable, - "azure-devops": unavailable, - }; - - if (!discovery) { - return readiness; - } + const readiness = new Map([ + ["url", { ready: true, hint: null }], + ]); + if (!discovery) return readiness; - const providerByKind = new Map( - discovery.sourceControlProviders.map((provider) => [provider.kind, provider]), - ); - for (const source of ADD_PROJECT_REMOTE_SOURCES) { - const kind = addProjectRemoteSourceProvider(source); - if (!kind) continue; - const provider = providerByKind.get(kind); - if (!provider) { - readiness[source] = unavailable; - continue; - } + for (const provider of discovery.sourceControlProviders) { + if (provider.kind === "unknown") continue; if (provider.status !== "available") { - readiness[source] = { ready: false, hint: provider.installHint }; + readiness.set(provider.id, { ready: false, hint: provider.installHint }); continue; } if (provider.auth.status === "unauthenticated") { - readiness[source] = { + readiness.set(provider.id, { ready: false, hint: Option.getOrNull(provider.auth.detail) ?? `${provider.label} is not authenticated. Open Source Control settings for setup guidance.`, - }; + }); continue; } - readiness[source] = { ready: true, hint: null }; + readiness.set(provider.id, { ready: true, hint: null }); } return readiness; } +export function addProjectRemoteTargetReadiness( + readiness: AddProjectRemoteSourceReadiness, + targetId: string, +): { readonly ready: boolean; readonly hint: string | null } { + return ( + readiness.get(targetId) ?? { + ready: false, + hint: "Provider status unavailable. Open Source Control settings and rescan.", + } + ); +} + export function getAddProjectInitialQuery(baseDirectory: string | null | undefined): string { const trimmed = baseDirectory?.trim() ?? ""; return trimmed.length === 0 ? "~/" : ensureBrowseDirectoryPath(trimmed); From 8ee1bf894c5ede031837f088ab35df34c2655073 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 23:18:55 +0200 Subject: [PATCH 18/34] fix(client-runtime): keep base provider targets when discovery is unavailable buildAddProjectRemoteTargets(null) was dropping the four base provider kinds entirely, leaving users with only the url option until discovery resolves. Seed them as unready fallback targets instead, matching the pre-task behavior and hint text. --- .../src/operations/projects.test.ts | 42 +++++++++++++++---- .../client-runtime/src/operations/projects.ts | 13 +++++- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index 5a3ae89a3b6..25af70516ed 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -234,13 +234,41 @@ describe("buildAddProjectRemoteTargets", () => { expect(readiness.get("url")).toEqual({ ready: true, hint: null }); }); - it("falls back to the url target and an unavailable hint when discovery is unavailable", () => { - expect(buildAddProjectRemoteTargets(null)).toEqual([{ id: "url", source: "url", host: null }]); - + it("still lists the four base providers, unready, when discovery is unavailable", () => { + const targets = buildAddProjectRemoteTargets(null); const readiness = buildAddProjectRemoteSourceReadiness(null); - expect(addProjectRemoteTargetReadiness(readiness, "github")).toEqual({ - ready: false, - hint: "Provider status unavailable. Open Source Control settings and rescan.", - }); + const sorted = sortAddProjectProviderSources(readiness, targets); + + expect(sorted.map((target) => target.id)).toEqual([ + "azure-devops", + "bitbucket", + "github", + "gitlab", + ]); + for (const target of sorted) { + expect(addProjectRemoteTargetReadiness(readiness, target.id)).toEqual({ + ready: false, + hint: "Provider status unavailable. Open Source Control settings and rescan.", + }); + } + }); + + it("lists exactly the four base providers when discovery has no enterprise rows", () => { + const discovery = discoveryResult([ + providerItem({ kind: "github", id: "github", host: "github.com" }), + providerItem({ kind: "gitlab", id: "gitlab", host: "gitlab.com" }), + providerItem({ kind: "bitbucket", id: "bitbucket", host: "bitbucket.org" }), + providerItem({ kind: "azure-devops", id: "azure-devops", host: "dev.azure.com" }), + ]); + const targets = buildAddProjectRemoteTargets(discovery); + const readiness = buildAddProjectRemoteSourceReadiness(discovery); + const sorted = sortAddProjectProviderSources(readiness, targets); + + expect(sorted.map((target) => target.id)).toEqual([ + "azure-devops", + "bitbucket", + "github", + "gitlab", + ]); }); }); diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 577d45010f9..897c78f85e1 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -102,10 +102,21 @@ export function addProjectRemoteSourceProvider( const URL_TARGET: AddProjectRemoteTarget = { id: "url", source: "url", host: null }; +const BASE_PROVIDER_KINDS: ReadonlyArray = [ + "github", + "gitlab", + "bitbucket", + "azure-devops", +]; + +const BASE_PROVIDER_TARGETS: ReadonlyArray = BASE_PROVIDER_KINDS.map( + (kind) => ({ id: kind, source: kind, host: null }), +); + export function buildAddProjectRemoteTargets( discovery: SourceControlDiscoveryResult | null, ): ReadonlyArray { - if (!discovery) return [URL_TARGET]; + if (!discovery) return [URL_TARGET, ...BASE_PROVIDER_TARGETS]; return [ URL_TARGET, ...discovery.sourceControlProviders.flatMap((provider) => From 9fff7514811c871ad05e9a9914c798fdd725d839 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 23:31:38 +0200 Subject: [PATCH 19/34] feat(web,mobile): offer GitHub Enterprise connections when adding a project --- .../src/components/SourceControlIcon.tsx | 8 +- .../projects/AddProjectRepositoryRoute.tsx | 7 +- .../features/projects/AddProjectScreen.tsx | 56 +++-- apps/web/src/components/CommandPalette.tsx | 197 +++++------------- 4 files changed, 98 insertions(+), 170 deletions(-) diff --git a/apps/mobile/src/components/SourceControlIcon.tsx b/apps/mobile/src/components/SourceControlIcon.tsx index b1d4918037c..046bdfdce83 100644 --- a/apps/mobile/src/components/SourceControlIcon.tsx +++ b/apps/mobile/src/components/SourceControlIcon.tsx @@ -1,6 +1,11 @@ import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg"; -export type SourceControlIconKind = "github" | "gitlab" | "bitbucket" | "azure-devops"; +export type SourceControlIconKind = + | "github" + | "github-enterprise" + | "gitlab" + | "bitbucket" + | "azure-devops"; export function SourceControlIcon(props: { readonly kind: SourceControlIconKind; @@ -11,6 +16,7 @@ export function SourceControlIcon(props: { switch (props.kind) { case "github": + case "github-enterprise": return ( ) { const params = route.params ?? {}; const source = Array.isArray(params.source) ? params.source[0] : params.source; + const host = Array.isArray(params.host) ? params.host[0] : params.host; const title = source === "github" || + source === "github-enterprise" || source === "gitlab" || source === "bitbucket" || source === "azure-devops" - ? addProjectRemoteSourceLabel(source) + ? addProjectRemoteTargetLabel({ id: source, source, host: host ?? null }) : "Git URL"; return ( diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 39e6bda3c44..e88ba5ec5ec 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -1,8 +1,10 @@ import { - addProjectRemoteSourceLabel, addProjectRemoteSourcePathHint, addProjectRemoteSourceProvider, + addProjectRemoteTargetLabel, + addProjectRemoteTargetReadiness, buildAddProjectRemoteSourceReadiness, + buildAddProjectRemoteTargets, buildProjectCreateCommand, canCreateProjectInEnvironment, findExistingAddProject, @@ -10,6 +12,7 @@ import { resolveAddProjectPath, sortAddProjectProviderSources, type AddProjectRemoteSource, + type AddProjectRemoteTarget, } from "@t3tools/client-runtime/operations/projects"; import { connectionStatusText, @@ -97,6 +100,7 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote if ( source === "url" || source === "github" || + source === "github-enterprise" || source === "gitlab" || source === "bitbucket" || source === "azure-devops" @@ -368,7 +372,7 @@ function EmptyEnvironmentState() { } function SourceControlRow(props: { - readonly source: AddProjectRemoteSource; + readonly target: AddProjectRemoteTarget; readonly selectedEnvironmentId: EnvironmentId; readonly ready: boolean; readonly hint: string; @@ -376,17 +380,15 @@ function SourceControlRow(props: { }) { const navigation = useNavigation(); const iconColor = useThemeColor("--color-icon"); - const title = - props.source === "url" ? "Git URL" : `${addProjectRemoteSourceLabel(props.source)} repository`; + const label = addProjectRemoteTargetLabel(props.target); + const title = props.target.source === "url" ? "Git URL" : `${label} repository`; const subtitle = - props.source === "url" - ? "Clone from a remote URL" - : `Clone ${addProjectRemoteSourceLabel(props.source)} ${props.hint}`; + props.target.source === "url" ? "Clone from a remote URL" : `Clone ${label} ${props.hint}`; const icon = - props.source === "url" ? ( + props.target.source === "url" ? ( ) : ( - + ); if (!props.ready) { @@ -406,7 +408,8 @@ function SourceControlRow(props: { screen: "AddProjectRepository", params: { environmentId: props.selectedEnvironmentId, - source: props.source, + source: props.target.source, + ...(props.target.host ? { host: props.target.host } : {}), }, }) } @@ -432,6 +435,10 @@ export function AddProjectSourceScreen() { () => buildAddProjectRemoteSourceReadiness(discoveryState.data), [discoveryState.data], ); + const targets = useMemo( + () => buildAddProjectRemoteTargets(discoveryState.data), + [discoveryState.data], + ); return ( @@ -506,22 +513,26 @@ export function AddProjectSourceScreen() { }) } /> - {(["url", ...sortAddProjectProviderSources(readiness)] as AddProjectRemoteSource[]).map( - (candidate) => ( + {[ + { id: "url", source: "url", host: null } as AddProjectRemoteTarget, + ...sortAddProjectProviderSources(readiness, targets), + ].map((target) => { + const targetReadiness = addProjectRemoteTargetReadiness(readiness, target.id); + return ( - ), - )} + ); + })} {discoveryState.isPending ? : null} @@ -594,6 +605,7 @@ function useEnvironmentFromParam( export function AddProjectRepositoryScreen(props: { readonly environmentId?: string | string[]; readonly source?: string | string[]; + readonly host?: string | string[]; }) { const lookupRepositoryQuery = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, @@ -601,6 +613,7 @@ export function AddProjectRepositoryScreen(props: { const navigation = useNavigation(); const environment = useEnvironmentFromParam(props.environmentId); const source = sourceFromParam(props.source); + const host = stringParam(props.host); const [repositoryInput, setRepositoryInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -630,6 +643,7 @@ export function AddProjectRepositoryScreen(props: { input: { provider, repository: repositoryInput.trim(), + ...(host ? { host } : {}), }, }); if (AsyncResult.isFailure(result)) { @@ -647,7 +661,7 @@ export function AddProjectRepositoryScreen(props: { }); } setIsSubmitting(false); - }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]); + }, [environment, host, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]); return ( diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 853d317655a..7175333a527 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,7 +1,19 @@ "use client"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; +import { + addProjectRemoteSourcePathHint, + addProjectRemoteTargetLabel, + addProjectRemoteTargetReadiness, + buildAddProjectRemoteSourceReadiness, + buildAddProjectRemoteTargets, + canCreateProjectInEnvironment, + sortAddProjectProviderSources, + type AddProjectRemoteProviderKind, + type AddProjectRemoteSource, + type AddProjectRemoteSourceReadiness, + type AddProjectRemoteTarget, +} from "@t3tools/client-runtime/operations/projects"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { @@ -20,13 +32,10 @@ import { type EnvironmentId, type FilesystemBrowseResult, type ProjectId, - type SourceControlDiscoveryResult, - type SourceControlProviderKind, type SourceControlRepositoryInfo, PRIMARY_LOCAL_ENVIRONMENT_ID, } from "@t3tools/contracts"; import { useNavigate, useParams } from "@tanstack/react-router"; -import * as Option from "effect/Option"; import { ArrowLeftIcon, CornerLeftUpIcon, @@ -172,71 +181,23 @@ interface AddProjectEnvironmentOption { readonly status: string; } -type AddProjectRemoteProviderKind = Extract< - SourceControlProviderKind, - "github" | "gitlab" | "bitbucket" | "azure-devops" ->; -type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; - type AddProjectCloneFlow = | { readonly step: "repository"; readonly environmentId: EnvironmentId; readonly source: AddProjectRemoteSource; + readonly host: string | null; } | { readonly step: "confirm"; readonly environmentId: EnvironmentId; readonly source: AddProjectRemoteSource; + readonly host: string | null; readonly repositoryInput: string; readonly repository: SourceControlRepositoryInfo | null; readonly remoteUrl: string; }; -const REMOTE_PROJECT_SOURCES: ReadonlyArray = [ - "url", - "github", - "gitlab", - "bitbucket", - "azure-devops", -]; -const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray = [ - "github", - "gitlab", - "bitbucket", - "azure-devops", -]; - -function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { - switch (source) { - case "github": - return "GitHub"; - case "gitlab": - return "GitLab"; - case "bitbucket": - return "Bitbucket"; - case "azure-devops": - return "Azure DevOps"; - case "url": - return "Git URL"; - } -} - -function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string { - switch (source) { - case "github": - return "owner/repo"; - case "gitlab": - return "group/project"; - case "bitbucket": - return "workspace/repository"; - case "azure-devops": - return "project/repository"; - case "url": - return "URL"; - } -} - function remoteProjectSourceProvider( source: AddProjectRemoteSource, ): AddProjectRemoteProviderKind | null { @@ -246,6 +207,7 @@ function remoteProjectSourceProvider( function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: string): ReactNode { switch (source) { case "github": + case "github-enterprise": return ; case "gitlab": return ; @@ -264,80 +226,12 @@ function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string if (flow.source === "url") { return "Enter Git clone URL"; } - return `Enter ${remoteProjectSourceLabel(flow.source)} repository (${remoteProjectSourcePathHint(flow.source)})`; -} - -function sourceProviderKind(source: AddProjectRemoteSource): AddProjectRemoteProviderKind | null { - return source === "url" ? null : source; -} - -function sortAddProjectProviderSources( - readinessBySource: AddProjectRemoteSourceReadiness, -): ReadonlyArray { - return REMOTE_PROJECT_PROVIDER_SOURCES.toSorted((left, right) => { - const leftReady = readinessBySource[left].ready; - const rightReady = readinessBySource[right].ready; - if (leftReady !== rightReady) { - return leftReady ? -1 : 1; - } - return remoteProjectSourceLabel(left).localeCompare(remoteProjectSourceLabel(right)); + const label = addProjectRemoteTargetLabel({ + id: flow.source, + source: flow.source, + host: flow.host, }); -} - -type AddProjectRemoteSourceReadiness = Record< - AddProjectRemoteSource, - { readonly ready: boolean; readonly hint: string | null } ->; - -function buildAddProjectRemoteSourceReadiness( - discovery: SourceControlDiscoveryResult | null, -): AddProjectRemoteSourceReadiness { - const unavailable = { - ready: false, - hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", - } as const; - const defaultReadiness: AddProjectRemoteSourceReadiness = { - url: { ready: true, hint: null }, - github: unavailable, - gitlab: unavailable, - bitbucket: unavailable, - "azure-devops": unavailable, - }; - - if (!discovery) { - return defaultReadiness; - } - - const providerByKind = new Map( - discovery.sourceControlProviders.map((provider) => [provider.kind, provider]), - ); - const readiness = { ...defaultReadiness }; - - for (const source of REMOTE_PROJECT_SOURCES) { - const kind = sourceProviderKind(source); - if (!kind) continue; - const provider = providerByKind.get(kind); - if (!provider) { - readiness[source] = unavailable; - continue; - } - if (provider.status !== "available") { - readiness[source] = { ready: false, hint: provider.installHint }; - continue; - } - if (provider.auth.status === "unauthenticated") { - readiness[source] = { - ready: false, - hint: - Option.getOrNull(provider.auth.detail) ?? - `${provider.label} is not authenticated. Open Settings -> Source Control for setup guidance.`, - }; - continue; - } - readiness[source] = { ready: true, hint: null }; - } - - return readiness; + return `Enter ${label} repository (${addProjectRemoteSourcePathHint(flow.source)})`; } function errorMessage(error: unknown): string { @@ -1097,9 +991,9 @@ function OpenCommandPaletteDialog(props: { ); const startAddProjectClone = useCallback( - (environmentId: EnvironmentId, source: AddProjectRemoteSource): void => { + (environmentId: EnvironmentId, source: AddProjectRemoteSource, host: string | null): void => { setAddProjectEnvironmentId(environmentId); - setAddProjectCloneFlow({ step: "repository", environmentId, source }); + setAddProjectCloneFlow({ step: "repository", environmentId, source, host }); pushPaletteView({ addonIcon: remoteProjectSourceIcon(source, ADDON_ICON_CLASS), groups: [], @@ -1117,6 +1011,7 @@ function OpenCommandPaletteDialog(props: { const buildAddProjectSourceGroups = useCallback( ( environmentId: EnvironmentId, + targets: ReadonlyArray, readinessBySource: AddProjectRemoteSourceReadiness, ): CommandPaletteView["groups"] => { const sourceItems: Array = [ @@ -1134,19 +1029,24 @@ function OpenCommandPaletteDialog(props: { }, ]; - const orderedSources: ReadonlyArray = [ - "url", - ...sortAddProjectProviderSources(readinessBySource), + const urlTarget = targets.find((target) => target.source === "url") ?? { + id: "url", + source: "url" as const, + host: null, + }; + const orderedTargets: ReadonlyArray = [ + urlTarget, + ...sortAddProjectProviderSources(readinessBySource, targets), ]; - for (const source of orderedSources) { - const label = remoteProjectSourceLabel(source); - const title = source === "url" ? "Git URL" : `${label} repository`; + for (const target of orderedTargets) { + const label = addProjectRemoteTargetLabel(target); + const title = target.source === "url" ? "Git URL" : `${label} repository`; const description = - source === "url" + target.source === "url" ? "Clone from a remote URL" - : `Clone ${label} ${remoteProjectSourcePathHint(source)}`; - const readiness = readinessBySource[source]; + : `Clone ${label} ${addProjectRemoteSourcePathHint(target.source)}`; + const readiness = addProjectRemoteTargetReadiness(readinessBySource, target.id); const disabledHint = readiness.hint; const titleTrailingContent = readiness.ready ? undefined : ( @@ -1176,12 +1076,12 @@ function OpenCommandPaletteDialog(props: { if (!readiness.ready) { sourceItems.push({ kind: "action", - value: `action:add-project:${environmentId}:${source}:not-ready`, + value: `action:add-project:${environmentId}:${target.id}:not-ready`, searchTerms: ["clone", "remote", "repository", "repo", "git", label, "setup required"], title, description, disabled: true, - icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), + icon: remoteProjectSourceIcon(target.source, ITEM_ICON_CLASS), ...(titleTrailingContent ? { titleTrailingContent } : {}), run: async () => {}, }); @@ -1190,15 +1090,15 @@ function OpenCommandPaletteDialog(props: { sourceItems.push({ kind: "action", - value: `action:add-project:${environmentId}:${source}`, + value: `action:add-project:${environmentId}:${target.id}`, searchTerms: ["clone", "remote", "repository", "repo", "git", label], title, description, - icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), + icon: remoteProjectSourceIcon(target.source, ITEM_ICON_CLASS), ...(titleTrailingContent ? { titleTrailingContent } : {}), keepOpen: true, run: async () => { - startAddProjectClone(environmentId, source); + startAddProjectClone(environmentId, target.source, target.host); }, }); } @@ -1225,13 +1125,14 @@ function OpenCommandPaletteDialog(props: { } setAddProjectEnvironmentId(environmentId); setAddProjectCloneFlow(null); + const discoveryForEnvironment = + browseEnvironmentId === environmentId ? sourceControlDiscovery.data : null; pushPaletteView({ addonIcon: , groups: buildAddProjectSourceGroups( environmentId, - buildAddProjectRemoteSourceReadiness( - browseEnvironmentId === environmentId ? sourceControlDiscovery.data : null, - ), + buildAddProjectRemoteTargets(discoveryForEnvironment), + buildAddProjectRemoteSourceReadiness(discoveryForEnvironment), ), }); }, @@ -1484,6 +1385,7 @@ function OpenCommandPaletteDialog(props: { currentView.groups[0]?.value === sourceSelectionViewValue ? buildAddProjectSourceGroups( addProjectEnvironmentId, + buildAddProjectRemoteTargets(sourceControlDiscovery.data), buildAddProjectRemoteSourceReadiness(sourceControlDiscovery.data), ) : (currentView?.groups ?? rootGroups); @@ -1692,6 +1594,7 @@ function OpenCommandPaletteDialog(props: { step: "confirm", environmentId: addProjectCloneFlow.environmentId, source: addProjectCloneFlow.source, + host: addProjectCloneFlow.host, repositoryInput: rawRepository, repository: null, remoteUrl: rawRepository, @@ -1708,6 +1611,7 @@ function OpenCommandPaletteDialog(props: { input: { provider, repository: rawRepository, + ...(addProjectCloneFlow.host ? { host: addProjectCloneFlow.host } : {}), }, }); setIsRemoteProjectLookingUp(false); @@ -1729,6 +1633,7 @@ function OpenCommandPaletteDialog(props: { step: "confirm", environmentId: addProjectCloneFlow.environmentId, source: addProjectCloneFlow.source, + host: addProjectCloneFlow.host, repositoryInput: rawRepository, repository, remoteUrl: repository.sshUrl, From 3b1c8e929e682643638f8004c147db1874cdd5fa Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 23:33:32 +0200 Subject: [PATCH 20/34] test(server): expect the github discovery row to carry github.com expandGitHubInstances stamps the host on the github row, so the Task 3 assertion of an absent host has been stale since it landed. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/sourceControl/SourceControlDiscovery.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index aa493cb7ffc..61ed5c66786 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -167,13 +167,13 @@ it.effect("reports implemented tools separately from locally available executabl assert.ok(bitbucket); assert.strictEqual(bitbucket.executable, undefined); - // github is "available" with no expandInstances defined yet, so it takes the - // non-expanding success fallback; gitlab/azure-devops are "missing" (no executable). - // Both defaulting paths should stamp id = the spec's own kind and an absent host. + // github is "available", so expandGitHubInstances stamps the github.com host on it; + // gitlab/azure-devops are "missing" (no executable) and take the defaulting path, + // which stamps id = the spec's own kind and an absent host. const github = result.sourceControlProviders.find((item) => item.kind === "github"); assert.ok(github); assert.strictEqual(github.id, "github"); - assert.deepStrictEqual(github.host, Option.none()); + assert.deepStrictEqual(github.host, Option.some("github.com")); const gitlab = result.sourceControlProviders.find((item) => item.kind === "gitlab"); assert.ok(gitlab); From 649f4da3399d024aeaafc9bc3ab1362f02970f23 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 23:49:00 +0200 Subject: [PATCH 21/34] test(server): assert unknown auth on unparseable gh output The test title claimed an unauthenticated row, but exit code 0 with unparseable JSON falls through parseGitHubAuth to the "unknown" branch. Assert auth.status explicitly so the title and the assertion agree. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/sourceControl/GitHubSourceControlProvider.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 4869bfb6fa9..b28e8bc9644 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -479,11 +479,14 @@ describe("expandGitHubInstances", () => { expect(instances).toHaveLength(2); }); - it("emits only an unauthenticated github row when output is unparseable", () => { + it("emits only a github row with unknown auth when output is unparseable", () => { const instances = GitHubSourceControlProvider.expandGitHubInstances(probe("not json")); expect(instances).toHaveLength(1); expect(instances[0]!.id).toBe("github"); + expect(instances[0]!.kind).toBe("github"); + expect(instances[0]!.host).toBe("github.com"); + expect(instances[0]!.auth.status).toBe("unknown"); }); }); From be2e7379a9736b9172d7d5701845d210e43d2e71 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 23:50:24 +0200 Subject: [PATCH 22/34] fix(server): refine GHES remotes served on a non-default port An `unknown` remote's provider name is the host including its port, so a GHES server at git.corp.com:8443 never matched the port-less host that `gh auth status` reports. Normalize both sides before comparing. Co-Authored-By: Claude Opus 5 (1M context) --- .../GitHubSourceControlProvider.test.ts | 24 +++++++++++++++++++ .../GitHubSourceControlProvider.ts | 14 +++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index b28e8bc9644..5527c94813e 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -513,6 +513,30 @@ describe("refineUnknownGitHubRemote", () => { }); }); + it("claims a remote whose host carries a non-default port", () => { + const refined = GitHubSourceControlProvider.refineUnknownGitHubRemote({ + cwd: "/repo", + context: { + provider: { + kind: "unknown" as const, + name: "git.corp.com:8443", + baseUrl: "https://git.corp.com:8443", + }, + remoteName: "origin", + remoteUrl: "https://git.corp.com:8443/owner/repo.git", + }, + auth: probe( + authStatusJson({ "git.corp.com": [{ login: "dev", state: "success", active: true }] }), + ), + }); + + expect(refined).toEqual({ + kind: "github-enterprise", + name: "git.corp.com", + baseUrl: "https://git.corp.com:8443", + }); + }); + it("does not claim a host that failed authentication", () => { expect( GitHubSourceControlProvider.refineUnknownGitHubRemote({ diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 7277c5cf1c6..b1ed30279d7 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -159,10 +159,20 @@ export function expandGitHubInstances( ]; } +// An `unknown` remote's provider name is the raw host, port included, so both +// sides have to drop the port before they can be compared. +function toHostName(host: string): string { + try { + return new URL(`https://${host}`).hostname.toLowerCase(); + } catch { + return host.replace(/:\d+$/u, "").toLowerCase(); + } +} + export function refineUnknownGitHubRemote(input: SourceControlUnknownRemoteRefinementInput) { - const host = input.context.provider.name.toLowerCase(); + const host = toHostName(input.context.provider.name); const authenticated = parseGitHubAuthStatus(input.auth.stdout).accounts.some( - (account) => account.host === host && account.authenticated, + (account) => toHostName(account.host) === host && account.authenticated, ); if (!authenticated) { From f651ea2de3b6a13ca3d4d4eab16bac49df57cfdc Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 23:52:04 +0200 Subject: [PATCH 23/34] fix(server): detect PR templates for GitHub Enterprise repos Template detection was gated on the `github` kind, so enterprise repos with .github/pull_request_template.md silently generated PR bodies that ignored it. Hosts previously classified as `github` regressed when they started resolving to `github-enterprise`. Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/git/GitManager.test.ts | 65 +++++++++++++++++++++++++- apps/server/src/git/GitManager.ts | 3 +- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 695c7b76f64..a1ec455da83 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -617,6 +617,7 @@ function makeManager(input?: { textGeneration?: Partial; serverSettings?: Parameters[0]; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; + providerKind?: "github" | "github-enterprise"; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); @@ -633,7 +634,7 @@ function makeManager(input?: { ); const sourceControlRegistryLayer = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, - GitHubSourceControlProvider.make.pipe( + GitHubSourceControlProvider.makeProvider(input?.providerKind ?? "github").pipe( Effect.map((provider) => SourceControlProviderRegistry.SourceControlProviderRegistry.of({ get: () => Effect.succeed(provider), @@ -2712,6 +2713,68 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("feeds the pull request template to enterprise change requests too", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.mkdirSync(NodePath.join(repoDir, ".github")); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".github", "pull_request_template.md"), + "## What changed?\n\n## Verification", + ); + yield* runGit(repoDir, ["add", ".github/pull_request_template.md"]); + yield* runGit(repoDir, ["commit", "-m", "Add pull request template"]); + yield* runGit(repoDir, ["checkout", "-b", "feature-enterprise-template"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature-enterprise-template"]); + yield* runGit(repoDir, [ + "config", + "branch.feature-enterprise-template.gh-merge-base", + "main", + ]); + let generatedChangeRequestTemplate: string | undefined; + + const { manager } = yield* makeManager({ + providerKind: "github-enterprise", + textGeneration: { + generatePrContent: (input) => { + generatedChangeRequestTemplate = input.changeRequestTemplate; + return Effect.succeed({ + title: "Add stacked git actions", + body: "## What changed?\nAdded stacked git actions.", + }); + }, + }, + ghScenario: { + prListSequence: [ + "[]", + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 12, + title: "Add stacked git actions", + url: "https://git.corp.com/owner/repo/pull/12", + baseRefName: "main", + headRefName: "feature-enterprise-template", + }, + ]), + ], + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(generatedChangeRequestTemplate).toBe("## What changed?\n\n## Verification"); + }), + ); + it.effect("generates PR content against the remote base when the local base is stale", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index da002df5e6c..2c14cea44b1 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1597,7 +1597,8 @@ export const make = Effect.gen(function* () { const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef); const policy = yield* resolveStylePolicy(cwd, settings.style); const changeRequestTemplate = - settings.style.followChangeRequestTemplates && provider.kind === "github" + settings.style.followChangeRequestTemplates && + (provider.kind === "github" || provider.kind === "github-enterprise") ? Option.getOrUndefined(yield* detectPrTemplate(cwd, baseRangeRef, gitCore.execute)) : undefined; From dc6974a0dc9f17cd7be32e233b84c43783f7453b Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 23:52:48 +0200 Subject: [PATCH 24/34] fix(client-runtime): carry a host only on enterprise add-project targets Every target inherited its discovery row's host, so the plain github target started sending host=github.com, which made GitHubCli set GH_HOST and copy process.env onto the highest-traffic gh repo view path for no behavioural gain. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/operations/projects.test.ts | 19 +++++++++++++++++++ .../client-runtime/src/operations/projects.ts | 10 +++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index 25af70516ed..643648f1663 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -205,6 +205,25 @@ describe("buildAddProjectRemoteTargets", () => { expect(targets[2]!.source).toBe("github-enterprise"); }); + it("leaves the host off non-enterprise targets", () => { + const targets = buildAddProjectRemoteTargets( + discoveryResult([ + providerItem({ kind: "github", id: "github", host: "github.com" }), + providerItem({ kind: "gitlab", id: "gitlab", host: "gitlab.com" }), + providerItem({ kind: "bitbucket", id: "bitbucket", host: "bitbucket.org" }), + providerItem({ kind: "azure-devops", id: "azure-devops", host: "dev.azure.com" }), + ]), + ); + + expect(targets.map((target) => ({ id: target.id, host: target.host }))).toEqual([ + { id: "url", host: null }, + { id: "github", host: null }, + { id: "gitlab", host: null }, + { id: "bitbucket", host: null }, + { id: "azure-devops", host: null }, + ]); + }); + it("labels an enterprise target with its host", () => { expect( addProjectRemoteTargetLabel({ diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 897c78f85e1..07e5fea4373 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -122,7 +122,15 @@ export function buildAddProjectRemoteTargets( ...discovery.sourceControlProviders.flatMap((provider) => provider.kind === "unknown" ? [] - : [{ id: provider.id, source: provider.kind, host: Option.getOrNull(provider.host) }], + : [ + { + id: provider.id, + source: provider.kind, + // Only an enterprise target needs to pin a host; the rest keep + // their requests host-free the way they always were. + host: provider.kind === "github-enterprise" ? Option.getOrNull(provider.host) : null, + }, + ], ), ]; } From 3a6d3a923def7fbd3d615a401ff7f52952f9553f Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 30 Jul 2026 23:54:50 +0200 Subject: [PATCH 25/34] fix(web): resolve enterprise publish readiness from its discovery row The enterprise publish card hard-coded ready:true, so an expired token on git.corp.com showed no Setup Required badge and could auto-select as the first ready provider, failing with a raw gh error. Readiness now comes from the row for that host, and the shared resolver takes a host so two enterprise connections no longer answer for each other. Co-Authored-By: Claude Opus 5 (1M context) --- .../GitActionsControl.logic.test.ts | 175 +++++++++++++++++- .../src/components/GitActionsControl.logic.ts | 43 +++++ apps/web/src/components/GitActionsControl.tsx | 39 +--- 3 files changed, 223 insertions(+), 34 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index bb8377b073e..851869d1a32 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -1,8 +1,10 @@ -import type { VcsStatusResult } from "@t3tools/contracts"; +import type { SourceControlProviderDiscoveryItem, VcsStatusResult } from "@t3tools/contracts"; +import * as Option from "effect/Option"; import { assert, describe, it } from "vite-plus/test"; import { buildGitActionProgressStages, buildMenuItems, + getPublishProviderReadiness, requiresDefaultBranchConfirmation, resolveAutoFeatureBranchName, resolveDefaultBranchActionDialogCopy, @@ -1155,6 +1157,177 @@ describe("resolveAutoFeatureBranchName", () => { }); }); +function discoveryItem(overrides: { + readonly kind: SourceControlProviderDiscoveryItem["kind"]; + readonly id: string; + readonly host?: string; + readonly label?: string; + readonly status?: SourceControlProviderDiscoveryItem["status"]; + readonly authStatus?: SourceControlProviderDiscoveryItem["auth"]["status"]; + readonly authDetail?: string; +}): SourceControlProviderDiscoveryItem { + return { + kind: overrides.kind, + id: overrides.id, + host: overrides.host ? Option.some(overrides.host) : Option.none(), + label: overrides.label ?? overrides.kind, + status: overrides.status ?? "available", + version: Option.none(), + installHint: "Install the CLI.", + detail: Option.none(), + auth: { + status: overrides.authStatus ?? "authenticated", + account: Option.none(), + host: overrides.host ? Option.some(overrides.host) : Option.none(), + detail: overrides.authDetail ? Option.some(overrides.authDetail) : Option.none(), + }, + }; +} + +describe("getPublishProviderReadiness", () => { + const staticProviders = [ + discoveryItem({ kind: "github", id: "github", host: "github.com", label: "GitHub" }), + discoveryItem({ kind: "gitlab", id: "gitlab", host: "gitlab.com", label: "GitLab" }), + discoveryItem({ + kind: "bitbucket", + id: "bitbucket", + host: "bitbucket.org", + label: "Bitbucket", + status: "missing", + }), + discoveryItem({ + kind: "azure-devops", + id: "azure-devops", + host: "dev.azure.com", + label: "Azure DevOps", + authStatus: "unauthenticated", + authDetail: "Run az login.", + }), + ]; + + it("resolves the four static providers from their own rows", () => { + assert.deepEqual( + getPublishProviderReadiness({ + provider: "github", + sourceControlProviders: staticProviders, + }), + { ready: true, hint: null }, + ); + assert.deepEqual( + getPublishProviderReadiness({ + provider: "gitlab", + sourceControlProviders: staticProviders, + }), + { ready: true, hint: null }, + ); + assert.deepEqual( + getPublishProviderReadiness({ + provider: "bitbucket", + sourceControlProviders: staticProviders, + }), + { ready: false, hint: "Install the CLI." }, + ); + assert.deepEqual( + getPublishProviderReadiness({ + provider: "azure-devops", + sourceControlProviders: staticProviders, + }), + { ready: false, hint: "Run az login." }, + ); + }); + + it("reports an unavailable provider when discovery has no matching row", () => { + assert.deepEqual( + getPublishProviderReadiness({ provider: "github", sourceControlProviders: [] }), + { + ready: false, + hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", + }, + ); + }); + + it("reports an unauthenticated enterprise host as not ready", () => { + const readiness = getPublishProviderReadiness({ + provider: "github-enterprise", + host: "git.corp.com", + sourceControlProviders: [ + discoveryItem({ kind: "github", id: "github", host: "github.com", label: "GitHub" }), + discoveryItem({ + kind: "github-enterprise", + id: "github-enterprise:git.corp.com", + host: "git.corp.com", + label: "git.corp.com", + authStatus: "unauthenticated", + authDetail: "Run `gh auth login --hostname git.corp.com` to authenticate.", + }), + ], + }); + + assert.deepEqual(readiness, { + ready: false, + hint: "Run `gh auth login --hostname git.corp.com` to authenticate.", + }); + }); + + it("answers per enterprise host rather than per kind", () => { + const providers = [ + discoveryItem({ + kind: "github-enterprise", + id: "github-enterprise:acme.ghe.com", + host: "acme.ghe.com", + label: "acme.ghe.com", + authStatus: "unauthenticated", + authDetail: "Run `gh auth login --hostname acme.ghe.com` to authenticate.", + }), + discoveryItem({ + kind: "github-enterprise", + id: "github-enterprise:git.corp.com", + host: "git.corp.com", + label: "git.corp.com", + }), + ]; + + assert.deepEqual( + getPublishProviderReadiness({ + provider: "github-enterprise", + host: "git.corp.com", + sourceControlProviders: providers, + }), + { ready: true, hint: null }, + ); + assert.equal( + getPublishProviderReadiness({ + provider: "github-enterprise", + host: "acme.ghe.com", + sourceControlProviders: providers, + }).ready, + false, + ); + }); + + it("falls back to the generic hint when an enterprise row omits a detail", () => { + assert.deepEqual( + getPublishProviderReadiness({ + provider: "github-enterprise", + host: "git.corp.com", + sourceControlProviders: [ + discoveryItem({ + kind: "github-enterprise", + id: "github-enterprise:git.corp.com", + host: "git.corp.com", + label: "git.corp.com", + authStatus: "unauthenticated", + }), + ], + }), + { + ready: false, + hint: "git.corp.com is not authenticated. Open Settings -> Source Control for setup guidance.", + }, + ); + }); +}); + describe("resolveSelectedEnterpriseHost", () => { it("keeps the selected host when it is still available", () => { const host = resolveSelectedEnterpriseHost({ diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 36e4308aeee..cdddc40a09a 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -1,9 +1,12 @@ import type { GitRunStackedActionResult, GitStackedAction, + SourceControlProviderDiscoveryItem, + SourceControlProviderKind, VcsStatusResult, } from "@t3tools/contracts"; import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; +import * as Option from "effect/Option"; import { DEFAULT_CHANGE_REQUEST_TERMINOLOGY, getChangeRequestTerminology, @@ -413,6 +416,46 @@ export function resolveLiveThreadBranchUpdate(input: { }; } +export interface PublishProviderReadiness { + readonly ready: boolean; + readonly hint: string | null; +} + +/** + * Resolves publish readiness from the discovery row backing a provider card. + * `host` disambiguates kinds that discovery reports once per host, so an + * enterprise card answers for its own connection rather than any enterprise one. + */ +export function getPublishProviderReadiness(input: { + provider: SourceControlProviderKind; + host?: string | null; + sourceControlProviders: ReadonlyArray; +}): PublishProviderReadiness { + const discovered = input.sourceControlProviders.find( + (provider) => + provider.kind === input.provider && + (input.host == null || Option.getOrNull(provider.host) === input.host), + ); + if (!discovered) { + return { + ready: false, + hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", + }; + } + if (discovered.status !== "available") { + return { ready: false, hint: discovered.installHint }; + } + if (discovered.auth.status === "unauthenticated") { + return { + ready: false, + hint: + Option.getOrNull(discovered.auth.detail) ?? + `${discovered.label} is not authenticated. Open Settings -> Source Control for setup guidance.`, + }; + } + return { ready: true, hint: null }; +} + export function resolveSelectedEnterpriseHost(input: { selectedHost: string | null; availableHosts: ReadonlyArray; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index aabe9a92043..571ba1d799c 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -9,7 +9,6 @@ import type { GitRunStackedActionResult, GitStackedAction, SourceControlCloneProtocol, - SourceControlProviderDiscoveryItem, SourceControlProviderKind, SourceControlPublishRepositoryResult, SourceControlRepositoryVisibility, @@ -42,6 +41,7 @@ import { type GitActionMenuItem, type GitQuickAction, type DefaultBranchConfirmableAction, + getPublishProviderReadiness, requiresDefaultBranchConfirmation, resolveDefaultBranchActionDialogCopy, resolveLiveThreadBranchUpdate, @@ -213,33 +213,6 @@ function isPublishProviderKind( return PUBLISH_PROVIDER_OPTIONS.some((option) => option.value === provider); } -function getPublishProviderReadiness(input: { - provider: PublishProviderKind; - sourceControlProviders: ReadonlyArray; -}): { readonly ready: boolean; readonly hint: string | null } { - const discovered = input.sourceControlProviders.find( - (provider) => provider.kind === input.provider, - ); - if (!discovered) { - return { - ready: false, - hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", - }; - } - if (discovered.status !== "available") { - return { ready: false, hint: discovered.installHint }; - } - if (discovered.auth.status === "unauthenticated") { - return { - ready: false, - hint: - Option.getOrNull(discovered.auth.detail) ?? - `${discovered.label} is not authenticated. Open Settings -> Source Control for setup guidance.`, - }; - } - return { ready: true, hint: null }; -} - function formatElapsedDescription(startedAtMs: number | null): string | undefined { if (startedAtMs === null) { return undefined; @@ -458,11 +431,11 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? []; return new Map( publishProviderOptions.map((option) => { - const value = option.value; - const readiness = - value === "github-enterprise" - ? { ready: true, hint: null } - : getPublishProviderReadiness({ provider: value, sourceControlProviders }); + const readiness = getPublishProviderReadiness({ + provider: option.value, + ...(option.value === "github-enterprise" ? { host: option.host } : {}), + sourceControlProviders, + }); return [option.id, readiness] as const; }), ); From 392d914d02b002cfd2932eb82177508e342add4e Mon Sep 17 00:00:00 2001 From: Jorrin Date: Fri, 31 Jul 2026 10:34:50 +0200 Subject: [PATCH 26/34] feat(server): resolve bare GitHub Enterprise repo names via search A bare name (e.g. "core") on an enterprise host resolves against the caller's personal namespace via `gh repo view`, which is usually empty. Bare names now fall back to `gh search repos`, preferring an exact repo-name match over search rank, only for github-enterprise providers. Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/git/GitManager.test.ts | 8 + .../src/sourceControl/GitHubCli.test.ts | 78 ++++++++ apps/server/src/sourceControl/GitHubCli.ts | 64 ++++++ .../GitHubSourceControlProvider.test.ts | 185 ++++++++++++++++++ .../GitHubSourceControlProvider.ts | 119 +++++++++-- 5 files changed, 433 insertions(+), 21 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index a1ec455da83..aab1d45e447 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -567,6 +567,14 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { cause: new Error(`Unexpected repository create: ${input.repository}`), }), ), + searchRepositories: (input) => + Effect.fail( + new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: input.cwd, + cause: new Error(`Unexpected repository search: ${input.query}`), + }), + ), checkoutPullRequest: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 717ca12f990..4dce63b9506 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -344,6 +344,62 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("searches repositories and decodes full names", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { fullName: "Sollit/core-documentation" }, + { fullName: "Sollit/core" }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.searchRepositories({ + cwd: "/repo", + query: "core", + }); + + assert.deepStrictEqual(result, [ + { fullName: "Sollit/core-documentation" }, + { fullName: "Sollit/core" }, + ]); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: ["search", "repos", "core", "--limit", "20", "--json", "fullName"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("treats empty repository search output as no results", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.searchRepositories({ cwd: "/repo", query: "core" }); + + assert.deepStrictEqual(result, []); + }).pipe(Effect.provide(layer)), + ); + + it.effect("surfaces a decode error for invalid repository search JSON", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not json"))); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh.searchRepositories({ cwd: "/repo", query: "core" }).pipe(Effect.flip); + + assert.strictEqual(error._tag, "GitHubRepositorySearchDecodeError"); + }).pipe(Effect.provide(layer)), + ); + it.effect("surfaces a friendly error when the pull request is not found", () => Effect.gen(function* () { const cause = new VcsProcessExitError({ @@ -424,6 +480,28 @@ describe("GitHubCli host targeting", () => { }).pipe(Effect.provide(layer)), ); + it.effect("sets GH_HOST when searching repositories with a host", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([{ fullName: "Sollit/core" }]), + ), + ), + ); + + const cli = yield* GitHubCli.GitHubCli; + yield* cli.searchRepositories({ + cwd: "/repo", + query: "core", + host: "sollit.ghe.com", + }); + + expect(mockRun.mock.calls[0]![0].env?.GH_HOST).toBe("sollit.ghe.com"); + }).pipe(Effect.provide(layer)), + ); + it.effect("derives enterprise clone urls when repo create prints no url", () => Effect.gen(function* () { mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 7de5a80e93f..4eb7752066d 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -135,6 +135,19 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedErrorClass()( + "GitHubRepositorySearchDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid repository search JSON."; + } + + override get message(): string { + return `GitHub CLI failed in searchRepositories: ${this.detail}`; + } +} + export const GitHubCliError = Schema.Union([ GitHubCliUnavailableError, GitHubCliAuthenticationError, @@ -144,6 +157,7 @@ export const GitHubCliError = Schema.Union([ GitHubChangeRequestListDecodeError, GitHubPullRequestDecodeError, GitHubRepositoryDecodeError, + GitHubRepositorySearchDecodeError, ]); export type GitHubCliError = typeof GitHubCliError.Type; @@ -196,6 +210,10 @@ export interface GitHubRepositoryCloneUrls { readonly sshUrl: string; } +export interface GitHubRepositorySearchResult { + readonly fullName: string; +} + export class GitHubCli extends Context.Service< GitHubCli, { @@ -223,6 +241,13 @@ export class GitHubCli extends Context.Service< readonly host?: string; }) => Effect.Effect; + readonly searchRepositories: (input: { + readonly cwd: string; + readonly query: string; + readonly host?: string; + readonly limit?: number; + }) => Effect.Effect, GitHubCliError>; + readonly createRepository: (input: { readonly cwd: string; readonly repository: string; @@ -259,6 +284,15 @@ const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect( Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema), ); +const RawGitHubRepositorySearchResultsSchema = Schema.Array( + Schema.Struct({ + fullName: TrimmedNonEmptyString, + }), +); +const decodeRawGitHubRepositorySearchResults = Schema.decodeEffect( + Schema.fromJsonString(RawGitHubRepositorySearchResultsSchema), +); + function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, ): GitHubRepositoryCloneUrls { @@ -415,6 +449,36 @@ export const make = Effect.gen(function* () { ), Effect.map(normalizeRepositoryCloneUrls), ), + searchRepositories: (input) => + execute({ + cwd: input.cwd, + args: [ + "search", + "repos", + input.query, + "--limit", + String(input.limit ?? 20), + "--json", + "fullName", + ], + ...(input.host ? { host: input.host } : {}), + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + raw.length === 0 + ? Effect.succeed([]) + : decodeRawGitHubRepositorySearchResults(raw).pipe( + Effect.mapError( + (cause) => + new GitHubRepositorySearchDecodeError({ + command: "gh", + cwd: input.cwd, + cause, + }), + ), + ), + ), + ), createRepository: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 5527c94813e..66d05415cdc 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -562,6 +562,191 @@ describe("refineUnknownGitHubRemote", () => { }); }); +function makeProviderOfKind( + kind: "github" | "github-enterprise", + github: Partial, +) { + return GitHubSourceControlProvider.makeProvider(kind).pipe( + Effect.provide(Layer.mock(GitHubCli.GitHubCli)(github)), + ); +} + +describe("getRepositoryCloneUrls bare name resolution", () => { + it.effect("resolves a bare enterprise name via search, preferring the exact-name match", () => + Effect.gen(function* () { + const searchRepositories = vi.fn(() => + Effect.succeed([ + { fullName: "Sollit/core-documentation" }, + { fullName: "Sollit/core" }, + { fullName: "Sollit/frontend-core" }, + { fullName: "Sollit/portal-core-service" }, + ]), + ); + const getRepositoryCloneUrls = vi.fn(() => + Effect.succeed({ + nameWithOwner: "Sollit/core", + url: "https://sollit.ghe.com/Sollit/core", + sshUrl: "git@sollit.ghe.com:Sollit/core.git", + }), + ); + + const provider = yield* makeProviderOfKind("github-enterprise", { + searchRepositories, + getRepositoryCloneUrls, + }); + + const result = yield* provider.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "core", + host: "sollit.ghe.com", + }); + + expect(searchRepositories).toHaveBeenCalledWith({ + cwd: "/repo", + query: "core", + host: "sollit.ghe.com", + }); + expect(getRepositoryCloneUrls).toHaveBeenCalledWith({ + cwd: "/repo", + repository: "Sollit/core", + host: "sollit.ghe.com", + }); + assert.deepStrictEqual(result, { + nameWithOwner: "Sollit/core", + url: "https://sollit.ghe.com/Sollit/core", + sshUrl: "git@sollit.ghe.com:Sollit/core.git", + }); + }), + ); + + it.effect("falls back to the first search result when no bare name matches exactly", () => + Effect.gen(function* () { + const searchRepositories = vi.fn(() => + Effect.succeed([{ fullName: "Sollit/widget-service" }, { fullName: "Sollit/mywidget" }]), + ); + const getRepositoryCloneUrls = vi.fn(() => + Effect.succeed({ + nameWithOwner: "Sollit/widget-service", + url: "https://sollit.ghe.com/Sollit/widget-service", + sshUrl: "git@sollit.ghe.com:Sollit/widget-service.git", + }), + ); + + const provider = yield* makeProviderOfKind("github-enterprise", { + searchRepositories, + getRepositoryCloneUrls, + }); + + yield* provider.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "widget", + host: "sollit.ghe.com", + }); + + expect(getRepositoryCloneUrls).toHaveBeenCalledWith({ + cwd: "/repo", + repository: "Sollit/widget-service", + host: "sollit.ghe.com", + }); + }), + ); + + it.effect("fails with a detail naming the query and host when search returns zero results", () => + Effect.gen(function* () { + const searchRepositories = vi.fn(() => Effect.succeed([])); + const getRepositoryCloneUrls = vi.fn(() => + Effect.succeed({ + nameWithOwner: "Sollit/typo-name", + url: "https://sollit.ghe.com/Sollit/typo-name", + sshUrl: "git@sollit.ghe.com:Sollit/typo-name.git", + }), + ); + + const provider = yield* makeProviderOfKind("github-enterprise", { + searchRepositories, + getRepositoryCloneUrls, + }); + + const error = yield* provider + .getRepositoryCloneUrls({ + cwd: "/repo", + repository: "typo-name", + host: "sollit.ghe.com", + }) + .pipe(Effect.flip); + + expect(getRepositoryCloneUrls).not.toHaveBeenCalled(); + expect(error.detail).toContain("typo-name"); + expect(error.detail).toContain("sollit.ghe.com"); + }), + ); + + it.effect("never calls search for an owner/repo reference on enterprise", () => + Effect.gen(function* () { + const searchRepositories = vi.fn(() => Effect.succeed([])); + const getRepositoryCloneUrls = vi.fn(() => + Effect.succeed({ + nameWithOwner: "Sollit/core", + url: "https://sollit.ghe.com/Sollit/core", + sshUrl: "git@sollit.ghe.com:Sollit/core.git", + }), + ); + + const provider = yield* makeProviderOfKind("github-enterprise", { + searchRepositories, + getRepositoryCloneUrls, + }); + + yield* provider.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "Sollit/core", + host: "sollit.ghe.com", + }); + + expect(searchRepositories).not.toHaveBeenCalled(); + expect(getRepositoryCloneUrls).toHaveBeenCalledWith({ + cwd: "/repo", + repository: "Sollit/core", + host: "sollit.ghe.com", + }); + }), + ); + + it.effect("never calls search for a bare name on plain github.com", () => + Effect.gen(function* () { + const searchRepositories = vi.fn(() => Effect.succeed([])); + const getRepositoryCloneUrls = vi.fn(() => + Effect.succeed({ + nameWithOwner: "octocat/core", + url: "https://github.com/octocat/core", + sshUrl: "git@github.com:octocat/core.git", + }), + ); + + const provider = yield* makeProviderOfKind("github", { + searchRepositories, + getRepositoryCloneUrls, + }); + + const result = yield* provider.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "core", + }); + + expect(searchRepositories).not.toHaveBeenCalled(); + expect(getRepositoryCloneUrls).toHaveBeenCalledWith({ + cwd: "/repo", + repository: "core", + }); + assert.deepStrictEqual(result, { + nameWithOwner: "octocat/core", + url: "https://github.com/octocat/core", + sshUrl: "git@github.com:octocat/core.git", + }); + }), + ); +}); + describe("makeProvider", () => { it.effect("tags change requests and errors with the enterprise kind", () => Effect.gen(function* () { diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index b1ed30279d7..1dcb7fc9b2c 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -200,10 +200,83 @@ export const discovery = { "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", } satisfies SourceControlCliDiscoverySpec; +function isBareRepositoryName(repository: string): boolean { + return !repository.includes("/"); +} + +// Bare names resolve against the caller's personal namespace on `gh repo +// view`, which is usually empty on an enterprise host. Prefer the search +// result whose repo-name segment matches the query exactly (search ranking +// otherwise happily puts e.g. "core-documentation" ahead of "core"). +function pickRepositorySearchMatch( + query: string, + results: ReadonlyArray, +): string | null { + if (results.length === 0) { + return null; + } + + const normalizedQuery = query.toLowerCase(); + const exact = results.find( + (result) => result.fullName.split("/").pop()?.toLowerCase() === normalizedQuery, + ); + return (exact ?? results[0]!).fullName; +} + export const makeProvider = (kind: GitHubProviderKind) => Effect.gen(function* () { const github = yield* GitHubCli.GitHubCli; + const resolveRepositoryReference = (input: { + readonly cwd: string; + readonly repository: string; + readonly host?: string; + }): Effect.Effect => { + const repository = input.repository.trim(); + if (kind !== "github-enterprise" || !isBareRepositoryName(repository)) { + return Effect.succeed(repository); + } + + return github + .searchRepositories({ + cwd: input.cwd, + query: repository, + ...(input.host ? { host: input.host } : {}), + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: kind, + operation: "getRepositoryCloneUrls", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue(repository), + detail: error.detail, + cause: error, + }), + ), + Effect.flatMap((results) => { + const match = pickRepositorySearchMatch(repository, results); + if (match) { + return Effect.succeed(match); + } + return Effect.fail( + new SourceControlProviderError({ + provider: kind, + operation: "getRepositoryCloneUrls", + command: "gh", + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue(repository), + detail: `No repository named "${repository}" was found on ${ + input.host ?? "the configured host" + }.`, + }), + ); + }), + ); + }; + const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = (input) => { if (input.state === "open") { @@ -338,28 +411,32 @@ export const makeProvider = (kind: GitHubProviderKind) => ), ), getRepositoryCloneUrls: (input) => - github - .getRepositoryCloneUrls({ - cwd: input.cwd, - repository: input.repository, - ...(input.host ? { host: input.host } : {}), - }) - .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: kind, - operation: "getRepositoryCloneUrls", - command: error.command, - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.detail, - cause: error, - }), - ), + resolveRepositoryReference(input).pipe( + Effect.flatMap((repository) => + github + .getRepositoryCloneUrls({ + cwd: input.cwd, + repository, + ...(input.host ? { host: input.host } : {}), + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: kind, + operation: "getRepositoryCloneUrls", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), ), + ), createRepository: (input) => github .createRepository({ From a8c27aae5d1fe447841062fb8707615c74aa8693 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Fri, 31 Jul 2026 11:17:21 +0200 Subject: [PATCH 27/34] chore: drop local planning docs from the branch These were working notes for the implementation, not something the repo needs to carry. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-07-30-github-enterprise-connector.md | 1824 ----------------- ...7-30-github-enterprise-connector-design.md | 279 --- 2 files changed, 2103 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-30-github-enterprise-connector.md delete mode 100644 docs/superpowers/specs/2026-07-30-github-enterprise-connector-design.md diff --git a/docs/superpowers/plans/2026-07-30-github-enterprise-connector.md b/docs/superpowers/plans/2026-07-30-github-enterprise-connector.md deleted file mode 100644 index 491e041ff91..00000000000 --- a/docs/superpowers/plans/2026-07-30-github-enterprise-connector.md +++ /dev/null @@ -1,1824 +0,0 @@ -# GitHub Enterprise Connector Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a `github-enterprise` source control provider kind that supports any number of GitHub Enterprise hosts simultaneously, at full parity with the existing GitHub connector. - -**Architecture:** Enterprise hosts are derived from `gh auth status --json hosts` — no new settings schema. The single `gh` discovery spec fans out into one `github` row plus one `github-enterprise` row per authenticated non-`github.com` host. Remote-URL detection classifies `*.ghe.com` and `github.*` synchronously, and falls back to a CLI probe (the mechanism GitLab already uses) for arbitrary hostnames. All command execution reuses the existing `GitHubCli` service; only repo-less operations need host targeting, via `GH_HOST`. - -**Tech Stack:** TypeScript, Effect (effect-smol), Effect/Schema contracts, React (web), React Native (mobile), `@effect/vitest`, vite-plus (`vp`). - -## Global Constraints - -- Spec: `docs/superpowers/specs/2026-07-30-github-enterprise-connector-design.md`. Read it before starting. -- Effect-heavy server code: read `.repos/effect-smol/LLMS.md` before writing Effect code. Never import from `.repos/`. -- Run only targeted checks: `vp test run ` for tests you touched, plus targeted lint/typecheck. **Never** run `vp check`, `vp run -r test`, or `vp run -r typecheck`. -- Test style varies by file and the file you are editing wins. `apps/server/src/sourceControl/*.test.ts` uses `@effect/vitest` (`it.effect` for Effect-returning tests, `Layer.mock(Service)({...})` for fakes). `packages/shared/src/sourceControl.test.ts`, `packages/client-runtime/src/operations/projects.test.ts`, and `apps/web/src/pullRequestReference.test.ts` use `vite-plus/test`. Check the first line of the file before writing; for a genuinely new file, copy its nearest sibling. -- **Every test file this plan touches already exists.** Merge new cases into the existing `describe` blocks — never overwrite a file, never add a parallel duplicate block, never delete existing coverage. -- `packages/contracts` holds Effect/Schema contracts plus small derived helpers — no heavy runtime logic. `packages/shared` has subpath exports and no barrel. -- Inferred types over annotations. `any` is the enemy. -- Branch is `feat/github-enterprise-connector`, already created and checked out. Commit after each task. -- Provider kind is never persisted — it is recomputed per request from the git remote. No migrations anywhere in this plan. - ---- - -## File Structure - -**Contracts** - -- Modify `packages/contracts/src/sourceControl.ts` — new kind literal; `id`/`host` on the discovery item; optional `host` on three inputs. - -**Shared** - -- Modify `packages/shared/src/sourceControl.ts` — enterprise host classification, enterprise presentation. -- Create `packages/shared/src/sourceControl.test.ts` — detection + presentation tests (no test file exists for this module today). - -**Server — discovery plumbing** - -- Modify `apps/server/src/sourceControl/SourceControlProviderDiscovery.ts` — `expandInstances` hook; probe returns an array. -- Modify `apps/server/src/sourceControl/SourceControlProviderRegistry.ts` — optional discovery spec; flatten discovery; register `github-enterprise`. - -**Server — GitHub** - -- Modify `apps/server/src/sourceControl/GitHubSourceControlProvider.ts` — `refineUnknownRemote`, `expandInstances`, kind-parametrized provider factory. -- Modify `apps/server/src/sourceControl/GitHubCli.ts` — optional `host` → `GH_HOST`; host-aware clone-url fallback. -- Modify `apps/server/src/sourceControl/SourceControlRepositoryService.ts` — thread `host`; reject hostless `github-enterprise`. - -**Web** - -- Modify `apps/web/src/pullRequestReference.ts` — host-agnostic PR URL pattern. -- Modify `apps/web/src/components/settings/SourceControlSettings.tsx` — key rows on `id`, render `host`, enterprise icon. -- Modify `apps/web/src/components/GitActionsControl.tsx` — enterprise entries in the publish picker. - -**Client runtime + mobile** - -- Modify `packages/client-runtime/src/operations/projects.ts` — dynamic add-project sources keyed by discovery `id`. -- Modify `apps/mobile/src/features/projects/AddProjectScreen.tsx` and `AddProjectRepositoryRoute.tsx` — accept enterprise sources. - -- Modify `apps/mobile/src/components/SourceControlIcon.tsx` — accept `github-enterprise`. - -`apps/web/src/sourceControlPresentation.ts` switches on `presentation.icon`, not on kind. Because the enterprise presentation reuses `icon: "github"`, that file needs **no changes**. Do not edit it. - -`SourceControlIcon` is different: `AddProjectScreen.tsx:389` passes the raw source (`kind={props.source}`), not `presentation.icon`, so its `SourceControlIconKind` union does need the new member. Task 12 covers it. - ---- - -### Task 1: Contract — new kind and discovery identity - -**Files:** - -- Modify: `packages/contracts/src/sourceControl.ts:5-11`, `:52-104`, `:140-145` - -**Interfaces:** - -- Consumes: nothing. -- Produces: `SourceControlProviderKind` now includes `"github-enterprise"`. `SourceControlProviderDiscoveryItem` gains `id: string` and `host: Option.Option`. `SourceControlRepositoryLookupInput`, `SourceControlCloneRepositoryInput`, and `SourceControlPublishRepositoryInput` each gain `host?: string`. - -- [ ] **Step 1: Add the kind literal** - -In `packages/contracts/src/sourceControl.ts`, replace the `SourceControlProviderKind` declaration: - -```ts -export const SourceControlProviderKind = Schema.Literals([ - "github", - "github-enterprise", - "gitlab", - "azure-devops", - "bitbucket", - "unknown", -]); -``` - -- [ ] **Step 2: Add identity fields to the discovery item** - -Replace the `SourceControlProviderDiscoveryItem` declaration: - -```ts -export const SourceControlProviderDiscoveryItem = Schema.Struct({ - kind: SourceControlProviderKind, - id: TrimmedNonEmptyString, - host: Schema.Option(TrimmedNonEmptyString), - ...SourceControlDiscoverySharedFields, - auth: SourceControlProviderAuth, -}); -``` - -`VcsDiscoveryItem` is untouched — it has no `id`, and consumers discriminate the two via the existing `isProviderDiscoveryItem` guard. - -- [ ] **Step 3: Add `host` to the three repository inputs** - -Add `host: Schema.optional(TrimmedNonEmptyString),` as a field to each of `SourceControlRepositoryLookupInput`, `SourceControlCloneRepositoryInput`, and `SourceControlPublishRepositoryInput`. For example: - -```ts -export const SourceControlRepositoryLookupInput = Schema.Struct({ - provider: SourceControlProviderKind, - repository: TrimmedNonEmptyString, - host: Schema.optional(TrimmedNonEmptyString), - cwd: Schema.optional(TrimmedNonEmptyString), -}); -``` - -- [ ] **Step 4: Typecheck the contracts package** - -Run: `vp run --filter @t3tools/contracts typecheck` -Expected: PASS. Contracts are self-contained; downstream packages will not typecheck until later tasks and that is expected. - -- [ ] **Step 5: Commit** - -```bash -git add packages/contracts/src/sourceControl.ts -git commit -m "feat(contracts): add github-enterprise kind and discovery identity" -``` - ---- - -### Task 2: Shared — enterprise host detection and presentation - -**Files:** - -- Modify: `packages/shared/src/sourceControl.ts:24-33`, `:77-93`, `:170-232` -- Modify: `packages/shared/src/sourceControl.test.ts` (exists; uses `vite-plus/test`) - -**Interfaces:** - -- Consumes: `SourceControlProviderKind` from Task 1. -- Produces: `detectSourceControlProviderFromRemoteUrl` returns `kind: "github-enterprise"` for `*.ghe.com` and `github.*` hosts. `resolveChangeRequestPresentation` handles the new kind, returning `icon: "github"`. - -- [ ] **Step 1: Write the failing test** - -Create `packages/shared/src/sourceControl.test.ts`: - -```ts -import { describe, expect, it } from "@effect/vitest"; - -import { - detectSourceControlProviderFromRemoteUrl, - resolveChangeRequestPresentation, -} from "./sourceControl.ts"; - -describe("detectSourceControlProviderFromRemoteUrl", () => { - it("classifies github.com as github", () => { - expect(detectSourceControlProviderFromRemoteUrl("https://github.com/owner/repo.git")).toEqual({ - kind: "github", - name: "GitHub", - baseUrl: "https://github.com", - }); - }); - - it("classifies a ghe.com tenant as github-enterprise", () => { - expect(detectSourceControlProviderFromRemoteUrl("https://acme.ghe.com/owner/repo.git")).toEqual( - { - kind: "github-enterprise", - name: "acme.ghe.com", - baseUrl: "https://acme.ghe.com", - }, - ); - }); - - // Previously classified as kind "github" / name "GitHub Self-Hosted". - // Reclassification is intended; nothing persists the kind. - it("classifies a github-prefixed corporate host as github-enterprise", () => { - expect(detectSourceControlProviderFromRemoteUrl("git@github.acme.com:owner/repo.git")).toEqual({ - kind: "github-enterprise", - name: "github.acme.com", - baseUrl: "https://github.acme.com", - }); - }); - - it("leaves an arbitrary GHES hostname unknown for CLI refinement", () => { - expect(detectSourceControlProviderFromRemoteUrl("https://git.corp.com/owner/repo.git")).toEqual( - { - kind: "unknown", - name: "git.corp.com", - baseUrl: "https://git.corp.com", - }, - ); - }); - - it("still classifies gitlab hosts as gitlab", () => { - expect( - detectSourceControlProviderFromRemoteUrl("https://gitlab.acme.com/group/project.git")?.kind, - ).toBe("gitlab"); - }); -}); - -describe("resolveChangeRequestPresentation", () => { - it("presents github-enterprise with GitHub PR terminology and icon", () => { - const presentation = resolveChangeRequestPresentation({ - kind: "github-enterprise", - name: "git.corp.com", - baseUrl: "https://git.corp.com", - }); - - expect(presentation.icon).toBe("github"); - expect(presentation.shortName).toBe("PR"); - expect(presentation.longName).toBe("pull request"); - expect(presentation.providerName).toBe("GitHub Enterprise"); - expect(presentation.checkoutCommandExample).toBe("gh pr checkout 123"); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `vp test run packages/shared/src/sourceControl.test.ts` -Expected: FAIL — enterprise cases return `kind: "github"` or `"unknown"`, and `resolveChangeRequestPresentation` has no `github-enterprise` case. - -- [ ] **Step 3: Add the enterprise presentation** - -In `packages/shared/src/sourceControl.ts`, after `GITHUB_CHANGE_REQUEST_PRESENTATION`: - -```ts -const GITHUB_ENTERPRISE_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { - icon: "github", - providerName: "GitHub Enterprise", - shortName: "PR", - longName: "pull request", - pluralLongName: "pull requests", - providerLongName: "GitHub Enterprise pull request", - checkoutCommandExample: "gh pr checkout 123", - urlExample: "https://git.company.com/owner/repo/pull/42", -}; -``` - -Add the case to `resolveChangeRequestPresentation`, immediately after the `"github"` / `undefined` case: - -```ts - case "github-enterprise": - return GITHUB_ENTERPRISE_CHANGE_REQUEST_PRESENTATION; -``` - -- [ ] **Step 4: Add enterprise host classification** - -Replace `isGitHubHost` and the GitHub branch of `detectSourceControlProviderFromRemoteUrl`: - -```ts -function isGitHubHost(host: string): boolean { - return host === "github.com"; -} - -function isGitHubEnterpriseHost(host: string): boolean { - return host !== "github.com" && (host.endsWith(".ghe.com") || host.includes("github")); -} -``` - -In `detectSourceControlProviderFromRemoteUrl`, replace the existing GitHub block with: - -```ts -if (isGitHubHost(hostname)) { - return { - kind: "github", - name: "GitHub", - baseUrl: toBaseUrl(host), - }; -} - -if (isGitHubEnterpriseHost(hostname)) { - return { - kind: "github-enterprise", - name: hostname, - baseUrl: toBaseUrl(host), - }; -} -``` - -Leave the GitLab, Azure DevOps, Bitbucket, and `unknown` branches exactly as they are. - -- [ ] **Step 5: Run test to verify it passes** - -Run: `vp test run packages/shared/src/sourceControl.test.ts` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add packages/shared/src/sourceControl.ts packages/shared/src/sourceControl.test.ts -git commit -m "feat(shared): detect and present GitHub Enterprise hosts" -``` - ---- - -### Task 3: Discovery plumbing — one spec, many rows - -**Files:** - -- Modify: `apps/server/src/sourceControl/SourceControlProviderDiscovery.ts:31-40`, `:203-268` -- Modify: `apps/server/src/sourceControl/SourceControlProviderRegistry.ts:31-35`, `:196-284` -- Test: `apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts` - -**Interfaces:** - -- Consumes: `SourceControlProviderDiscoveryItem` from Task 1. -- Produces: `SourceControlCliDiscoverySpec` gains optional `expandInstances`. `probeSourceControlProvider` returns `Effect.Effect>` (was a single item). `SourceControlProviderRegistration.discovery` becomes optional. `SourceControlProviderRegistry.discover` stays `Effect.Effect>`, now flattened. - -`expandInstances` has this exact shape: - -```ts -export interface SourceControlDiscoveryInstance { - readonly kind: SourceControlProviderKind; - readonly id: string; - readonly host: string | null; - readonly label: string; - readonly auth: SourceControlProviderAuth; -} -``` - -- [ ] **Step 1: Write the failing test** - -Append to `apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts`. Read the file's existing imports and helpers first and reuse them rather than duplicating; this test needs `Effect`, `Layer.mock`, `ChildProcessSpawner`, `VcsProcess`, `VcsDriverRegistry`, and `ServerConfig` wired the same way the existing tests do. - -```ts -describe("SourceControlProviderRegistry.discover", () => { - it.effect("flattens a spec that expands into multiple instances", () => - Effect.gen(function* () { - const spec = { - type: "cli", - kind: "github", - label: "GitHub", - executable: "gh", - versionArgs: ["--version"], - authArgs: ["auth", "status"], - parseAuth: () => providerAuth({ status: "unknown" }), - expandInstances: () => [ - { - kind: "github" as const, - id: "github", - host: "github.com", - label: "GitHub", - auth: providerAuth({ status: "authenticated", account: "octocat", host: "github.com" }), - }, - { - kind: "github-enterprise" as const, - id: "github-enterprise:git.corp.com", - host: "git.corp.com", - label: "git.corp.com", - auth: providerAuth({ status: "authenticated", account: "dev", host: "git.corp.com" }), - }, - ], - installHint: "Install gh.", - } satisfies SourceControlCliDiscoverySpec; - - const registry = yield* SourceControlProviderRegistry.makeWithProviders([ - { kind: "github", provider: stubProvider("github"), discovery: spec }, - { kind: "github-enterprise", provider: stubProvider("github-enterprise") }, - ]); - - const items = yield* registry.discover; - - expect(items.map((item) => item.id)).toEqual(["github", "github-enterprise:git.corp.com"]); - expect(items.map((item) => item.kind)).toEqual(["github", "github-enterprise"]); - expect(Option.getOrNull(items[1]!.host)).toBe("git.corp.com"); - expect(items[1]!.label).toBe("git.corp.com"); - }), - ); -}); -``` - -Add a local `stubProvider` helper in the test file if one does not already exist: - -```ts -const stubProvider = (kind: SourceControlProviderKind) => - SourceControlProvider.SourceControlProvider.of({ - kind, - listChangeRequests: () => Effect.succeed([]), - getChangeRequest: () => Effect.die("unused"), - createChangeRequest: () => Effect.die("unused"), - getRepositoryCloneUrls: () => Effect.die("unused"), - createRepository: () => Effect.die("unused"), - getDefaultBranch: () => Effect.succeed(null), - checkoutChangeRequest: () => Effect.die("unused"), - }); -``` - -The mocked `VcsProcess.run` must succeed for both the `--version` probe and the `auth status` probe so the expansion path runs. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `vp test run apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts` -Expected: FAIL — `expandInstances` is not a known spec field, `discovery` is required on a registration, and `discover` yields one item per spec. - -- [ ] **Step 3: Add the expansion hook to the spec type** - -In `SourceControlProviderDiscovery.ts`, add the instance interface above `SourceControlDiscoverySpecBase`: - -```ts -export interface SourceControlDiscoveryInstance { - readonly kind: SourceControlProviderKind; - readonly id: string; - readonly host: string | null; - readonly label: string; - readonly auth: SourceControlProviderAuth; -} -``` - -Add the optional field to `SourceControlCliDiscoverySpec`, alongside `refineUnknownRemote`: - -```ts - readonly expandInstances?: ( - input: SourceControlAuthProbeInput, - ) => ReadonlyArray; -``` - -- [ ] **Step 4: Return arrays from the probe** - -Change `probeSourceControlProvider`'s return type to `Effect.Effect>`. - -The `api` branch wraps its single item in an array and sets identity fields: - -```ts -if (input.spec.type === "api") { - return input.spec.probeAuth.pipe( - Effect.map((auth) => [ - { - kind: input.spec.kind, - id: input.spec.kind, - host: Option.none(), - label: input.spec.label, - status: "available" as const, - version: Option.none(), - installHint: input.spec.installHint, - detail: Option.none(), - auth, - } satisfies SourceControlProviderDiscoveryItem, - ]), - ); -} -``` - -In the `cli` branch, the missing-executable path returns a one-element array with `id: spec.kind` and `host: Option.none()`. On the auth-probe success path, consult `expandInstances`: - -```ts -return input.process - .run({ - /* unchanged auth probe arguments */ - }) - .pipe( - Effect.map((result) => { - const instances = spec.expandInstances?.(result); - if (instances) { - return instances.map( - (instance) => - ({ - ...item, - kind: instance.kind, - id: instance.id, - host: instance.host === null ? Option.none() : Option.some(instance.host), - label: instance.label, - auth: instance.auth, - }) satisfies SourceControlProviderDiscoveryItem, - ); - } - return [ - { - ...item, - id: spec.kind, - host: Option.none(), - auth: spec.parseAuth(result), - } satisfies SourceControlProviderDiscoveryItem, - ]; - }), - Effect.catch((cause) => - Effect.succeed([ - { - ...item, - id: spec.kind, - host: Option.none(), - auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), - } satisfies SourceControlProviderDiscoveryItem, - ]), - ), - ); -``` - -`DiscoveryProbeResult` (the internal `probeCli` result) is unchanged — identity fields are attached here, not there. - -- [ ] **Step 5: Make the registration's discovery spec optional and flatten** - -In `SourceControlProviderRegistry.ts`, change the registration interface: - -```ts -export interface SourceControlProviderRegistration { - readonly kind: SourceControlProviderKind; - readonly provider: SourceControlProvider.SourceControlProvider["Service"]; - readonly discovery?: SourceControlProviderDiscoverySpec; -} -``` - -Filter out registrations without a spec: - -```ts -const discoverySpecs = registrations.flatMap((registration) => - registration.discovery ? [registration.discovery] : [], -); -``` - -Flatten the discovery result: - -```ts - discover: Effect.all( - discoverySpecs.map((spec) => - probeSourceControlProvider({ - spec, - process, - cwd: config.cwd, - }), - ), - { concurrency: "unbounded" }, - ).pipe(Effect.map((results) => results.flat())), -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `vp test run apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts apps/server/src/sourceControl/SourceControlDiscovery.test.ts` -Expected: PASS. If `SourceControlDiscovery.test.ts` asserts on discovery items, update its fixtures to include `id` and `host`. - -- [ ] **Step 7: Commit** - -```bash -git add apps/server/src/sourceControl/SourceControlProviderDiscovery.ts apps/server/src/sourceControl/SourceControlProviderRegistry.ts apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts apps/server/src/sourceControl/SourceControlDiscovery.test.ts -git commit -m "feat(server): let a discovery spec expand into multiple provider rows" -``` - ---- - -### Task 4: GitHub spec — enterprise expansion and unknown-remote refinement - -**Files:** - -- Modify: `apps/server/src/sourceControl/GitHubSourceControlProvider.ts:85-95` -- Test: `apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts` - -**Interfaces:** - -- Consumes: `SourceControlDiscoveryInstance` and the `expandInstances` hook from Task 3; `parseGitHubAuthStatus` / `findAuthenticatedGitHubAccount` from `./gitHubAuthStatus.ts` (unchanged). -- Produces: `discovery` gains `expandInstances: expandGitHubInstances` and `refineUnknownRemote: refineUnknownGitHubRemote`. Both are module-local functions, exported for test access. - -- [ ] **Step 1: Write the failing test** - -Append to `apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts`: - -```ts -const authStatusJson = ( - hosts: Record>, -) => - JSON.stringify({ - hosts: Object.fromEntries( - Object.entries(hosts).map(([host, accounts]) => [ - host, - accounts.map((account) => ({ ...account, host })), - ]), - ), - }); - -const probe = (stdout: string) => ({ - stdout, - stderr: "", - exitCode: ChildProcessSpawner.ExitCode(0), -}); - -describe("expandGitHubInstances", () => { - it("emits only a github row when no enterprise host is logged in", () => { - const instances = GitHubSourceControlProvider.expandGitHubInstances( - probe( - authStatusJson({ "github.com": [{ login: "octocat", state: "success", active: true }] }), - ), - ); - - expect(instances.map((instance) => instance.id)).toEqual(["github"]); - expect(instances[0]!.kind).toBe("github"); - expect(instances[0]!.auth.status).toBe("authenticated"); - }); - - it("emits one enterprise row per non-github.com host", () => { - const instances = GitHubSourceControlProvider.expandGitHubInstances( - probe( - authStatusJson({ - "github.com": [{ login: "octocat", state: "success", active: true }], - "git.corp.com": [{ login: "dev", state: "success", active: false }], - "acme.ghe.com": [{ login: "dev2", state: "success", active: false }], - }), - ), - ); - - expect(instances.map((instance) => instance.id)).toEqual([ - "github", - "github-enterprise:acme.ghe.com", - "github-enterprise:git.corp.com", - ]); - expect(instances[1]!.kind).toBe("github-enterprise"); - expect(instances[1]!.label).toBe("acme.ghe.com"); - expect(instances[1]!.host).toBe("acme.ghe.com"); - expect(Option.getOrNull(instances[1]!.auth.account)).toBe("dev2"); - }); - - it("still emits a github row when github.com is not logged in", () => { - const instances = GitHubSourceControlProvider.expandGitHubInstances( - probe(authStatusJson({ "git.corp.com": [{ login: "dev", state: "success", active: true }] })), - ); - - expect(instances[0]!.id).toBe("github"); - expect(instances[0]!.auth.status).toBe("unauthenticated"); - expect(instances).toHaveLength(2); - }); - - it("emits only an unauthenticated github row when output is unparseable", () => { - const instances = GitHubSourceControlProvider.expandGitHubInstances(probe("not json")); - - expect(instances).toHaveLength(1); - expect(instances[0]!.id).toBe("github"); - }); -}); - -describe("refineUnknownGitHubRemote", () => { - const context = { - provider: { kind: "unknown" as const, name: "git.corp.com", baseUrl: "https://git.corp.com" }, - remoteName: "origin", - remoteUrl: "https://git.corp.com/owner/repo.git", - }; - - it("claims a remote whose host is authenticated in gh", () => { - const refined = GitHubSourceControlProvider.refineUnknownGitHubRemote({ - cwd: "/repo", - context, - auth: probe( - authStatusJson({ "git.corp.com": [{ login: "dev", state: "success", active: true }] }), - ), - }); - - expect(refined).toEqual({ - kind: "github-enterprise", - name: "git.corp.com", - baseUrl: "https://git.corp.com", - }); - }); - - it("does not claim a host that failed authentication", () => { - expect( - GitHubSourceControlProvider.refineUnknownGitHubRemote({ - cwd: "/repo", - context, - auth: probe( - authStatusJson({ "git.corp.com": [{ login: "dev", state: "error", active: true }] }), - ), - }), - ).toBeNull(); - }); - - it("does not claim a host absent from gh auth status", () => { - expect( - GitHubSourceControlProvider.refineUnknownGitHubRemote({ - cwd: "/repo", - context, - auth: probe( - authStatusJson({ "github.com": [{ login: "octocat", state: "success", active: true }] }), - ), - }), - ).toBeNull(); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `vp test run apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts` -Expected: FAIL with "expandGitHubInstances is not a function". - -- [ ] **Step 3: Implement expansion and refinement** - -In `GitHubSourceControlProvider.ts`, add above the `discovery` export. Note `parseGitHubAuthStatus` already lowercases hosts (`gitHubAuthStatus.ts:53`), so no extra normalization is needed on parsed accounts. - -```ts -function githubComAuth(status: ReturnType) { - const accounts = status.accounts.filter((account) => account.host === "github.com"); - const authenticated = findAuthenticatedGitHubAccount(accounts); - if (authenticated) { - return providerAuth({ - status: "authenticated", - account: authenticated.account, - host: "github.com", - }); - } - return providerAuth({ - status: "unauthenticated", - host: "github.com", - detail: - accounts[0]?.error ?? - "Run `gh auth login` to authenticate GitHub CLI with an active account.", - }); -} - -export function expandGitHubInstances( - input: SourceControlAuthProbeInput, -): ReadonlyArray { - const status = parseGitHubAuthStatus(input.stdout); - if (!status.parsed) { - return [ - { - kind: "github", - id: "github", - host: "github.com", - label: "GitHub", - auth: parseGitHubAuth(input), - }, - ]; - } - - const enterpriseHosts = [ - ...new Set( - status.accounts.map((account) => account.host).filter((host) => host !== "github.com"), - ), - ].sort(); - - return [ - { - kind: "github", - id: "github", - host: "github.com", - label: "GitHub", - auth: githubComAuth(status), - }, - ...enterpriseHosts.map((host) => { - const accounts = status.accounts.filter((account) => account.host === host); - const authenticated = findAuthenticatedGitHubAccount(accounts); - return { - kind: "github-enterprise" as const, - id: `github-enterprise:${host}`, - host, - label: host, - auth: authenticated - ? providerAuth({ status: "authenticated", account: authenticated.account, host }) - : providerAuth({ - status: "unauthenticated", - host, - detail: - accounts[0]?.error ?? `Run \`gh auth login --hostname ${host}\` to authenticate.`, - }), - }; - }), - ]; -} - -export function refineUnknownGitHubRemote(input: SourceControlUnknownRemoteRefinementInput) { - const host = input.context.provider.name.toLowerCase(); - const authenticated = parseGitHubAuthStatus(input.auth.stdout).accounts.some( - (account) => account.host === host && account.authenticated, - ); - - if (!authenticated) { - return null; - } - - return { - kind: "github-enterprise", - name: host, - baseUrl: input.context.provider.baseUrl, - } as const; -} -``` - -Add `type SourceControlDiscoveryInstance` and `type SourceControlUnknownRemoteRefinementInput` to the existing import from `./SourceControlProviderDiscovery.ts`. - -- [ ] **Step 4: Wire both hooks into the discovery spec** - -```ts -export const discovery = { - type: "cli", - kind: "github", - label: "GitHub", - executable: "gh", - versionArgs: ["--version"], - authArgs: ["auth", "status", "--json", "hosts"], - parseAuth: parseGitHubAuth, - expandInstances: expandGitHubInstances, - refineUnknownRemote: refineUnknownGitHubRemote, - installHint: - "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", -} satisfies SourceControlCliDiscoverySpec; -``` - -- [ ] **Step 5: Run test to verify it passes** - -Run: `vp test run apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add apps/server/src/sourceControl/GitHubSourceControlProvider.ts apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts -git commit -m "feat(server): expand gh auth hosts into enterprise connections" -``` - ---- - -### Task 5: GitHubCli — host targeting via `GH_HOST` - -**Files:** - -- Modify: `apps/server/src/sourceControl/GitHubCli.ts:199-248`, `:269-304`, `:306-453` -- Test: `apps/server/src/sourceControl/GitHubCli.test.ts` - -**Interfaces:** - -- Consumes: nothing from earlier tasks. -- Produces: `GitHubCli.execute`, `getRepositoryCloneUrls`, and `createRepository` accept an optional `host?: string`. When present, the spawned process env is `{ ...process.env, GH_HOST: host }`; when absent, no `env` key is passed at all. `deriveRepositoryCloneUrlsFromCreateOutput(stdout, repository, host)` takes the fallback host as its third parameter. - -- [ ] **Step 1: Write the failing test** - -Append to `apps/server/src/sourceControl/GitHubCli.test.ts` (reuse the file's existing `mockRun`, `layer`, and `processOutput` helpers): - -```ts -describe("GitHubCli host targeting", () => { - it.effect("sets GH_HOST when a host is supplied", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - nameWithOwner: "owner/repo", - url: "https://git.corp.com/owner/repo", - sshUrl: "git@git.corp.com:owner/repo.git", - }), - ), - ), - ); - - const cli = yield* GitHubCli.GitHubCli; - yield* cli.getRepositoryCloneUrls({ - cwd: "/repo", - repository: "owner/repo", - host: "git.corp.com", - }); - - expect(mockRun.mock.calls[0]![0].env?.GH_HOST).toBe("git.corp.com"); - }).pipe(Effect.provide(layer)), - ); - - it.effect("omits env entirely when no host is supplied", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - nameWithOwner: "owner/repo", - url: "https://github.com/owner/repo", - sshUrl: "git@github.com:owner/repo.git", - }), - ), - ), - ); - - const cli = yield* GitHubCli.GitHubCli; - yield* cli.getRepositoryCloneUrls({ cwd: "/repo", repository: "owner/repo" }); - - expect(mockRun.mock.calls[0]![0]).not.toHaveProperty("env"); - }).pipe(Effect.provide(layer)), - ); - - it.effect("derives enterprise clone urls when repo create prints no url", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); - - const cli = yield* GitHubCli.GitHubCli; - const urls = yield* cli.createRepository({ - cwd: "/repo", - repository: "owner/repo", - visibility: "private", - host: "git.corp.com", - }); - - expect(urls).toEqual({ - nameWithOwner: "owner/repo", - url: "https://git.corp.com/owner/repo", - sshUrl: "git@git.corp.com:owner/repo.git", - }); - }).pipe(Effect.provide(layer)), - ); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `vp test run apps/server/src/sourceControl/GitHubCli.test.ts` -Expected: FAIL — `host` is not an accepted property, and the create fallback yields `github.com` URLs. - -- [ ] **Step 3: Thread `host` through the service type** - -In the `GitHubCli` service declaration, add `readonly host?: string;` to the input objects of `execute`, `getRepositoryCloneUrls`, and `createRepository`. Leave the in-repo operations (`listOpenPullRequests`, `getPullRequest`, `createPullRequest`, `getDefaultBranch`, `checkoutPullRequest`) unchanged — `gh` resolves the host from the repository's git remote for those. - -- [ ] **Step 4: Set `GH_HOST` in `execute`** - -```ts -const execute: GitHubCli["Service"]["execute"] = (input) => - process - .run({ - operation: "GitHubCli.execute", - command: "gh", - args: input.args, - cwd: input.cwd, - ...(input.host ? { env: { ...globalThis.process.env, GH_HOST: input.host } } : {}), - timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, - }) - .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); -``` - -`globalThis.process` is required because `process` is shadowed by the `VcsProcess` service binding in this scope. - -- [ ] **Step 5: Forward `host` from the two host-aware operations** - -In `getRepositoryCloneUrls` and `createRepository`, add `...(input.host ? { host: input.host } : {})` to their `execute({...})` calls. - -- [ ] **Step 6: Make the create fallback host-aware** - -```ts -function deriveRepositoryCloneUrlsFromCreateOutput( - stdout: string, - repository: string, - host: string = "github.com", -): GitHubRepositoryCloneUrls { - const match = stdout.match(/https?:\/\/[^\s]+/); - // ...unchanged URL-parsing block... - return { - nameWithOwner: repository, - url: `https://${host}/${repository}`, - sshUrl: `git@${host}:${repository}.git`, - }; -} -``` - -Remove the `const fallbackHost = "github.com";` line and update the call site in `createRepository` to pass `input.host`. - -- [ ] **Step 7: Run test to verify it passes** - -Run: `vp test run apps/server/src/sourceControl/GitHubCli.test.ts` -Expected: PASS - -- [ ] **Step 8: Commit** - -```bash -git add apps/server/src/sourceControl/GitHubCli.ts apps/server/src/sourceControl/GitHubCli.test.ts -git commit -m "feat(server): target enterprise hosts with GH_HOST in GitHubCli" -``` - ---- - -### Task 6: Kind-parametrized GitHub provider and registration - -**Files:** - -- Modify: `apps/server/src/sourceControl/GitHubSourceControlProvider.ts:23-43`, `:97-301` -- Modify: `apps/server/src/sourceControl/SourceControlProviderRegistry.ts:286-314` -- Test: `apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts` - -**Interfaces:** - -- Consumes: `host` on `GitHubCli` from Task 5; optional `discovery` on registrations from Task 3. -- Produces: `makeProvider(kind: "github" | "github-enterprise")` returns the provider effect. `make` is kept as `makeProvider("github")` so existing importers (including `layer`) keep working. `SourceControlProvider.getRepositoryCloneUrls` and `createRepository` accept an optional `host?: string` in their input, forwarded to `GitHubCli`. - -- [ ] **Step 1: Write the failing test** - -Append to `apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts`: - -```ts -describe("makeProvider", () => { - it.effect("tags change requests and errors with the enterprise kind", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([ - { - number: 7, - title: "Add widget", - url: "https://git.corp.com/owner/repo/pull/7", - baseRefName: "main", - headRefName: "feature", - state: "OPEN", - }, - ]), - ), - ), - ); - - const provider = yield* GitHubSourceControlProvider.makeProvider("github-enterprise"); - const requests = yield* provider.listChangeRequests({ - cwd: "/repo", - headSelector: "feature", - state: "open", - }); - - expect(provider.kind).toBe("github-enterprise"); - expect(requests[0]!.provider).toBe("github-enterprise"); - }).pipe(Effect.provide(cliLayer)), - ); -}); -``` - -Reuse the file's existing `GitHubCli` mock layer; name it `cliLayer` if the file does not already expose one. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `vp test run apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts` -Expected: FAIL with "makeProvider is not a function". - -- [ ] **Step 3: Parametrize the provider factory** - -Introduce a kind alias and thread it through. Change `toChangeRequest` to take the kind: - -```ts -type GitHubProviderKind = "github" | "github-enterprise"; - -function toChangeRequest( - kind: GitHubProviderKind, - summary: GitHubCli.GitHubPullRequestSummary, -): ChangeRequest { - return { - provider: kind, - // ...remaining fields unchanged... - }; -} -``` - -Rename `export const make = Effect.gen(function* () { ... })` to: - -```ts -export const makeProvider = (kind: GitHubProviderKind) => - Effect.gen(function* () { - // ...existing body... - }); - -export const make = makeProvider("github"); -``` - -Inside the body, replace every literal `provider: "github"` in `SourceControlProviderError` constructions with `provider: kind`, replace `kind: "github"` in `SourceControlProvider.SourceControlProvider.of({...})` with `kind`, and update the two `toChangeRequest` call sites plus the `.map(toChangeRequest)` to `.map((summary) => toChangeRequest(kind, summary))`. The `GitHubChangeRequestListDecodeError` keeps `command: "gh"` — that field names the executable, not the provider. - -Forward `host` in the two host-aware operations: - -```ts - getRepositoryCloneUrls: (input) => - github - .getRepositoryCloneUrls({ - cwd: input.cwd, - repository: input.repository, - ...(input.host ? { host: input.host } : {}), - }) - .pipe(/* unchanged error mapping */), -``` - -and the same pattern for `createRepository`. - -- [ ] **Step 4: Add `host` to the provider interface** - -In `apps/server/src/sourceControl/SourceControlProvider.ts`, add `readonly host?: string;` to the input types of `getRepositoryCloneUrls` and `createRepository`. `bindProviderContext` in the registry spreads `...input`, so `host` flows through untouched. - -- [ ] **Step 5: Register the enterprise kind** - -In `SourceControlProviderRegistry.ts`'s `make`: - -```ts -export const make = Effect.gen(function* () { - const github = yield* GitHubSourceControlProvider.makeProvider("github"); - const githubEnterprise = yield* GitHubSourceControlProvider.makeProvider("github-enterprise"); - const gitlab = yield* GitLabSourceControlProvider.make; - const bitbucket = yield* BitbucketSourceControlProvider.make; - const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery; - const azureDevOps = yield* AzureDevOpsSourceControlProvider.make; - return yield* makeWithProviders([ - { - kind: "github", - provider: github, - discovery: GitHubSourceControlProvider.discovery, - }, - { - // Rows come from the `gh` spec's expandInstances; no spec of its own. - kind: "github-enterprise", - provider: githubEnterprise, - }, - // ...gitlab, azure-devops, bitbucket unchanged... - ]); -}); -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `vp test run apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts` -Expected: PASS - -- [ ] **Step 7: Commit** - -```bash -git add apps/server/src/sourceControl/GitHubSourceControlProvider.ts apps/server/src/sourceControl/SourceControlProvider.ts apps/server/src/sourceControl/SourceControlProviderRegistry.ts apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts -git commit -m "feat(server): register github-enterprise provider on the gh CLI" -``` - ---- - -### Task 7: Repository service — host threading and validation - -**Files:** - -- Modify: `apps/server/src/sourceControl/SourceControlRepositoryService.ts:97-127`, `:180-232` -- Test: `apps/server/src/sourceControl/SourceControlRepositoryService.test.ts` - -**Interfaces:** - -- Consumes: `host` on the three contract inputs (Task 1); `host` on provider operations (Task 6). -- Produces: `lookupRepository`, `cloneRepository`, and `publishRepository` forward `input.host`. `ensureConcreteProvider` gains a `host` parameter and fails when `provider === "github-enterprise"` and no host is present. - -- [ ] **Step 1: Write the failing test** - -Append to `apps/server/src/sourceControl/SourceControlRepositoryService.test.ts` (reuse the file's existing service layer and provider mocks): - -```ts -describe("github-enterprise host requirement", () => { - it.effect("rejects an enterprise lookup with no host", () => - Effect.gen(function* () { - const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; - const error = yield* service - .lookupRepository({ provider: "github-enterprise", repository: "owner/repo" }) - .pipe(Effect.flip); - - expect(error.detail).toBe("Choose a GitHub Enterprise host before continuing."); - }), - ); - - it.effect("forwards the host to the provider", () => - Effect.gen(function* () { - const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; - yield* service.lookupRepository({ - provider: "github-enterprise", - repository: "owner/repo", - host: "git.corp.com", - }); - - expect(getRepositoryCloneUrls.mock.calls[0]![0].host).toBe("git.corp.com"); - }), - ); -}); -``` - -`getRepositoryCloneUrls` is the `vi.fn` backing the mocked provider; add it to the file's existing mock setup if it is not already a spy. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `vp test run apps/server/src/sourceControl/SourceControlRepositoryService.test.ts` -Expected: FAIL — the hostless lookup succeeds instead of failing, and `host` never reaches the provider. - -- [ ] **Step 3: Validate the host** - -```ts -const ensureConcreteProvider = (input: { - readonly operation: string; - readonly provider: SourceControlProviderKind; - readonly host?: string | undefined; -}) => { - if (input.provider === "unknown") { - return Effect.fail( - new SourceControlRepositoryError({ - operation: input.operation, - provider: input.provider, - detail: "Choose a source control provider before continuing.", - }), - ); - } - - if (input.provider === "github-enterprise" && !input.host?.trim()) { - return Effect.fail( - new SourceControlRepositoryError({ - operation: input.operation, - provider: input.provider, - detail: "Choose a GitHub Enterprise host before continuing.", - }), - ); - } - - return Effect.succeed(input.provider); -}; -``` - -- [ ] **Step 4: Forward the host at all three call sites** - -In `lookupRepository`: - -```ts -const providerKind = - yield * - ensureConcreteProvider({ - operation: "lookupRepository", - provider: input.provider, - host: input.host, - }); -const provider = yield * providers.get(providerKind); -const urls = - yield * - provider.getRepositoryCloneUrls({ - cwd: input.cwd ?? config.cwd, - repository: input.repository.trim(), - ...(input.host ? { host: input.host } : {}), - }); -``` - -In `cloneRepository`, add `...(input.host ? { host: input.host } : {})` to the inner `lookupRepository({...})` call. - -In `publishRepository`, pass `host: input.host` to `ensureConcreteProvider` and add `...(input.host ? { host: input.host } : {})` to the `provider.createRepository({...})` call. - -- [ ] **Step 5: Run test to verify it passes** - -Run: `vp test run apps/server/src/sourceControl/SourceControlRepositoryService.test.ts` -Expected: PASS - -- [ ] **Step 6: Typecheck the server** - -Run: `vp run --filter t3-server typecheck` (confirm the package name with `grep '"name"' apps/server/package.json` and use whatever it reports) -Expected: PASS - -- [ ] **Step 7: Commit** - -```bash -git add apps/server/src/sourceControl/SourceControlRepositoryService.ts apps/server/src/sourceControl/SourceControlRepositoryService.test.ts -git commit -m "feat(server): route repository operations to an enterprise host" -``` - ---- - -### Task 8: Web — accept enterprise pull request URLs - -**Files:** - -- Modify: `apps/web/src/pullRequestReference.ts:1-2` -- Test: `apps/web/src/pullRequestReference.test.ts` (exists; uses `vite-plus/test`) - -**Interfaces:** - -- Consumes: nothing. -- Produces: `parsePullRequestReference` accepts `https://///pull/`. - -- [ ] **Step 1: Write the failing test** - -Merge these cases into the existing `apps/web/src/pullRequestReference.test.ts` (it already imports `describe`/`expect`/`it` from `vite-plus/test` and `parsePullRequestReference` from `./pullRequestReference`). Fold them into the existing describe blocks where one fits; do not overwrite the file or duplicate existing assertions: - -```ts -describe("parsePullRequestReference enterprise hosts", () => { - it("accepts an enterprise pull request url", () => { - expect(parsePullRequestReference("https://git.corp.com/owner/repo/pull/42")).toBe( - "https://git.corp.com/owner/repo/pull/42", - ); - }); - - it("accepts a ghe.com pull request url", () => { - expect(parsePullRequestReference("https://acme.ghe.com/owner/repo/pull/7")).toBe( - "https://acme.ghe.com/owner/repo/pull/7", - ); - }); - - it("still accepts github.com urls and bare numbers", () => { - expect(parsePullRequestReference("https://github.com/owner/repo/pull/1")).toBe( - "https://github.com/owner/repo/pull/1", - ); - expect(parsePullRequestReference("#12")).toBe("12"); - }); - - it("still rejects a non pull request url", () => { - expect(parsePullRequestReference("https://git.corp.com/owner/repo/issues/42")).toBeNull(); - expect(parsePullRequestReference("https://git.corp.com/owner/repo")).toBeNull(); - }); - - it("still unwraps a gh cli checkout command", () => { - expect(parsePullRequestReference("gh pr checkout https://git.corp.com/owner/repo/pull/9")).toBe( - "https://git.corp.com/owner/repo/pull/9", - ); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `vp test run apps/web/src/pullRequestReference.test.ts` -Expected: FAIL — enterprise URLs return `null`. - -- [ ] **Step 3: Widen the pattern** - -```ts -const GITHUB_PULL_REQUEST_URL_PATTERN = - /^https:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; -``` - -Leave the GitLab and Azure DevOps patterns and the evaluation order in `parsePullRequestReference` exactly as they are. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `vp test run apps/web/src/pullRequestReference.test.ts` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add apps/web/src/pullRequestReference.ts apps/web/src/pullRequestReference.test.ts -git commit -m "fix(web): accept enterprise pull request urls" -``` - ---- - -### Task 9: Web settings — one row per connection - -**Files:** - -- Modify: `apps/web/src/components/settings/SourceControlSettings.tsx:64-69`, `:565-575`, `:266-300` - -**Interfaces:** - -- Consumes: `id` and `host` on `SourceControlProviderDiscoveryItem` (Task 1). -- Produces: no exports; UI only. - -- [ ] **Step 1: Map the enterprise icon** - -```ts -const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { - github: GitHubIcon, - "github-enterprise": GitHubIcon, - gitlab: GitLabIcon, - "azure-devops": AzureDevOpsIcon, - bitbucket: BitbucketIcon, -}; -``` - -- [ ] **Step 2: Key rows on the item id** - -```tsx -{ - result.sourceControlProviders.map((item) => ( - - )); -} -``` - -Two enterprise connections previously collided on `kind` and React would have warned about duplicate keys. - -- [ ] **Step 3: Show the host beside the label** - -In `DiscoveryItemRow`, next to the `{item.label}` span, render the host when the item is a provider item, has a host, and the host differs from the label (so the `github` row does not read "GitHub github.com" and an enterprise row does not repeat its own hostname): - -```tsx -const host = isProviderDiscoveryItem(item) ? optionLabel(item.host) : null; -``` - -```tsx -{ - host && host !== item.label ? ( - {host} - ) : null; -} -``` - -- [ ] **Step 4: Typecheck the web app** - -Run: `vp run --filter @t3tools/web typecheck` (confirm the package name with `grep '"name"' apps/web/package.json`) -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add apps/web/src/components/settings/SourceControlSettings.tsx -git commit -m "feat(web): list each GitHub Enterprise connection in settings" -``` - ---- - -### Task 10: Web — publish to an enterprise host - -**Files:** - -- Modify: `apps/web/src/components/GitActionsControl.tsx:158-198` and the publish submit path - -**Interfaces:** - -- Consumes: discovery `id`/`host` (Task 1); `host` on `SourceControlPublishRepositoryInput` (Task 1); server-side validation (Task 7). -- Produces: no exports; UI only. - -- [ ] **Step 1: Make the option list discovery-aware** - -Keep `PUBLISH_PROVIDER_OPTIONS` as the static list of the four hosted providers, then derive the rendered list. Add an `id` and `host` to the option shape: - -```ts -interface PublishProviderOption { - readonly id: string; - readonly value: PublishProviderKind; - readonly label: string; - readonly description: string; - readonly host: string; - readonly pathPlaceholder: string; - readonly Icon: typeof GitHubIcon; -} -``` - -Give each static entry an `id` equal to its `value` (`"github"`, `"gitlab"`, `"bitbucket"`, `"azure-devops"`), matching the discovery ids from Task 4. - -- [ ] **Step 2: Append one entry per enterprise connection** - -`GitActionsControl.tsx:379` already runs `sourceControlEnvironment.discovery({ environmentId, input: {} })` via `useEnvironmentQuery` — reuse that result rather than adding a second query. Insert enterprise entries directly after the `github` entry so related options stay adjacent: - -```ts -const enterpriseOptions: ReadonlyArray = discovery.sourceControlProviders - .filter((item) => item.kind === "github-enterprise" && item.status === "available") - .flatMap((item) => { - const host = Option.getOrNull(item.host); - if (!host) return []; - return [ - { - id: item.id, - value: "github-enterprise" as const, - label: "GitHub Enterprise", - description: host, - host, - pathPlaceholder: "owner/repo", - Icon: GitHubIcon, - }, - ]; - }); -``` - -Select the active option by `id`, not by `value` — two enterprise entries share the same `value`. - -- [ ] **Step 3: Send the host on publish** - -Where the publish command is dispatched, include the selected option's host for enterprise only: - -```ts - provider: selectedOption.value, - ...(selectedOption.value === "github-enterprise" ? { host: selectedOption.host } : {}), -``` - -- [ ] **Step 4: Widen `PublishProviderKind`** - -Find its declaration (`grep -rn "PublishProviderKind" apps/web/src packages`) and add `"github-enterprise"` so the union covers the new value. - -- [ ] **Step 5: Typecheck and lint the web app** - -Run: `vp run --filter @t3tools/web typecheck && vp lint apps/web/src/components/GitActionsControl.tsx` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add apps/web/src/components/GitActionsControl.tsx -git commit -m "feat(web): publish repositories to a GitHub Enterprise host" -``` - ---- - -### Task 11: Client runtime — add-project sources per connection - -**Files:** - -- Modify: `packages/client-runtime/src/operations/projects.ts:25-172` -- Test: `packages/client-runtime/src/operations/projects.test.ts` - -**Interfaces:** - -- Consumes: discovery `id`/`host` (Task 1), enterprise discovery rows (Task 4). -- Produces: - - `AddProjectRemoteSource` stays `AddProjectRemoteProviderKind | "url"`, unchanged. - - New `AddProjectRemoteTarget = { readonly id: string; readonly source: AddProjectRemoteSource; readonly host: string | null }`. - - New `buildAddProjectRemoteTargets(discovery: SourceControlDiscoveryResult | null): ReadonlyArray` — always includes `{ id: "url", source: "url", host: null }` first, then one target per discovery provider row, enterprise rows carrying their host. - - `AddProjectRemoteSourceReadiness` becomes `ReadonlyMap` keyed by target `id`. - - `sortAddProjectProviderSources(readiness, targets)` returns `ReadonlyArray` excluding the `url` target. - - `addProjectRemoteTargetLabel(target)` returns the existing static labels, or the host for an enterprise target. - -- [ ] **Step 1: Write the failing test** - -Append to `packages/client-runtime/src/operations/projects.test.ts` (reuse the file's existing discovery fixture builder; if it builds items inline, extend it with `id` and `host`): - -```ts -describe("buildAddProjectRemoteTargets", () => { - it("returns url plus one target per discovered connection", () => { - const targets = buildAddProjectRemoteTargets( - discoveryResult([ - providerItem({ kind: "github", id: "github", host: "github.com" }), - providerItem({ - kind: "github-enterprise", - id: "github-enterprise:git.corp.com", - host: "git.corp.com", - label: "git.corp.com", - }), - ]), - ); - - expect(targets.map((target) => target.id)).toEqual([ - "url", - "github", - "github-enterprise:git.corp.com", - ]); - expect(targets[2]!.host).toBe("git.corp.com"); - expect(targets[2]!.source).toBe("github-enterprise"); - }); - - it("labels an enterprise target with its host", () => { - expect( - addProjectRemoteTargetLabel({ - id: "github-enterprise:git.corp.com", - source: "github-enterprise", - host: "git.corp.com", - }), - ).toBe("git.corp.com"); - }); - - it("keys readiness by target id", () => { - const discovery = discoveryResult([ - providerItem({ - kind: "github-enterprise", - id: "github-enterprise:git.corp.com", - host: "git.corp.com", - auth: { status: "unauthenticated", detail: "Run gh auth login." }, - }), - ]); - - const readiness = buildAddProjectRemoteSourceReadiness(discovery); - - expect(readiness.get("github-enterprise:git.corp.com")).toEqual({ - ready: false, - hint: "Run gh auth login.", - }); - expect(readiness.get("url")).toEqual({ ready: true, hint: null }); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `vp test run packages/client-runtime/src/operations/projects.test.ts` -Expected: FAIL with "buildAddProjectRemoteTargets is not defined". - -- [ ] **Step 3: Add the target type and builder** - -```ts -export type AddProjectRemoteProviderKind = Extract< - SourceControlProviderKind, - "github" | "github-enterprise" | "gitlab" | "bitbucket" | "azure-devops" ->; -export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; - -export interface AddProjectRemoteTarget { - readonly id: string; - readonly source: AddProjectRemoteSource; - readonly host: string | null; -} - -const URL_TARGET: AddProjectRemoteTarget = { id: "url", source: "url", host: null }; - -export function buildAddProjectRemoteTargets( - discovery: SourceControlDiscoveryResult | null, -): ReadonlyArray { - if (!discovery) return [URL_TARGET]; - return [ - URL_TARGET, - ...discovery.sourceControlProviders.flatMap((provider) => - provider.kind === "unknown" - ? [] - : [{ id: provider.id, source: provider.kind, host: Option.getOrNull(provider.host) }], - ), - ]; -} -``` - -- [ ] **Step 4: Add the enterprise label and path hint** - -Add `case "github-enterprise": return "GitHub Enterprise";` to `addProjectRemoteSourceLabel` and `case "github-enterprise": return "owner/repo";` to `addProjectRemoteSourcePathHint`. Then add the target-aware label: - -```ts -export function addProjectRemoteTargetLabel(target: AddProjectRemoteTarget): string { - if (target.source === "github-enterprise" && target.host) { - return target.host; - } - return addProjectRemoteSourceLabel(target.source); -} -``` - -- [ ] **Step 5: Rekey readiness by target id** - -```ts -export type AddProjectRemoteSourceReadiness = ReadonlyMap< - string, - { readonly ready: boolean; readonly hint: string | null } ->; - -export function buildAddProjectRemoteSourceReadiness( - discovery: SourceControlDiscoveryResult | null, -): AddProjectRemoteSourceReadiness { - const readiness = new Map([ - ["url", { ready: true, hint: null }], - ]); - if (!discovery) return readiness; - - for (const provider of discovery.sourceControlProviders) { - if (provider.kind === "unknown") continue; - if (provider.status !== "available") { - readiness.set(provider.id, { ready: false, hint: provider.installHint }); - continue; - } - if (provider.auth.status === "unauthenticated") { - readiness.set(provider.id, { - ready: false, - hint: - Option.getOrNull(provider.auth.detail) ?? - `${provider.label} is not authenticated. Open Source Control settings for setup guidance.`, - }); - continue; - } - readiness.set(provider.id, { ready: true, hint: null }); - } - return readiness; -} -``` - -The previous "Provider status unavailable" fallback now applies to any target id missing from the map; consumers read it via a helper: - -```ts -export function addProjectRemoteTargetReadiness( - readiness: AddProjectRemoteSourceReadiness, - targetId: string, -): { readonly ready: boolean; readonly hint: string | null } { - return ( - readiness.get(targetId) ?? { - ready: false, - hint: "Provider status unavailable. Open Source Control settings and rescan.", - } - ); -} -``` - -- [ ] **Step 6: Sort targets instead of source literals** - -```ts -export function sortAddProjectProviderSources( - readinessBySource: AddProjectRemoteSourceReadiness, - targets: ReadonlyArray, -): ReadonlyArray { - return Arr.sort( - targets.filter((target) => target.source !== "url"), - Order.mapInput( - Order.Struct({ - ready: Order.flip(Order.Boolean), - label: Order.String, - }), - (target: AddProjectRemoteTarget) => ({ - ready: addProjectRemoteTargetReadiness(readinessBySource, target.id).ready, - label: addProjectRemoteTargetLabel(target), - }), - ), - ); -} -``` - -Delete `ADD_PROJECT_REMOTE_SOURCES` and `ADD_PROJECT_REMOTE_PROVIDER_SOURCES` — targets now come from discovery. - -- [ ] **Step 7: Update the existing tests in the file** - -The existing assertion `expect(sortAddProjectProviderSources(readiness)[0]).toBe("github")` (line 99) now needs the targets argument and compares `.id`: - -```ts -expect(sortAddProjectProviderSources(readiness, targets)[0]!.id).toBe("github"); -``` - -Update every other call site in the test file the same way. - -- [ ] **Step 8: Run test to verify it passes** - -Run: `vp test run packages/client-runtime/src/operations/projects.test.ts` -Expected: PASS - -- [ ] **Step 9: Commit** - -```bash -git add packages/client-runtime/src/operations/projects.ts packages/client-runtime/src/operations/projects.test.ts -git commit -m "feat(client-runtime): derive add-project sources from discovered connections" -``` - ---- - -### Task 12: Consumers — web command palette and mobile add project - -**Files:** - -- Modify: `apps/web/src/components/CommandPalette.tsx:179-230` -- Modify: `apps/mobile/src/features/projects/AddProjectScreen.tsx:95-106`, `:371-390`, `:505-515`, `:605-640` -- Modify: `apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx:15-24` -- Modify: `apps/mobile/src/components/SourceControlIcon.tsx:3-16` - -**Interfaces:** - -- Consumes: `AddProjectRemoteTarget`, `buildAddProjectRemoteTargets`, `addProjectRemoteTargetLabel`, `addProjectRemoteTargetReadiness`, and the new `sortAddProjectProviderSources` signature from Task 11. -- Produces: no exports; UI only. - -- [ ] **Step 1: Update the web command palette** - -`CommandPalette.tsx` declares its own local `AddProjectRemoteSource` alias and a static `REMOTE_PROJECT_SOURCES` array. Replace both with targets from `buildAddProjectRemoteTargets(discovery)`, render `addProjectRemoteTargetLabel(target)`, use `addProjectRemoteSourcePathHint(target.source)` for the hint, and carry `target.host` into the navigation params it dispatches. - -- [ ] **Step 2: Carry host through mobile route params** - -In `AddProjectRepositoryRoute.tsx`, add `host` to `AddProjectRepositoryRouteParams` and replace the hardcoded source check with a target-aware title: - -```tsx -const source = Array.isArray(params.source) ? params.source[0] : params.source; -const host = Array.isArray(params.host) ? params.host[0] : params.host; -const title = - source === "github" || - source === "github-enterprise" || - source === "gitlab" || - source === "bitbucket" || - source === "azure-devops" - ? addProjectRemoteTargetLabel({ id: source, source, host: host ?? null }) - : "Git URL"; -``` - -- [ ] **Step 3: Accept the enterprise source in mobile param parsing** - -In `AddProjectScreen.tsx`, add `source === "github-enterprise" ||` to the `sourceFromParam` guard, and thread a `host: string | null` alongside `source` through the screen's props and navigation calls. - -- [ ] **Step 4: Render targets in the mobile picker** - -Replace the `["url", ...sortAddProjectProviderSources(readiness)]` mapping at line 509 with targets: - -```tsx -{[ - { id: "url", source: "url", host: null } as AddProjectRemoteTarget, - ...sortAddProjectProviderSources(readiness, targets), -].map((target) => ( - // key on target.id, label with addProjectRemoteTargetLabel(target), - // readiness via addProjectRemoteTargetReadiness(readiness, target.id) -))} -``` - -- [ ] **Step 5: Send the host on lookup** - -In `lookupRepository` (line ~608), include the host: - -```ts -const result = await lookupRepositoryQuery({ - environmentId: environment.environmentId, - input: { - provider, - repository: repositoryInput.trim(), - ...(host ? { host } : {}), - }, -}); -``` - -- [ ] **Step 6: Accept the enterprise kind in the mobile icon** - -`AddProjectScreen.tsx:389` renders ``, passing the raw source rather than a presentation icon, so the union must widen. In `apps/mobile/src/components/SourceControlIcon.tsx`: - -```tsx -export type SourceControlIconKind = - | "github" - | "github-enterprise" - | "gitlab" - | "bitbucket" - | "azure-devops"; -``` - -Then make the existing GitHub arm serve both by replacing `case "github":` with: - -```tsx - case "github": - case "github-enterprise": -``` - -Leave the SVG body untouched — enterprise reuses the GitHub mark. - -- [ ] **Step 7: Typecheck web and mobile** - -Run: `vp run --filter @t3tools/web typecheck && vp run --filter @t3tools/mobile typecheck` (confirm both package names from their `package.json` files) -Expected: PASS - -- [ ] **Step 8: Run the touched test files** - -Run: `vp test run packages/client-runtime/src/operations/projects.test.ts packages/shared/src/sourceControl.test.ts apps/web/src/pullRequestReference.test.ts apps/server/src/sourceControl/` -Expected: PASS - -- [ ] **Step 9: Commit** - -```bash -git add apps/web/src/components/CommandPalette.tsx apps/mobile/src/features/projects/AddProjectScreen.tsx apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx apps/mobile/src/components/SourceControlIcon.tsx -git commit -m "feat(web,mobile): offer GitHub Enterprise connections when adding a project" -``` - ---- - -## Manual verification - -After Task 12, on a machine with `gh` authenticated against an enterprise host (`gh auth login --hostname `): - -1. Open Source Control settings. Expect a `GitHub` row plus one row per enterprise host, each showing its own account. -2. Open a repository cloned from the enterprise host. Expect PR list, PR view, and create-PR to work and to say "pull request". -3. Paste an enterprise PR URL into the PR reference field. Expect it to be accepted. -4. Add Project → expect the enterprise host listed as its own source; look up `owner/repo` against it. - -Ask before launching a dev server or browser. If asked to verify in a real client, use the `test-t3-app` skill for web and `test-t3-mobile` for mobile. - -## Notes for the implementer - -- `parseGitHubAuthStatus` lowercases hosts already (`gitHubAuthStatus.ts:53`). Compare hosts lowercased everywhere else. -- `gh auth status --json hosts` is only available on newer `gh`. The existing code already tolerates unparseable output by falling back to `parseGitHubAuth`; Task 4's `status.parsed` check preserves that path — do not remove it. -- Provider-context detection is cached for 5 seconds (`SourceControlProviderRegistry.ts:29`). When manually testing detection changes, wait that long or restart the server. diff --git a/docs/superpowers/specs/2026-07-30-github-enterprise-connector-design.md b/docs/superpowers/specs/2026-07-30-github-enterprise-connector-design.md deleted file mode 100644 index f1c8ff7e0da..00000000000 --- a/docs/superpowers/specs/2026-07-30-github-enterprise-connector-design.md +++ /dev/null @@ -1,279 +0,0 @@ -# GitHub Enterprise Connector — Design - -## Problem - -T3 has a GitHub source control connector built on the `gh` CLI. It does not recognize GitHub -Enterprise. Two host families fail: - -- **GitHub Enterprise Server** on an arbitrary hostname (`git.corp.com`) — - `detectSourceControlProviderFromRemoteUrl` (`packages/shared/src/sourceControl.ts:186`) falls - through to kind `unknown`, so no provider is routed. -- **GitHub Enterprise Cloud with data residency** on `.ghe.com` — same failure; the - hostname contains neither `github` nor `github.com`. - -A third case is mislabeled rather than broken: `github.acme.com` matches -`isGitHubHost`'s `host.includes("github")` and resolves to kind `github`, name -"GitHub Self-Hosted". - -The `gh` CLI itself supports enterprise hosts natively — `gh auth login --hostname`, -`gh auth status --json hosts`, and per-repo host resolution from the git remote. The gap is -entirely on the T3 side. - -## Goals - -Full parity with the existing GitHub connector, against any number of enterprise hosts -simultaneously: PR list / view / create / checkout, repository lookup, clone, publish, default -branch, discovery, and settings display. - -Enterprise hosts appear as distinct connections, one per host, in the same way Cursor models -separate GitHub and GitHub Enterprise connections. - -## Non-goals - -T3 does not drive `gh auth login`. Enterprise authentication stays in the user's `gh` config, -consistent with how GitLab, Bitbucket, and Azure DevOps auth work today. An unauthenticated host -surfaces in settings with a `gh auth login --hostname ` hint. - -## Prior art in this repo - -GitLab already solved the self-hosted detection problem. `GitLabSourceControlProvider.ts:73` -defines `refineUnknownGitLabRemote`, wired into the discovery spec as `refineUnknownRemote`. When -a remote resolves to kind `unknown`, `refineUnknownRemoteProvider` -(`SourceControlProviderDiscovery.ts:270`) runs each spec's auth probe and lets it claim the host. -The GitHub spec has no such hook. This design follows the same mechanism. - -## Design - -### 1. Contract - -`packages/contracts/src/sourceControl.ts:5` gains a kind: - -```ts -export const SourceControlProviderKind = Schema.Literals([ - "github", "github-enterprise", "gitlab", "azure-devops", "bitbucket", "unknown", -]); -``` - -Because multiple enterprise hosts coexist, a discovery item needs identity beyond its kind: - -```ts -export const SourceControlProviderDiscoveryItem = Schema.Struct({ - kind: SourceControlProviderKind, - id: TrimmedNonEmptyString, // "github" | "github-enterprise:git.acme.com" - host: Schema.Option(TrimmedNonEmptyString), - ...SourceControlDiscoverySharedFields, - auth: SourceControlProviderAuth, -}); -``` - -`id` replaces `kind` as the React key for settings rows -(`apps/web/src/components/settings/SourceControlSettings.tsx:571`). `host` drives the row subtitle -and host-scoped commands. - -Operations that run outside a repository need a target host, so `host` is added as an optional -field to `SourceControlRepositoryLookupInput`, `SourceControlCloneRepositoryInput`, and -`SourceControlPublishRepositoryInput`. It is ignored for every kind except `github-enterprise`, -where it is required. - -Nothing persists a provider kind — it is computed at runtime from the git remote on every -request, and the only references outside runtime code are in `packages/contracts/src/git.ts` and -`packages/contracts/src/sourceControl.ts`. Adding a kind therefore needs no data migration, and -reclassifying `github.acme.com` breaks no stored rows. - -### 2. Host detection - -`detectSourceControlProviderFromRemoteUrl` is synchronous and has no process access, so it can -only classify hosts recognizable by name: - -```ts -function isGitHubEnterpriseHost(host: string): boolean { - return host !== "github.com" && (host.endsWith(".ghe.com") || host.includes("github")); -} -``` - -Resolution order: `github.com` → kind `github`. Then `*.ghe.com` or a hostname containing -`github` → kind `github-enterprise`, `name` = the hostname, `baseUrl` = `https://`. -Everything else falls through unchanged to `unknown`. - -This moves `github.acme.com` from kind `github` / name "GitHub Self-Hosted" to kind -`github-enterprise`. Intended, and safe per §1. - -Arbitrary hostnames cannot be classified by name, so they resolve to `unknown` and are refined by -CLI probe. Add to the GitHub discovery spec: - -```ts -function refineUnknownGitHubRemote(input: SourceControlUnknownRemoteRefinementInput) { - const host = input.context.provider.name.toLowerCase(); - const account = parseGitHubAuthStatus(input.auth.stdout).accounts - .find((entry) => entry.host === host && entry.authenticated); - if (!account) return null; - return { kind: "github-enterprise", name: host, baseUrl: input.context.provider.baseUrl } as const; -} -``` - -A GHES install on an arbitrary hostname is thus recognized once `gh auth login --hostname` has -been run for it — which is required for the connector to function at all, so this adds no burden. -`refineUnknownRemoteProvider` already runs specs in order and takes the first non-null result, and -the outcome is cached for 5 seconds (`SourceControlProviderRegistry.ts:29`). - -A `refineUnknownRemote` hook returning a kind other than its own spec's kind is already permitted -by the signature — it returns a full `SourceControlProviderInfo`, not a kind-bound value. No -change to `SourceControlProviderDiscovery.ts`'s refinement path is needed. - -### 3. Discovery — one `gh` probe, N rows - -`discover` currently maps one spec to one item (`SourceControlProviderRegistry.ts:272`). -Enterprise needs one spec to produce N items, without spawning `gh --version` and -`gh auth status` twice to populate two sections. - -The `gh` spec fans out. `SourceControlCliDiscoverySpec` gains an optional hook: - -```ts -readonly expandInstances?: (input: SourceControlAuthProbeInput) => ReadonlyArray<{ - readonly kind: SourceControlProviderKind; - readonly id: string; - readonly host: string | null; - readonly label: string; - readonly auth: SourceControlProviderAuth; -}>; -``` - -`probeSourceControlProvider` returns `ReadonlyArray` and -`discover` flattens. Specs without `expandInstances` return a single-element array, leaving -GitLab, Bitbucket, and Azure DevOps behavior unchanged. - -`expandInstances` is consulted only on the success path, after the auth probe runs. When it is -present it supersedes `parseAuth` for that spec — the `gh` spec keeps `parseAuth` only as the -fallback used by `refineUnknownRemote`, which receives a raw probe rather than expanded items. On -the failure paths — executable missing, or the auth probe itself erroring — the existing -single-item construction runs unchanged using the spec's own `kind`, so a missing `gh` still -yields exactly one `github` row. - -The `gh` expansion reads `parseGitHubAuthStatus`, which already parses `hosts` as a record -(`gitHubAuthStatus.ts:44`), and emits: - -- always one `github` item for `github.com`, auth from the github.com account — identical to - today's row -- one `github-enterprise` item per other host, with `id: "github-enterprise:"`, - `label: `, and auth from that host's account - -When `gh` is missing: one `github` item with status `missing`, zero enterprise items. When `gh` is -present but no enterprise host is logged in: zero enterprise items. Enterprise rows exist only -when a connection exists. - -Registry side: `github-enterprise` registers a provider (§4) but no discovery spec of its own, so -`SourceControlProviderRegistration.discovery` becomes optional and `discoverySpecs` filters nulls. - -Settings groups the flattened list by kind, keys rows on `item.id`, and renders `item.host` as the -row subtitle so two enterprise connections are visually distinct. - -### 4. Provider and `gh` host routing - -`gh` resolves its target host from the repository's git remote, so every in-repo operation — -`pr list`, `pr view`, `pr create`, `pr checkout`, `repo view --json defaultBranchRef` — works -against an authenticated GHES host with no extra flags. Those paths need only registration. - -Operations that run outside a repository do need targeting: `repo view owner/repo` and -`repo create`. Those `GitHubCli` methods gain an optional `host`, threaded into the `env` support -`VcsProcess` already exposes (`VcsProcess.ts:27`): - -```ts -...(input.host ? { env: { ...process.env, GH_HOST: input.host } } : {}), -``` - -`GitHubSourceControlProvider.make` becomes parametrized by kind, since it currently hardcodes -`provider: "github"` in `toChangeRequest` and in every `SourceControlProviderError`: - -```ts -export const makeProvider = (kind: "github" | "github-enterprise") => Effect.gen(...) -``` - -The registry registers both kinds against the same `GitHubCli` service — one binary, two routing -keys, no duplicated command logic. - -This also fixes `GitHubCli.ts:279`: `deriveRepositoryCloneUrlsFromCreateOutput` hardcodes -`fallbackHost = "github.com"`, which on an enterprise host silently fabricates a `github.com` URL. -It takes the caller's host, defaulting to `github.com`. - -### 5. Host-targeted operations and pickers - -`PUBLISH_PROVIDER_OPTIONS` (`apps/web/src/components/GitActionsControl.tsx:158`) is a static list -of four hosted providers. It becomes that list plus one entry per discovered enterprise -connection: - -``` -GitHub github.com -GitHub Enterprise git.corp.com -GitHub Enterprise acme.ghe.com -GitLab gitlab.com -… -``` - -Selecting an enterprise entry sets `provider: "github-enterprise"` and `host: ` on the -publish input. `pathPlaceholder` stays `owner/repo`; `description` is the host. The clone and Add -Project repository pickers on web and mobile (`AddProjectRepositoryRoute.tsx`, -`AddProjectScreen.tsx`) take the same shape. - -`SourceControlRepositoryService` threads `host` from those inputs into `getRepositoryCloneUrls` -and `createRepository`, which forward it as `GH_HOST` per §4. `ensureConcreteProvider` -(`SourceControlRepositoryService.ts:101`) additionally rejects `github-enterprise` with no host — -a kind that cannot be routed without one. - -Icons: `SOURCE_CONTROL_PROVIDER_ICONS` (web) and `SourceControlIcon.tsx` (mobile) map -`github-enterprise` to the existing GitHub mark. - -### 6. Presentation and reference parsing - -`packages/shared/src/sourceControl.ts` gains `GITHUB_ENTERPRISE_CHANGE_REQUEST_PRESENTATION`, -identical to the GitHub one — PR / pull request / `gh pr checkout 123`, icon `github` — except -`providerName: "GitHub Enterprise"` and -`urlExample: "https://git.company.com/owner/repo/pull/42"`. - -The `switch` in `resolveChangeRequestPresentation` is exhaustive over the literal union, so -TypeScript flags every other site needing the new case. That compiler output is the authoritative -checklist for the remaining web and mobile switches. - -`apps/web/src/pullRequestReference.ts:2` pins the PR URL pattern to `github.com`, so an enterprise -PR URL is rejected. Widen it the way the GitLab pattern at line 3 already is: - -```ts -const GITHUB_PULL_REQUEST_URL_PATTERN = - /^https:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; -``` - -Host-agnostic by shape (`/owner/repo/pull/N`), which is correct because the function only -normalizes a reference string for `gh pr view` — and `gh` resolves the host from the repository. -The pattern stays ordered after the Azure DevOps and GitLab patterns; those use `/_git/… -/pullrequest/` and `/-/merge_requests/` and cannot collide with `/pull/`, but the ordering keeps -the intent legible. - -## Testing - -Unit tests, following the existing per-file `.test.ts` convention: - -- `packages/shared` — `detectSourceControlProviderFromRemoteUrl` over `github.com`, - `acme.ghe.com`, `github.acme.com`, `git.corp.com`, and SSH-form remotes; the - `github.acme.com` reclassification asserted explicitly as intended behavior -- `GitHubSourceControlProvider.test.ts` — `refineUnknownGitHubRemote` returns enterprise for an - authenticated matching host and `null` for unauthenticated or non-matching hosts; - `expandInstances` over a multi-host `gh auth status --json hosts` fixture covering zero, one, - and two enterprise hosts, plus `gh` missing -- `GitHubCli.test.ts` — `GH_HOST` present when `host` is passed and absent otherwise; enterprise - clone-url fallback derives the enterprise host, not `github.com` -- `SourceControlRepositoryService.test.ts` — `github-enterprise` without a host is rejected; with - a host it routes and forwards correctly -- `SourceControlProviderRegistry.test.ts` — flattened multi-item discovery; registration with an - absent discovery spec -- `pullRequestReference` — enterprise PR URL accepted; non-PR URLs still rejected - -## Risks - -Widening the PR URL pattern makes it structurally permissive: any `https://host/a/b/pull/N` now -parses. The function's contract is normalization, not validation — `gh pr view` rejects a -reference it cannot resolve — so the blast radius is a clearer downstream error rather than a -wrong action. Tests pin the non-PR rejection cases. - -Detection for arbitrary-hostname GHES depends on `gh auth status` succeeding. If `gh` is -installed but the host is not logged in, the remote stays `unknown` and no PR features appear. -This is the same failure mode GitLab self-hosted has today, and the settings row makes the cause -visible. From a3de5670dbd447e3f09753ea45d3b2cf0d0fc88e Mon Sep 17 00:00:00 2001 From: Jorrin Date: Fri, 31 Jul 2026 12:09:22 +0200 Subject: [PATCH 28/34] fix(server): report ambiguous bare enterprise repository names Several owners can hold the same repository name on an enterprise host. Search ranking is no basis for choosing between them, so resolving a bare name could silently target a repository the user never asked for. Report the candidates instead and let the caller supply the full owner/repo path. Also route the not-found detail through transportSafeSourceControlErrorValue so it matches the sanitized repository attribute on the same error. Co-Authored-By: Claude Opus 5 (1M context) --- .../GitHubSourceControlProvider.test.ts | 37 ++++++++++++++++ .../GitHubSourceControlProvider.ts | 44 ++++++++++++++----- 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 66d05415cdc..0fa58adab4d 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -681,6 +681,43 @@ describe("getRepositoryCloneUrls bare name resolution", () => { }), ); + it.effect("fails naming every owner when several exact matches share the bare name", () => + Effect.gen(function* () { + const searchRepositories = vi.fn(() => + Effect.succeed([ + { fullName: "team-a/core" }, + { fullName: "team-b/core" }, + { fullName: "team-c/core-docs" }, + ]), + ); + const getRepositoryCloneUrls = vi.fn(() => + Effect.succeed({ + nameWithOwner: "team-a/core", + url: "https://sollit.ghe.com/team-a/core", + sshUrl: "git@sollit.ghe.com:team-a/core.git", + }), + ); + + const provider = yield* makeProviderOfKind("github-enterprise", { + searchRepositories, + getRepositoryCloneUrls, + }); + + const error = yield* provider + .getRepositoryCloneUrls({ + cwd: "/repo", + repository: "core", + host: "sollit.ghe.com", + }) + .pipe(Effect.flip); + + expect(getRepositoryCloneUrls).not.toHaveBeenCalled(); + expect(error.detail).toContain("team-a/core"); + expect(error.detail).toContain("team-b/core"); + expect(error.detail).not.toContain("team-c/core-docs"); + }), + ); + it.effect("never calls search for an owner/repo reference on enterprise", () => Effect.gen(function* () { const searchRepositories = vi.fn(() => Effect.succeed([])); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 1dcb7fc9b2c..bc9264bbb48 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -204,23 +204,35 @@ function isBareRepositoryName(repository: string): boolean { return !repository.includes("/"); } +const AMBIGUOUS_REPOSITORY_CANDIDATE_LIMIT = 5; + +type RepositorySearchMatch = + | { readonly _tag: "match"; readonly fullName: string } + | { readonly _tag: "ambiguous"; readonly candidates: ReadonlyArray } + | { readonly _tag: "none" }; + // Bare names resolve against the caller's personal namespace on `gh repo // view`, which is usually empty on an enterprise host. Prefer the search // result whose repo-name segment matches the query exactly (search ranking -// otherwise happily puts e.g. "core-documentation" ahead of "core"). +// otherwise happily puts e.g. "core-documentation" ahead of "core"). Several +// owners can hold the same repo name, and search ranking is no basis for +// picking between them, so report that back instead of guessing. function pickRepositorySearchMatch( query: string, results: ReadonlyArray, -): string | null { +): RepositorySearchMatch { if (results.length === 0) { - return null; + return { _tag: "none" }; } const normalizedQuery = query.toLowerCase(); - const exact = results.find( + const exact = results.filter( (result) => result.fullName.split("/").pop()?.toLowerCase() === normalizedQuery, ); - return (exact ?? results[0]!).fullName; + if (exact.length > 1) { + return { _tag: "ambiguous", candidates: exact.map((result) => result.fullName) }; + } + return { _tag: "match", fullName: (exact[0] ?? results[0]!).fullName }; } export const makeProvider = (kind: GitHubProviderKind) => @@ -258,19 +270,29 @@ export const makeProvider = (kind: GitHubProviderKind) => ), Effect.flatMap((results) => { const match = pickRepositorySearchMatch(repository, results); - if (match) { - return Effect.succeed(match); + if (match._tag === "match") { + return Effect.succeed(match.fullName); } + + const safeRepository = + SourceControlProvider.transportSafeSourceControlErrorValue(repository); + const hostLabel = input.host ?? "the configured host"; return Effect.fail( new SourceControlProviderError({ provider: kind, operation: "getRepositoryCloneUrls", command: "gh", cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue(repository), - detail: `No repository named "${repository}" was found on ${ - input.host ?? "the configured host" - }.`, + repository: safeRepository, + detail: + match._tag === "ambiguous" + ? `Several repositories on ${hostLabel} are named "${safeRepository}": ${match.candidates + .slice(0, AMBIGUOUS_REPOSITORY_CANDIDATE_LIMIT) + .map((candidate) => + SourceControlProvider.transportSafeSourceControlErrorValue(candidate), + ) + .join(", ")}. Enter the full owner/repo path.` + : `No repository named "${safeRepository}" was found on ${hostLabel}.`, }), ); }), From 17f7304958b1f0cb10ef2e3b1fc40335769c5b17 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Fri, 31 Jul 2026 12:09:25 +0200 Subject: [PATCH 29/34] fix(web): keep enterprise publish reachable when a host is unauthenticated The publish dialog picked the alphabetically first enterprise host. When that host was not authenticated its card rendered as Setup Required rather than a radio, and the host picker only exists once the card is selected, so an authenticated host on the same machine became unreachable. Default to a ready host, and render unauthenticated hosts in the picker as Setup Required so selecting one cannot re-enter the same dead end. Co-Authored-By: Claude Opus 5 (1M context) --- .../GitActionsControl.logic.test.ts | 22 +++++++ .../src/components/GitActionsControl.logic.ts | 13 +++- apps/web/src/components/GitActionsControl.tsx | 66 +++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 851869d1a32..28f06ee1e3a 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -1333,6 +1333,7 @@ describe("resolveSelectedEnterpriseHost", () => { const host = resolveSelectedEnterpriseHost({ selectedHost: "git.corp.com", availableHosts: ["acme.ghe.com", "git.corp.com"], + readyHosts: ["acme.ghe.com", "git.corp.com"], }); assert.equal(host, "git.corp.com"); }); @@ -1341,6 +1342,25 @@ describe("resolveSelectedEnterpriseHost", () => { const host = resolveSelectedEnterpriseHost({ selectedHost: null, availableHosts: ["acme.ghe.com", "git.corp.com"], + readyHosts: ["acme.ghe.com", "git.corp.com"], + }); + assert.equal(host, "acme.ghe.com"); + }); + + it("skips an unauthenticated first host in favour of a ready one", () => { + const host = resolveSelectedEnterpriseHost({ + selectedHost: null, + availableHosts: ["acme.ghe.com", "git.corp.com"], + readyHosts: ["git.corp.com"], + }); + assert.equal(host, "git.corp.com"); + }); + + it("falls back to the first available host when none are ready", () => { + const host = resolveSelectedEnterpriseHost({ + selectedHost: null, + availableHosts: ["acme.ghe.com", "git.corp.com"], + readyHosts: [], }); assert.equal(host, "acme.ghe.com"); }); @@ -1349,6 +1369,7 @@ describe("resolveSelectedEnterpriseHost", () => { const host = resolveSelectedEnterpriseHost({ selectedHost: "git.corp.com", availableHosts: ["acme.ghe.com"], + readyHosts: ["acme.ghe.com"], }); assert.equal(host, "acme.ghe.com"); }); @@ -1357,6 +1378,7 @@ describe("resolveSelectedEnterpriseHost", () => { const host = resolveSelectedEnterpriseHost({ selectedHost: "git.corp.com", availableHosts: [], + readyHosts: [], }); assert.equal(host, null); }); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index cdddc40a09a..de883fc66f9 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -456,14 +456,25 @@ export function getPublishProviderReadiness(input: { return { ready: true, hint: null }; } +/** + * Defaulting to the first host alphabetically strands the user when that host + * is the unauthenticated one: its card renders as "Setup Required" rather than + * a radio, and the host picker only exists once the card is selected. Prefer a + * ready host so there is always a way in. + */ export function resolveSelectedEnterpriseHost(input: { selectedHost: string | null; availableHosts: ReadonlyArray; + readyHosts: ReadonlyArray; }): string | null { if (input.selectedHost !== null && input.availableHosts.includes(input.selectedHost)) { return input.selectedHost; } - return input.availableHosts[0] ?? null; + return ( + input.availableHosts.find((host) => input.readyHosts.includes(host)) ?? + input.availableHosts[0] ?? + null + ); } // Re-export from shared for backwards compatibility in this module's exports diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 571ba1d799c..7f8afd42427 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -388,9 +388,32 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { }) .toSorted((left, right) => left.localeCompare(right)); }, [sourceControlDiscovery.data]); + // Computed straight from discovery rather than from the readiness map below, + // which is keyed by provider card and so already depends on the active host. + const enterpriseHostReadiness = useMemo(() => { + const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? []; + return new Map( + enterpriseHosts.map( + (host) => + [ + host, + getPublishProviderReadiness({ + provider: "github-enterprise", + host, + sourceControlProviders, + }), + ] as const, + ), + ); + }, [enterpriseHosts, sourceControlDiscovery.data]); + const readyEnterpriseHosts = useMemo( + () => enterpriseHosts.filter((host) => enterpriseHostReadiness.get(host)?.ready === true), + [enterpriseHosts, enterpriseHostReadiness], + ); const activeEnterpriseHost = resolveSelectedEnterpriseHost({ selectedHost: selectedEnterpriseHost, availableHosts: enterpriseHosts, + readyHosts: readyEnterpriseHosts, }); const enterprisePublishProviderOptions = useMemo>(() => { if (enterpriseHosts.length === 0 || activeEnterpriseHost === null) return []; @@ -736,6 +759,49 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { className="grid grid-cols-2 gap-2" > {enterpriseHosts.map((host) => { + const hostReadiness = enterpriseHostReadiness.get(host); + // Selecting an unauthenticated host would swap the card + // for its "Setup Required" state, which removes this + // picker and leaves no way back to a working host. + if (!hostReadiness?.ready) { + return ( +
+ + + {host} + + + { + event.preventDefault(); + event.stopPropagation(); + openSourceControlSettings(); + }} + > + Setup Required + + } + /> + + {hostReadiness?.hint ?? + `${host} is not authenticated. Open Settings -> Source Control for setup guidance.`} + + +
+ ); + } + const isSelected = activeEnterpriseHost === host; return ( Date: Fri, 31 Jul 2026 13:26:36 +0200 Subject: [PATCH 30/34] fix(web): drop a stale enterprise host selection when another host is ready A host selected while authenticated can lose that state on a later discovery pass. Holding onto it left the publish card in its Setup Required state with the host picker unmounted, which is the same dead end an unauthenticated default produced. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/GitActionsControl.logic.test.ts | 18 ++++++++++++++++++ .../src/components/GitActionsControl.logic.ts | 16 ++++++++++------ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 28f06ee1e3a..9cc5452dbbe 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -1347,6 +1347,24 @@ describe("resolveSelectedEnterpriseHost", () => { assert.equal(host, "acme.ghe.com"); }); + it("gives up a selected host that lost authentication while another is ready", () => { + const host = resolveSelectedEnterpriseHost({ + selectedHost: "acme.ghe.com", + availableHosts: ["acme.ghe.com", "git.corp.com"], + readyHosts: ["git.corp.com"], + }); + assert.equal(host, "git.corp.com"); + }); + + it("keeps a selected host that lost authentication when no host is ready", () => { + const host = resolveSelectedEnterpriseHost({ + selectedHost: "git.corp.com", + availableHosts: ["acme.ghe.com", "git.corp.com"], + readyHosts: [], + }); + assert.equal(host, "git.corp.com"); + }); + it("skips an unauthenticated first host in favour of a ready one", () => { const host = resolveSelectedEnterpriseHost({ selectedHost: null, diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index de883fc66f9..7f7b3a1c9bb 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -467,14 +467,18 @@ export function resolveSelectedEnterpriseHost(input: { availableHosts: ReadonlyArray; readyHosts: ReadonlyArray; }): string | null { - if (input.selectedHost !== null && input.availableHosts.includes(input.selectedHost)) { + const firstReadyHost = input.availableHosts.find((host) => input.readyHosts.includes(host)); + // A host selected while it was authenticated can lose that state on a later + // discovery pass. Holding onto it strands the user the same way an + // unauthenticated default does, so give it up while another host still works. + const keepsSelection = + input.selectedHost !== null && + input.availableHosts.includes(input.selectedHost) && + (input.readyHosts.includes(input.selectedHost) || firstReadyHost === undefined); + if (keepsSelection) { return input.selectedHost; } - return ( - input.availableHosts.find((host) => input.readyHosts.includes(host)) ?? - input.availableHosts[0] ?? - null - ); + return firstReadyHost ?? input.availableHosts[0] ?? null; } // Re-export from shared for backwards compatibility in this module's exports From d4ca39670bebe046ad08dab09f18c2796ea463da Mon Sep 17 00:00:00 2001 From: Jorrin Date: Fri, 31 Jul 2026 14:37:20 +0200 Subject: [PATCH 31/34] fix(server): stop ranking near matches for bare enterprise names A sole near match still resolves, which is what makes a bare name useful. Picking between several of them was search ranking standing in for a decision only the caller can make, so report the candidates instead. Co-Authored-By: Claude Opus 5 (1M context) --- .../GitHubSourceControlProvider.test.ts | 36 +++++++++++++++++-- .../GitHubSourceControlProvider.ts | 22 +++++++----- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 0fa58adab4d..6f4e24a9416 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -619,10 +619,10 @@ describe("getRepositoryCloneUrls bare name resolution", () => { }), ); - it.effect("falls back to the first search result when no bare name matches exactly", () => + it.effect("resolves a sole near match when no bare name matches exactly", () => Effect.gen(function* () { const searchRepositories = vi.fn(() => - Effect.succeed([{ fullName: "Sollit/widget-service" }, { fullName: "Sollit/mywidget" }]), + Effect.succeed([{ fullName: "Sollit/widget-service" }]), ); const getRepositoryCloneUrls = vi.fn(() => Effect.succeed({ @@ -651,6 +651,38 @@ describe("getRepositoryCloneUrls bare name resolution", () => { }), ); + it.effect("fails rather than rank near matches when none of them is exact", () => + Effect.gen(function* () { + const searchRepositories = vi.fn(() => + Effect.succeed([{ fullName: "Sollit/widget-service" }, { fullName: "Sollit/mywidget" }]), + ); + const getRepositoryCloneUrls = vi.fn(() => + Effect.succeed({ + nameWithOwner: "Sollit/widget-service", + url: "https://sollit.ghe.com/Sollit/widget-service", + sshUrl: "git@sollit.ghe.com:Sollit/widget-service.git", + }), + ); + + const provider = yield* makeProviderOfKind("github-enterprise", { + searchRepositories, + getRepositoryCloneUrls, + }); + + const error = yield* provider + .getRepositoryCloneUrls({ + cwd: "/repo", + repository: "widget", + host: "sollit.ghe.com", + }) + .pipe(Effect.flip); + + expect(getRepositoryCloneUrls).not.toHaveBeenCalled(); + expect(error.detail).toContain("Sollit/widget-service"); + expect(error.detail).toContain("Sollit/mywidget"); + }), + ); + it.effect("fails with a detail naming the query and host when search returns zero results", () => Effect.gen(function* () { const searchRepositories = vi.fn(() => Effect.succeed([])); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index bc9264bbb48..4650014b013 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -212,11 +212,11 @@ type RepositorySearchMatch = | { readonly _tag: "none" }; // Bare names resolve against the caller's personal namespace on `gh repo -// view`, which is usually empty on an enterprise host. Prefer the search -// result whose repo-name segment matches the query exactly (search ranking -// otherwise happily puts e.g. "core-documentation" ahead of "core"). Several -// owners can hold the same repo name, and search ranking is no basis for -// picking between them, so report that back instead of guessing. +// view`, which is usually empty on an enterprise host, so they go through +// search instead. An exact repo-name match wins outright; a sole near match +// still resolves, since that is the whole point of accepting a bare name. +// Anything else is a choice between repositories that search ranking is no +// basis for making, so report the candidates rather than guess. function pickRepositorySearchMatch( query: string, results: ReadonlyArray, @@ -229,10 +229,14 @@ function pickRepositorySearchMatch( const exact = results.filter( (result) => result.fullName.split("/").pop()?.toLowerCase() === normalizedQuery, ); - if (exact.length > 1) { - return { _tag: "ambiguous", candidates: exact.map((result) => result.fullName) }; + if (exact.length === 1) { + return { _tag: "match", fullName: exact[0]!.fullName }; } - return { _tag: "match", fullName: (exact[0] ?? results[0]!).fullName }; + const candidates = exact.length > 1 ? exact : results; + if (candidates.length > 1) { + return { _tag: "ambiguous", candidates: candidates.map((result) => result.fullName) }; + } + return { _tag: "match", fullName: candidates[0]!.fullName }; } export const makeProvider = (kind: GitHubProviderKind) => @@ -286,7 +290,7 @@ export const makeProvider = (kind: GitHubProviderKind) => repository: safeRepository, detail: match._tag === "ambiguous" - ? `Several repositories on ${hostLabel} are named "${safeRepository}": ${match.candidates + ? `More than one repository on ${hostLabel} matches "${safeRepository}": ${match.candidates .slice(0, AMBIGUOUS_REPOSITORY_CANDIDATE_LIMIT) .map((candidate) => SourceControlProvider.transportSafeSourceControlErrorValue(candidate), From aff4c5e9cd0658206e66d539684c33848505f0fb Mon Sep 17 00:00:00 2001 From: Jorrin Date: Tue, 4 Aug 2026 22:34:19 +0200 Subject: [PATCH 32/34] fix(server): refuse a bare enterprise name when no host is known Without a host `gh search repos` answers for github.com, so a bare name meant for an enterprise instance resolved against public GitHub. The repository service already refuses this, but the provider is reachable on its own. Co-Authored-By: Claude Opus 5 (1M context) --- .../GitHubSourceControlProvider.test.ts | 26 +++++++++++++++++++ .../GitHubSourceControlProvider.ts | 17 ++++++++++++ 2 files changed, 43 insertions(+) diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 6f4e24a9416..906a50982e1 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -750,6 +750,32 @@ describe("getRepositoryCloneUrls bare name resolution", () => { }), ); + it.effect("never searches for a bare enterprise name without a host", () => + Effect.gen(function* () { + const searchRepositories = vi.fn(() => Effect.succeed([{ fullName: "Sollit/core" }])); + const getRepositoryCloneUrls = vi.fn(() => + Effect.succeed({ + nameWithOwner: "Sollit/core", + url: "https://sollit.ghe.com/Sollit/core", + sshUrl: "git@sollit.ghe.com:Sollit/core.git", + }), + ); + + const provider = yield* makeProviderOfKind("github-enterprise", { + searchRepositories, + getRepositoryCloneUrls, + }); + + const error = yield* provider + .getRepositoryCloneUrls({ cwd: "/repo", repository: "core" }) + .pipe(Effect.flip); + + expect(searchRepositories).not.toHaveBeenCalled(); + expect(getRepositoryCloneUrls).not.toHaveBeenCalled(); + expect(error.detail).toContain("host"); + }), + ); + it.effect("never calls search for an owner/repo reference on enterprise", () => Effect.gen(function* () { const searchRepositories = vi.fn(() => Effect.succeed([])); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 4650014b013..47f55a06460 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -253,6 +253,23 @@ export const makeProvider = (kind: GitHubProviderKind) => return Effect.succeed(repository); } + // Without a host `gh search repos` answers for github.com, so a bare name + // meant for an enterprise instance would quietly resolve against public + // GitHub. The repository service already refuses this, but the provider + // is reachable on its own. + if (!input.host) { + return Effect.fail( + new SourceControlProviderError({ + provider: kind, + operation: "getRepositoryCloneUrls", + command: "gh", + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue(repository), + detail: "Choose a GitHub Enterprise host before resolving a bare repository name.", + }), + ); + } + return github .searchRepositories({ cwd: input.cwd, From 3b568e2fcc56b70f793f25bfd16fbf5221c86abf Mon Sep 17 00:00:00 2001 From: Jorrin Date: Tue, 4 Aug 2026 22:34:20 +0200 Subject: [PATCH 33/34] fix(web): clear the publish provider and host when the dialog closes Both were introduced by this branch and were the only publish state that survived a close, so reopening the wizard from another repository could still be pointed at the previous enterprise host. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/GitActionsControl.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index c71074c6e26..68f2d4d71f4 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -572,6 +572,8 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { setPublishAdvancedOpen(false); setPublishError(null); setPublishResult(null); + setSelectedPublishProviderId(null); + setSelectedEnterpriseHost(null); }, []); const handleOpenChange = useCallback( From a766d6e9d29be12907dfe5568eca6e4cef2abc3e Mon Sep 17 00:00:00 2001 From: Jorrin Date: Tue, 4 Aug 2026 22:43:03 +0200 Subject: [PATCH 34/34] fix(server): require a host for every enterprise repository operation The previous guard only covered bare names, so an owner/repo lookup or a repository creation on the enterprise provider with no host still reached `gh` without GH_HOST and answered for github.com. Move the check ahead of both operations. Also sanitize the host before interpolating it into the error detail, to match the repository value on the same error. Co-Authored-By: Claude Opus 5 (1M context) --- .../GitHubSourceControlProvider.test.ts | 47 ++++++--- .../GitHubSourceControlProvider.ts | 97 +++++++++++-------- 2 files changed, 91 insertions(+), 53 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 906a50982e1..19039d16051 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -750,28 +750,51 @@ describe("getRepositoryCloneUrls bare name resolution", () => { }), ); - it.effect("never searches for a bare enterprise name without a host", () => + it.effect.each(["core", "Sollit/core"])( + "refuses %s on enterprise without a host rather than answering for github.com", + (repository) => + Effect.gen(function* () { + const searchRepositories = vi.fn(() => Effect.succeed([{ fullName: "Sollit/core" }])); + const getRepositoryCloneUrls = vi.fn(() => + Effect.succeed({ + nameWithOwner: "Sollit/core", + url: "https://sollit.ghe.com/Sollit/core", + sshUrl: "git@sollit.ghe.com:Sollit/core.git", + }), + ); + + const provider = yield* makeProviderOfKind("github-enterprise", { + searchRepositories, + getRepositoryCloneUrls, + }); + + const error = yield* provider + .getRepositoryCloneUrls({ cwd: "/repo", repository }) + .pipe(Effect.flip); + + expect(searchRepositories).not.toHaveBeenCalled(); + expect(getRepositoryCloneUrls).not.toHaveBeenCalled(); + expect(error.detail).toContain("host"); + }), + ); + + it.effect("refuses to create an enterprise repository without a host", () => Effect.gen(function* () { - const searchRepositories = vi.fn(() => Effect.succeed([{ fullName: "Sollit/core" }])); - const getRepositoryCloneUrls = vi.fn(() => + const createRepository = vi.fn(() => Effect.succeed({ nameWithOwner: "Sollit/core", - url: "https://sollit.ghe.com/Sollit/core", - sshUrl: "git@sollit.ghe.com:Sollit/core.git", + url: "https://github.com/Sollit/core", + sshUrl: "git@github.com:Sollit/core.git", }), ); - const provider = yield* makeProviderOfKind("github-enterprise", { - searchRepositories, - getRepositoryCloneUrls, - }); + const provider = yield* makeProviderOfKind("github-enterprise", { createRepository }); const error = yield* provider - .getRepositoryCloneUrls({ cwd: "/repo", repository: "core" }) + .createRepository({ cwd: "/repo", repository: "Sollit/core", visibility: "private" }) .pipe(Effect.flip); - expect(searchRepositories).not.toHaveBeenCalled(); - expect(getRepositoryCloneUrls).not.toHaveBeenCalled(); + expect(createRepository).not.toHaveBeenCalled(); expect(error.detail).toContain("host"); }), ); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 47f55a06460..7fefe2af437 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -243,6 +243,31 @@ export const makeProvider = (kind: GitHubProviderKind) => Effect.gen(function* () { const github = yield* GitHubCli.GitHubCli; + // Every repo-less `gh` call falls back to github.com without `GH_HOST`, so + // an enterprise operation missing its host does not fail — it quietly + // answers for public GitHub. The repository service already refuses this, + // but the provider is reachable on its own. + const ensureEnterpriseHost = (input: { + readonly cwd: string; + readonly repository: string; + readonly host?: string; + readonly operation: string; + }): Effect.Effect => + kind === "github-enterprise" && !input.host + ? Effect.fail( + new SourceControlProviderError({ + provider: kind, + operation: input.operation, + command: "gh", + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: "Choose a GitHub Enterprise host before continuing.", + }), + ) + : Effect.void; + const resolveRepositoryReference = (input: { readonly cwd: string; readonly repository: string; @@ -253,23 +278,6 @@ export const makeProvider = (kind: GitHubProviderKind) => return Effect.succeed(repository); } - // Without a host `gh search repos` answers for github.com, so a bare name - // meant for an enterprise instance would quietly resolve against public - // GitHub. The repository service already refuses this, but the provider - // is reachable on its own. - if (!input.host) { - return Effect.fail( - new SourceControlProviderError({ - provider: kind, - operation: "getRepositoryCloneUrls", - command: "gh", - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue(repository), - detail: "Choose a GitHub Enterprise host before resolving a bare repository name.", - }), - ); - } - return github .searchRepositories({ cwd: input.cwd, @@ -297,7 +305,9 @@ export const makeProvider = (kind: GitHubProviderKind) => const safeRepository = SourceControlProvider.transportSafeSourceControlErrorValue(repository); - const hostLabel = input.host ?? "the configured host"; + const hostLabel = input.host + ? SourceControlProvider.transportSafeSourceControlErrorValue(input.host) + : "the configured host"; return Effect.fail( new SourceControlProviderError({ provider: kind, @@ -454,7 +464,8 @@ export const makeProvider = (kind: GitHubProviderKind) => ), ), getRepositoryCloneUrls: (input) => - resolveRepositoryReference(input).pipe( + ensureEnterpriseHost({ ...input, operation: "getRepositoryCloneUrls" }).pipe( + Effect.andThen(() => resolveRepositoryReference(input)), Effect.flatMap((repository) => github .getRepositoryCloneUrls({ @@ -481,29 +492,33 @@ export const makeProvider = (kind: GitHubProviderKind) => ), ), createRepository: (input) => - github - .createRepository({ - cwd: input.cwd, - repository: input.repository, - visibility: input.visibility, - ...(input.host ? { host: input.host } : {}), - }) - .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: kind, - operation: "createRepository", - command: error.command, - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.detail, - cause: error, - }), - ), + ensureEnterpriseHost({ ...input, operation: "createRepository" }).pipe( + Effect.andThen(() => + github + .createRepository({ + cwd: input.cwd, + repository: input.repository, + visibility: input.visibility, + ...(input.host ? { host: input.host } : {}), + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: kind, + operation: "createRepository", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), ), + ), getDefaultBranch: (input) => github.getDefaultBranch(input).pipe( Effect.mapError(