> ## Documentation Index
> Fetch the complete documentation index at: https://docs.levelrail.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Git provider integrations: shared abstraction, GHE, Bitbucket, connect-flow UX

> Design for unified post-auth git provider abstraction supporting GitHub, GitLab, Bitbucket, and GitHub Enterprise Server

**Status:** Proposed. Feeds implementation for Bitbucket support, GitHub Enterprise Server, and connect-flow preview.

**Context:** GitHub App and GitLab OAuth integrations are both shipped and independently typed. Bitbucket is next, and GitHub needs self-hosted (GHES) support. Three providers is the point to decide the abstraction question on purpose rather than by default.

## 1. Shared Go abstraction: split the answer, do not unify the handshake

**Recommendation: a narrow abstraction. Introduce `internal/gitprovider` covering only the post-authentication repository surface. Keep every auth handshake in its own package and on its own routes. Do not put `Connect` in any interface.**

### Why not the full `GitProvider` interface

The convergence point already exists downstream of auth, not at it. `internal/api/git_sources.go`'s `connectGitSource` is the single place a repository becomes a deploy source. `handleUseGitLabProjectAsSource` already funnels into it, producing the same `store.GitSource` row and webhook receiver as hand-typed sources.

Bitbucket gets that for free without any new interface. An interface spanning connect through webhook creation would re-abstract a path that already converges, just to unify the part that does not.

### What actually differs between the handshakes

These are structural differences, not cosmetic ones. An interface would hide them:

* **App creation:** GitHub creates the App programmatically (browser POSTs manifest to `settings/apps/new`, GitHub returns one-time code). GitLab and Bitbucket require manual registration. Operator pastes `client_id`/`client_secret`.

* **Browser interaction:** GitHub needs a server-rendered self-submitting form (`writeGitHubAppManifestForm`). GitLab and Bitbucket need a 302 redirect to an authorize URL. Any interface method covering both becomes `func(http.ResponseWriter, *http.Request)`, which is a router, not an abstraction.

* **Completion flow:** GitHub: create App, then install on account/org, yielding `installation_id` verified server-side against `app_id` (`handleGitHubAppInstalled`). GitLab and Bitbucket: configure, then authorize, yielding an access token. Status resources already use separate `installed` and `authorized` booleans for this reason.

* **Token lifecycle:** GitHub mints fresh installation tokens per call via RS256 JWT with stored PEM key. No refresh token or stored access token. GitLab and Bitbucket store access token plus refresh token plus expiry, refresh on demand (`gitlabAccessToken`). Bitbucket Cloud tokens expire in 2 hours, making refresh mandatory.

* **Credential shape:** GitHub stores `app_id`, `client_id`, `installation_id`, `account_login` plus three secrets. GitLab adds `instance_url` plus four secrets. Bitbucket adds a workspace concept.

* **Token scope:** GitHub installation tokens scope to "repos this installation was granted". GitLab and Bitbucket tokens scope to "repos this user is a member of". Same method name, different meaning.

### What is genuinely uniform, and is worth the interface

Everything after a token exists: list repos, list branches, create a push webhook. Expand the accordion below to see the interface definition.

`ID string` is the cost: GitHub takes `owner, repo`, GitLab takes `int64`, each adapter parses its own format. That is acceptable because alternatives (`any` ID, three-armed union) violate the no-`any`-in-exported-signatures rule.

<Accordion title="gitprovider.Source interface definition">
  ```go theme={"dark"}
  // Package gitprovider holds the provider-agnostic surface that only
  // exists after a provider connection is authenticated. Authentication
  // itself is deliberately absent: see docs/design/git-provider-integrations.md.
  package gitprovider

  type Kind string

  const (
  	KindGitHub    Kind = "github"
  	KindGitLab    Kind = "gitlab"
  	KindBitbucket Kind = "bitbucket"
  )

  // Repo is one repository the connected credential can reach.
  type Repo struct {
  	// ID is provider-native and opaque: a GitHub "owner/repo" pair, a
  	// GitLab numeric project ID, a Bitbucket "workspace/repo_slug".
  	// String, not int64, because only GitLab's is numeric. Callers pass
  	// it back verbatim and never parse it.
  	ID            string
  	FullName      string
  	CloneURL      string
  	DefaultBranch string
  	Private       bool
  	WebURL        string
  }

  // Branch is one branch head.
  type Branch struct {
  	Name string
  	SHA  string
  }

  // PushHook is the webhook this control plane wants registered on a repo.
  type PushHook struct {
  	URL    string
  	Secret string
  }

  // Source is one authenticated provider connection. Implementations are
  // constructed per request by internal/api, closing over whatever token
  // acquisition that provider needs, so no method here takes a credential
  // argument.
  //
  // ListRepos returns installation-scoped repositories on GitHub and
  // user-scoped memberships on GitLab and Bitbucket. That difference is
  // real and not normalized away.
  type Source interface {
  	Kind() Kind
  	ListRepos(ctx context.Context) ([]Repo, error)
  	ListBranches(ctx context.Context, repoID string) ([]Branch, error)
  	GetRepo(ctx context.Context, repoID string) (Repo, error)
  	CreatePushHook(ctx context.Context, repoID string, hook PushHook) error
  }
  ```
</Accordion>

**The interface only pays for itself if the HTTP surface unifies.** Collapse repo-facing routes:

* `GET /api/v1/git-providers/{kind}/repos`
* `GET /api/v1/git-providers/{kind}/repos/{id...}/branches`
* `POST /api/v1/git-providers/{kind}/repos/{id...}/use-as-source`

Keep existing routes (`/api/v1/github-app/repos`, `/api/v1/github-app/repos/{owner}/{repo}/branches`, `/api/v1/gitlab-app/projects*`) as thin aliases delegating to the same handlers for backward compatibility.

Connect, callback, disconnect, and status routes stay per-provider under `/api/v1/{github,gitlab,bitbucket}-app`.

Note: GitHub currently has no `use-as-source` and registers no repo webhook (manifest sets `hook_attributes.active: false`). Unifying the route surface makes this gap visible and closable.

### Small dedupes to fold into the same work

* `githubAppRegistrationState` (`internal/api/github_app.go`) and `pendingState` (`internal/api/pending_state.go`) are identical types. Delete the GitHub copy, use `pendingState` for all three providers.

* `verifyGitPushWebhookAuth` (`internal/api/git_webhook.go`) already branches on GitHub's `X-Hub-Signature-256` vs GitLab's `X-Gitlab-Token`. Bitbucket adds `X-Hub-Signature` (sha256 HMAC, no `-256` suffix). Write a table-driven test over the header matrix, including "no known header" failing closed.

* `githubAppBaseURL` is used by GitLab handlers under its GitHub name. Rename to `controlPlaneBaseURL` and move out of `github_app.go`. Same for `errGitHubAppNoPrimaryDomain`.

### What this recommendation does not do

* Does not create a `GitProvider` interface with `Connect`/`Disconnect`.
* Does not merge the three connection tables into one polymorphic table.
* Does not change how any provider stores credentials.

If a fourth provider (Gitea, GitLab-shaped) lands and connect handshakes are still three copies, revisit with real evidence rather than speculating now with two.

## 2. GitHub Enterprise Server: single `instance_url`, plus a reachability preflight

**Recommendation: follow GitLab's single `instance_url` field exactly. Do not adopt Dokploy's external-URL/internal-URL split.**

The split exists for cases where the browser's reachable address differs from the control plane's address on an internal instance. It trades that case for a second field every operator must reason about. It also has a live bug from this complexity (Dokploy/dokploy#3848: self-hosted OAuth callback ECONNREFUSED).

A cheaper mitigation is a preflight: on save, the control plane calls the instance's version endpoint (`/api/v3/meta` for GHES). If it cannot reach it, return a specific, actionable 400, turning silent runtime OAuth failure into an immediate save error. No second field needed.

**Concrete changes:**

**Store**

New migration adding `instance_url TEXT NOT NULL DEFAULT 'https://github.com'` to `github_app_connections`. The default makes every existing row correct with no backfill.

**`internal/githubapp/client.go`**

Add `APIBaseURL(instanceURL string) string`:

* Returns `https://api.github.com` when `instanceURL` is `https://github.com` or empty
* Otherwise returns `strings.TrimRight(instanceURL, "/") + "/api/v3"`

GHES's REST root is `/api/v3` under the instance host, not a separate `api.` hostname.

Thread `instanceURL` as the first argument of every method, mirroring `gitlabapp.Client`. Widen the `GitHubAppClient` interface in `internal/api/github_app.go` to match.

**`internal/githubapp/manifest.go`**

`BuildManifest` needs no signature change. Every URL it emits derives from our own `baseURL`, not GitHub's.

Changes:

* `writeGitHubAppManifestForm`: hardcoded `https://github.com/settings/apps/new` becomes `<instance>/settings/apps/new`, or `<instance>/organizations/<org>/settings/apps/new` for org-owned apps.
* `handleGitHubAppCallback`: hardcoded redirect to `https://github.com/apps/<slug>/installations/new` becomes `<instance>/apps/<slug>/installations/new`.

**Manual connect**

`github_app_manual.go` gains the same `instance_url` field, defaulting to `https://github.com`.

**Out of scope**

No `insecure_skip_verify` or custom CA toggle. GHES instances with private CAs are supported by adding that CA to the host trust store (documented), not by disabling certificate verification for credential-bearing connections.

## 3. Bitbucket: Cloud now, Server not scheduled

**Recommendation: Bitbucket Cloud only. Bitbucket Server / Data Center is explicitly not on the roadmap, and this is not a "fast follow."**

Why not Server/Data Center:

1. Bitbucket Server has no OAuth-consumer equivalent. Integration is via Application Links or HTTP access tokens (paste-a-token flow).

2. **A paste-a-token flow already ships here.** `PUT /api/v1/apps/{name}/git-source` takes a repo URL plus optional deploy token and returns a webhook secret. `handleGitPushWebhook` verifies GitHub-style HMAC. A Data Center user can connect a repo today by pasting the clone URL and HTTP access token, then adding the webhook by hand.

3. Atlassian ended Bitbucket Server sales and support in Feb 2024. Remaining installs are Data Center. Building first-class integration against a shrinking, self-hosted-only, token-paste target is poor use of the next work.

Document point 2 so "no Bitbucket Server support" reads as a documented path, not a gap.

### Minimum viable Bitbucket Cloud, matching the bar GitHub and GitLab already meet

Deliver: status endpoint, connect and disconnect, credential storage via `internal/secrets`, repo listing behind `AbilityReadSensitive`, branch listing, use-as-source landing in `store.GitSource`, automatic push-webhook registration, and webhook receiver that verifies and triggers deploy.

GitLab meets all of it. GitHub currently meets all but webhook registration.

**`internal/bitbucketapp`**

Structured like `internal/gitlabapp`. OAuth code exchange and refresh against `https://bitbucket.org/site/oauth2/access_token`, plus API below. No instance URL parameter (Cloud only, fixed `api.bitbucket.org/2.0`).

**Operator-registered OAuth consumer**

`key` (client id) plus `secret`. Connect dialog must tell operator which permissions to tick on the consumer (Account: read, Repositories: read, Webhooks: read and write). Bitbucket scopes are configured on the consumer, not requested per authorization.

**Token refresh is mandatory**

Bitbucket access tokens live 2 hours. Reuse the `gitlabAccessToken` pattern.

**Repo listing**

`GET /2.0/repositories?role=member`. Pagination is cursor-based via `next` URL in response body, a third shape distinct from existing clients' page-counter loops. Cap follow count like `listPageCap` does.

**Branch listing**

`GET /2.0/repositories/{workspace}/{repo_slug}/refs/branches`.

**Webhook registration**

`POST /2.0/repositories/{workspace}/{repo_slug}/hooks`, event `repo:push`, `active: true`, with secret. If secret cannot be set, fail use-as-source with the same "connected, but webhook registration failed; add manually" 502 that GitLab returns. Never register an unauthenticated hook.

**Push payload adapter**

`internal/webhook.ParsePushEvent` requires top-level `ref` and `after` (GitHub and GitLab both send these). Bitbucket sends `push.changes[].new.name` (bare branch name, not `refs/heads/`) and `push.changes[].new.target.hash`. Add provider-aware parse selected by `X-Event-Key: repo:push`, normalizing to `PushEvent`. Table-driven test with fixtures for all three providers, including Bitbucket branch-delete where `new` is null.

**Webhook auth**

Third branch in `verifyGitPushWebhookAuth` for `X-Hub-Signature`.

**Frontend**

`BitbucketAppConnectionCard.tsx`, `queries/bitbucketApp.ts`, `types/bitbucketApp.ts`, repo picker reusing unified `/api/v1/git-providers/{kind}/repos` route.

**Out of scope for first slice**

Bitbucket Server / Data Center, app passwords as alternative credential, workspace-restriction UI, pull-request [preview environments](/previews), auto-populating clone deploy token from OAuth token.

## 4. Manifest preview and confirm step

`GitHubAppConnectionCard.tsx` currently redirects directly to `/api/v1/github-app/register/start` with no preview. Everything the operator grants lives in a warning paragraph next to the button.

GitHub's own confirmation page only lets operators edit the App name. Callback URL, webhook URL, permissions, and events must all be correct before redirect. That demands a preview step.

**Backend: `GET /api/v1/github-app/register/preview`**

Requires `AbilityRoot`. Returns JSON:

```json theme={"dark"}
{
  "instance_url": "", "app_name": "", "homepage_url": "",
  "callback_url": "", "setup_url": "", "webhook_url": "",
  "webhook_active": false, "permissions": {"contents": "read"},
  "events": [], "public": false, "request_oauth_on_install": false,
  "owner": "user", "organization": ""
}
```

Built by calling `githubapp.BuildManifest` with same inputs as `handleStartGitHubAppRegistration`, so preview and real POST cannot drift. The preview endpoint must not call `pendingState.begin()`. State is minted only when the operator confirms and hits `register/start`.

`register/start` gains two optional, validated query parameters:

* `name`: the one field GitHub lets you change
* `org`: org login, changing form action to `<instance>/organizations/<org>/settings/apps/new`

**Frontend: `GitHubAppManifestPreviewDialog`**

Opened by the existing "Add GitHub App" button:

* Editable App name, prefilled from `app_name`.
* Owner selector: personal account or organization. When organization is chosen, show login input.
* Read-only monospace list of four URLs (homepage, callback, setup, webhook), each labelled with what GitHub uses it for.
* Permissions as `contents: read` style rows, plus events list. Add "webhooks are declared but inactive" note when `webhook_active` is false.
* Reachability warning moved from card body.
* Confirm navigates with `?name=&org=`. Cancel does nothing.

**Should this generalize?**

The manifest preview stays GitHub-specific. GitLab and Bitbucket create nothing remotely on the operator's behalf, so there is no surprise to prevent.

One piece does generalize and fixes a live bug: `GitLabAppConnectionCard.tsx`'s `ConfigureDialog` tells operators to register redirect URI `${window.location.origin}/api/v1/gitlab-app/callback`, but the backend builds its redirect URI from `IngressSettings.PrimaryDomain`. Operators viewing the dashboard over an IP, tunnel, or secondary hostname are told to register a URI that will not match, causing OAuth failure.

Extract `<CallbackURLNotice baseURL={status.base_url} path="/api/v1/gitlab-app/callback" />`: render the server-derived URL with a copy button and explicit "set a primary domain first" state. Use in all three cards and inside the GitHub preview dialog. Both status endpoints already return `base_url`.

## 5. Frontend: extract four primitives, keep three cards

**Recommendation: no generic `ProviderConnectionCard`, no `useProviderConnection` hook. Extract four small primitives and let each card keep its own body.**

The two existing cards are 465 and 322 lines. What is actually identical is the chrome, not the logic. State machines genuinely differ:

* GitHub: not-connected/connected/installed with two competing entry actions on one row.
* GitLab: strict not-connected/configured/authorized sequence.
* Bitbucket: matches GitLab but adds workspace concept.

A config-driven generic card would need a discriminated union over three state machines plus per-provider render slots. That is more code than three cards and trends toward the 500-line cap.

Extract these, because they are byte-identical (except strings) and Bitbucket would make a third copy:

1. `<DisconnectProviderDialog>`: takes `{ triggerLabel, title, description, isPending, onConfirm }`.
2. `<ProviderCardHeader>`: icon tile, title, description block.
3. `<ConnectionStatusRow>`: "connected with badge / not connected" left column, actions right column.
4. `<CallbackURLNotice>`: from section 4.

**Also recommended**

Collapse `/settings/github-app` and `/settings/gitlab-app` into a single `/settings/git-providers` route rendering all three cards. Keep the two existing routes as redirects. Three sidebar entries for three providers gets noisy at four.

## Sequencing

Each step is one complete, coherent PR.

1. **Groundwork, no user-visible change.**
   * Dedupe `pendingState`
   * Rename `githubAppBaseURL` to `controlPlaneBaseURL`
   * Fix GitLab callback URI mismatch bug
   * Add Bitbucket branch to `verifyGitPushWebhookAuth` (table-driven test)
   * Add provider-aware push-payload parsing with fixtures for all three providers

2. **`internal/gitprovider` plus unified repo routes**
   * New package, GitHub and GitLab adapters
   * `/api/v1/git-providers/{kind}/...` routes
   * Old routes aliased

3. **GitHub Enterprise Server**
   * Migration, `APIBaseURL`
   * `instanceURL` threaded through client
   * Form action and install redirect derived from instance
   * Manual-connect field, reachability preflight
   * Frontend instance URL field

4. **Manifest preview and confirm**
   * Preview endpoint, `name`/`org` parameters
   * `GitHubAppManifestPreviewDialog`
   * Four extracted frontend primitives
   * `<CallbackURLNotice>` in GitLab card
   * `/settings/git-providers` route

5. **Bitbucket Cloud**
   * `internal/bitbucketapp`
   * Connection table and migration
   * Connect/callback/disconnect/status handlers
   * `gitprovider.Source` adapter
   * Connection card, queries, types
   * Webhook registration on use-as-source

Steps 3, 4, and 5 are independent once 1 and 2 land. They can run in parallel across sessions against a frozen contract.

## Decisions recorded elsewhere

This is a design note, not an ADR. Two decisions here are ADR-shaped and should get one at the next free number: "post-auth-only git provider abstraction, no unified connect handshake," and "single instance URL for self-hosted providers, no internal/external split."

## Known bugs found while researching this design

1. `web/src/components/GitLabAppConnectionCard.tsx` tells the operator to register redirect URI `${window.location.origin}/api/v1/gitlab-app/callback`, but the backend builds its redirect URI from `IngressSettings.PrimaryDomain`. Viewing the dashboard over an IP, tunnel, or secondary hostname yields a mismatched redirect URI and OAuth fails.
2. `internal/api/github_app.go`'s `githubAppRegistrationState` and `internal/api/pending_state.go`'s `pendingState` are the same type implemented twice.

## See also

* [Git integrations user guide](/git-integrations): operator documentation for connecting providers
* [Identity and access guide](/identity-and-access): user management and permissions context
* [ADR 010: Extend own auth](https://github.com/glincker/levelrail/blob/main/adr/010-extend-own-auth-not-theauth-go.md): why Levelrail uses its own auth system


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.