Skip to content

feat(git-providers): first-class repositories behind a provider interface - #6939

Open
viktormarinho wants to merge 8 commits into
mainfrom
t3code/assess-github-gitlab-integration
Open

feat(git-providers): first-class repositories behind a provider interface#6939
viktormarinho wants to merge 8 commits into
mainfrom
t3code/assess-github-gitlab-integration

Conversation

@viktormarinho

@viktormarinho viktormarinho commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Repositories become an org entity instead of an owner/name pair scattered across connection metadata and denormalized column pairs, and Studio talks to GitHub and GitLab over their REST APIs directly instead of through an MCP round-trip. This is the foundation for onboarding GitLab customers; the task board, the PR panel and the reports service still take the legacy path and follow on top of this.

The model

git_provider_accounts holds the credential — its type selects the provider client, its auth_kind how it authenticates (GitHub App installation, OAuth, or an access token). repositories is keyed by (org, host, path) case-insensitively, so a GitLab namespace of any depth fits where owner/name did not.

GitProviderClient is the seam: one implementation per provider, resolved from the account row. Studio now owns the GitHub App JWT and mints installation tokens itself; GitLab uses OAuth with refresh, or a personal/project access token. The OAuth refresh helpers became generic over an OAuthGrantStore, so one refresh path serves both downstream_tokens and the new account store — no new credential storage was invented.

What consumes it

  • Settings → Repositories (new) lists accounts and linked repositories. OAuth buttons appear only when the deployment has credentials; connecting GitLab with a token needs nothing and is always offered, which is the path a self-managed instance takes.
  • The repo picker reads the new model whenever the org has a serviceable account, and stamps metadata.githubRepo.repositoryId on the agent it creates. An org still on mcp-github keeps the old flow untouched.
  • Sandbox start, credential re-mint and push refresh use a repository's Studio-owned credentials when it has them, and fall back to the existing path otherwise.
  • Repo → volume sync takes a repositoryId and fetches through archiveTarball, which makes a GitLab-hosted sync work.

The daemon derives its CLI environment from the clone URL's userinfo (gh or glab) and no longer compares against a literal github.com before refusing an uncredentialed push. glab is now in the sandbox image.

Every schema change is additive: consumers gain a nullable repository_id alongside their existing columns, backfilled from current metadata. Migration 202 relaxes org_repo_sync.connection_id's NOT NULL and replaces it with "at least one source".

Two bugs the live APIs caught

Exercising the clients against real GitHub and GitLab, rather than a stub, found two things a stub could not:

  1. listRepos asked gitlab.com for membership=true ordered by last_activity_at, which answers 500 after ~15s, reproducibly (updated_at and name too; owned=true with the same ordering is fine). It now orders by id and takes the simple representation — which does carry every field the summary needs, contrary to the comment that justified omitting it.
  2. glab cannot authenticate from the environment for an OAuth token: it sends an env token as PRIVATE-TOKEN, which GitLab rejects for one, and every documented variable (GITLAB_TOKEN, GITLAB_ACCESS_TOKEN, OAUTH_TOKEN) behaves the same. The daemon now writes glab's config file (0600, is_oauth2: true so the token goes out as a bearer), and removes it for a non-GitLab remote so a pod that switches repositories cannot leave another provider's token behind.

Testing

  • Unit: provider clients' pure parts (JWT build verified against a generated RSA key, permission-shed ladder, URL/path builders, response mappers), RepoRef parsing including GitLab subgroups and /-/ sub-resources, the daemon's CLI-env and glab-config builders, and the push guard.
  • E2E (packages/e2e/tests/git-providers.spec.ts, new): a repository is org-scoped and keyed case-insensitively, a GitLab project nested in subgroups survives a merge-request URL, invalid URLs are refused, link/list/delete round-trips, and one org can neither list nor delete another's row — the cross-tenant test sets an active org explicitly, since a signup-only session leaves activeOrganizationId NULL and would pass even with the gate broken. org-repo-sync.spec.ts gains repository-backed coverage.
  • Against the live APIs: on GitLab, a private project created and driven through the whole loop — getRepo on a three-level namespace, readFile, 404s, listRepos, clone with oauth2:<token>@, commit, push, ls-remote --symref, and a merge request opened with the exact config the daemon writes. On GitHub, identity, repo reads, 404s and search, plus confirmation that gh authenticates from GH_TOKEN alone. Test resources were deleted afterwards.
  • Migrations 201 and 202 applied to a real Postgres, backfill inspected row by row, down and re-up verified.

Not verified end to end: the GitHub App installation-token mint, which needs a registered App (browser flow). Its JWT signing is unit-tested.

Deploying

Dormant until configured — with no credentials the connect routes answer 503 and existing mcp-github connections keep working. See selfhost/examples/docker-compose/.env.example for the full list (GITHUB_APP_*, GITLAB_OAUTH_*); the helm secret template carries the same keys.

The decision that unblocks the rest: whether the GitHub App backing deco/mcp-github moves to Studio (its installation_ids stay valid and migration is transparent) or a new App is registered (every org must reinstall).


Summary by cubic

Repositories become an org-scoped entity behind a provider interface — git_provider_accounts holds credentials and repositories is keyed by (org, host, path) — and Studio talks to GitHub and GitLab over their REST APIs directly instead of an MCP round-trip. This is the foundation for onboarding GitLab; the PR panel and reports service still use the legacy path.

Changes

  • Studio mints GitHub App installation tokens itself; GitLab uses OAuth with refresh or a token, all through the shared OAuthGrantStore path.
  • Sandbox start, credential re-mint, and push refresh use Studio-owned credentials, falling back to mcp-github.
  • Settings → Repositories lists accounts and linked repos; the repo picker reads the new model when the org has a serviceable account.
  • All agent repo entry points (load_repo, task-board dispatch, TASK_ADD_REPO) list repositories and legacy connections together, deduped by (host, path) with the repository winning, so a GitHub primary and a GitLab secondary coexist in one sandbox.
  • Repo → volume sync accepts repositoryId and fetches via archiveTarball, making GitLab syncs work.
  • The in-pod CLI login is provider-aware and runs in the checkout being added, so gh and glab both work; the daemon derives CLI env from the clone URL and writes glab's config (0600) for OAuth tokens.
  • GitLab listRepos now orders by id with the simple representation (the prior query 500s); force-refresh only applies to refreshable grants, and the token dialog asks for api.
  • Migration 201 is additive and swaps connection_id NOT NULL for "at least one source".

Deploying

  • Dormant until configured; without credentials the connect routes answer 503 and mcp-github keeps working.
  • Needs GITHUB_APP_* / GITLAB_OAUTH_* env vars (see .env.example and the helm secret template).
  • Decide whether the App behind deco/mcp-github moves to Studio (installations stay valid) or a new App is registered (orgs must reinstall).

Written for commit 6ff2f02. Summary will update on new commits.

Review in cubic

@viktormarinho

Copy link
Copy Markdown
Contributor Author

Deployment setup, step by step

Decision taken: reuse the same GitHub App that backs deco/mcp-github, so every installation_id already stored stays valid and orgs do not reinstall anything.

Nothing here is required to merge. With none of these variables set, the connect routes answer 503, Settings → Repositories shows "no git provider configured", and the existing mcp-github flow keeps working exactly as today.


0. Before you touch anything

Two fields on a GitHub App are single-valued and shared with mcp-github. Read this first or you can break the current integration:

Field Risk What to do
Callback URL The App accepts a list. mcp-github's entry must stay. Add Studio's URL as an additional entry. Do not replace.
Setup URL Only one value exists. If mcp-github already uses it, you cannot have both. See step 3 — it is optional, and skipping it costs one convenience button.

Private keys and client secrets are additive: generating a new one does not revoke existing ones. Never delete the key mcp-github is using while it still serves traffic.


1. Open the App's settings

The App is owned by the organization, so its settings live at:

https://github.com/organizations/deco/settings/apps/<app-slug>

If you do not know the slug, find the App under https://github.com/organizations/deco/settings/apps and open it — the slug is the last path segment, and also the last segment of its public page https://github.com/apps/<app-slug>.

2. Collect the four values on that page

Variable Where Notes
GITHUB_APP_ID "About" section, field App ID A short number.
GITHUB_APP_SLUG The URL slug from step 1 Used to build https://github.com/apps/<slug>/installations/new.
GITHUB_APP_CLIENT_ID Same page, field Client ID Starts with Iv1. or Iv23. Not the App ID.
GITHUB_APP_CLIENT_SECRET Same page → Generate a new client secret Shown once. If mcp-github holds an existing secret you cannot read, generate a second one — old secrets keep working.

Then, further down the same page:

Variable Where Notes
GITHUB_APP_PRIVATE_KEY Private keysGenerate a private key Downloads a .pem. Paste its full contents, -----BEGIN... and -----END... lines included. In a single-line .env, escaped \n is accepted and converted; in Helm, a quoted multi-line block works as-is.

3. Register Studio's URLs on the App

Replace <PUBLIC_URL> with the deployment's public origin (the same value Studio serves on — the callback URLs are derived from it at runtime, so a wrong PUBLIC_URL produces a redirect mismatch).

  1. Identifying and authorizing users → Callback URL: click Add callback URL and add

    <PUBLIC_URL>/api/_git/github/callback
    

    Leave any existing entry in place.

  2. Request user authorization (OAuth) during installation: leave unchecked. Studio runs the OAuth step itself right after installing.

  3. Post installation → Setup URL (optional):

    <PUBLIC_URL>/api/_git/github/setup
    

    This only exists so the "Install on another account" button returns the user straight into Studio's OAuth step. If mcp-github already occupies this field, leave it alone — the flow still works, the user just lands on GitHub after installing and clicks "Connect GitHub" in Settings → Repositories to finish.

  4. Permissions — verify the App already grants these (it should, they are what MINT_REPO_TOKEN requests today):

    Permission Level Needed for
    Contents Read and write clone and push
    Pull requests Read and write opening PRs
    Issues Read and write task board
    Metadata Read-only mandatory
    Checks Read-only PR checks tab (optional — the mint degrades without it)
    Deployments Read-only preview URLs (optional — same)

    Changing permissions puts installations into "pending approval" until an org owner accepts, so only touch this if something is actually missing.

4. Set the variables

Self-hosted / docker compose — see selfhost/examples/docker-compose/.env.example, which now documents all of these:

GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
GITHUB_APP_CLIENT_ID=
GITHUB_APP_CLIENT_SECRET=
GITHUB_APP_SLUG=

Helm — the keys exist in deploy/helm/studio/templates/secret.yaml, so they go under secret.* in your values (or your secret manager, alongside AUTH_GITHUB_CLIENT_SECRET):

secret:
  GITHUB_APP_ID: "..."
  GITHUB_APP_PRIVATE_KEY: |
    -----BEGIN RSA PRIVATE KEY-----
    ...
    -----END RSA PRIVATE KEY-----
  GITHUB_APP_CLIENT_ID: "..."
  GITHUB_APP_CLIENT_SECRET: "..."
  GITHUB_APP_SLUG: "..."

Unrelated but easy to confuse: AUTH_GITHUB_CLIENT_ID/_SECRET are Better Auth sign-in with GitHub and are untouched; GITHUB_WEBHOOK_SECRET is the existing push webhook for warm pools. Neither is part of this.

5. Verify

  1. Redeploy, then open Settings → Repositories. "Connect GitHub" appears only when all five variables parse — if it does not, the deployment did not pick them up.
  2. Click it. You will be sent to GitHub, back to /api/_git/github/callback, and one account row should appear per installation the user can see.
  3. Click Add repository → Search, pick the account, and link a repo. A successful link means the App key signed a JWT and GitHub minted an installation token for that repository.
  4. Open an agent bound to that repository and start a sandbox: the clone now uses a Studio-minted token instead of the mcp-github path.

Nothing migrates on its own — an org keeps using its mcp-github connections until someone connects an account here. Both paths coexist per repository.


GitLab, when you get there

gitlab.com needs an OAuth application. For an org-wide integration create it on the group (https://gitlab.com/groups/<group>/-/settings/applications); a user-owned one at https://gitlab.com/-/user_settings/applications also works.

  • Redirect URI: <PUBLIC_URL>/api/_git/gitlab/callback
  • Scopes: api, read_user
  • Confidential: yes
GITLAB_OAUTH_HOST=gitlab.com
GITLAB_OAUTH_CLIENT_ID=
GITLAB_OAUTH_CLIENT_SECRET=

Self-managed GitLab needs no deployment configuration at all. An OAuth application is per instance, so instead the customer connects with an access token in Settings → Repositories → Connect with a token, entering their host (e.g. gitlab.acme.com) and a token with the api scope, from https://<host>/-/user_settings/personal_access_tokens or a group access token at https://<host>/groups/<group>/-/settings/access_tokens. That button is always visible, precisely so this path does not depend on an administrator.

One more thing

The sandbox image gained glab in this PR, so it has to be rebuilt and published for an agent to open merge requests on a GitLab-hosted repository. Cloning and pushing do not depend on it.

@viktormarinho
viktormarinho force-pushed the t3code/assess-github-gitlab-integration branch from af800b1 to a639a4a Compare September 3, 2026 19:50
@viktormarinho

Copy link
Copy Markdown
Contributor Author

Ran the flow locally against a live GitLab account

Brought Studio up locally (bun run dev), drove Settings → Repositories in a real browser, and connected a real gitlab.com account. Three defects surfaced that neither the unit tests nor the e2e suite could catch, because both stop at Studio's own boundary.

1. A long-lived access token could never start a sandbox. SANDBOX_START always asks for a freshly-minted credential, and the token source passed that through as force. A personal or project access token has no refresh token and no expiry, so forcing it took getValidDownstreamAccessToken's expired_without_refresh branch and returned null. Every sandbox backed by a GitLab token account would have failed to clone — the exact configuration a self-managed customer uses. Force now applies only to a grant that can actually be refreshed; canRefresh is the predicate, and there is a regression test on it.

2. "Add repository" was disabled with no connected account, while the copy beside it offered to link a public repository by URL — which needs no account. The button is enabled; the now-orphaned accounts prop is gone.

3. The token dialog asked for the wrong scopes (read_api, read_repository), neither of which can push a branch or open a merge request. It asks for api, matching the setup comment above.

What was exercised

Connect by token → the account row renders (kmute · gitlab.com · Personal token) → Add repository → Search lists the account's real projects from the GitLab API → linking one writes a row carrying the provider's own facts (external_id, visibility: private, default_branch: master — read, not assumed) → the stored token is encrypted at rest (128 bytes stored for a 64-byte token) → resolving the repository through cloneInfoForRepository produces https://oauth2:<token>@gitlab.com/…that URL clones the private repository.

The error path was checked too: an invalid token surfaces GitLab's own message (GitLab API 401: 401 Unauthorized) as a toast, and no account row is written.

Still not covered locally

The sandbox pod itself, which needs the k8s provider — the clone credential it consumes is what was verified above, and the daemon's side is covered by its Go tests. And the GitHub App installation mint, which needs a registered App; its JWT signing is unit-tested.

All gates re-run after the fixes: typecheck, fmt, knip, lint, provider unit tests (64), and the git-provider + repo-sync e2e specs (16).

@viktormarinho
viktormarinho force-pushed the t3code/assess-github-gitlab-integration branch 4 times, most recently from 9c3ed73 to 281954e Compare September 4, 2026 16:03
…face

Repositories become an org entity instead of an `owner/name` pair scattered
across connection metadata and denormalized column pairs, and Studio talks to
GitHub and GitLab over their REST APIs directly rather than through an MCP
round-trip.

- `git_provider_accounts` holds the credential; its `type` selects the provider
  client and `auth_kind` how it authenticates (GitHub App installation, OAuth,
  or an access token). `repositories` is keyed by (org, host, path), so a
  GitLab namespace of any depth fits where `owner/name` did not.
- `GitProviderClient` is the seam: one implementation per provider, resolved
  from the account row. Studio now owns the GitHub App JWT and mints
  installation tokens itself; GitLab uses OAuth with refresh or a token.
- The OAuth refresh helpers are generic over an `OAuthGrantStore`, so one
  refresh path serves both `downstream_tokens` and the new account store.
- Sandbox start, credential re-mint and push refresh use a repository's
  Studio-owned credentials when it has them, and fall back to the existing
  `mcp-github` path otherwise. The daemon derives its CLI environment from the
  clone URL's userinfo (`gh` or `glab`) and no longer compares against a
  literal `github.com` before refusing an uncredentialed push.

Every schema change is additive: consumers gain a nullable `repository_id`
alongside their existing columns, backfilled from the current metadata.

Task board, the web PR panel and the reports service still take the legacy
path; they follow on top of this.
…ication

Both found by exercising the provider clients against the live APIs rather
than a stub.

- `listRepos` asked gitlab.com for `membership=true` ordered by
  `last_activity_at`, which answers 500 after ~15s, reproducibly (`updated_at`
  and `name` too; `owned=true` with the same ordering is fine). Order by `id`
  instead, and take the `simple` representation — it carries every field the
  summary needs, contrary to the comment that justified omitting it.
- `glab` cannot authenticate from the environment for an OAuth access token:
  it sends an env token as `PRIVATE-TOKEN`, which GitLab rejects for one, and
  every documented variable behaves the same. The daemon now writes glab's
  config file (0600, `is_oauth2: true` so the token goes out as a bearer,
  which is accepted for personal and project access tokens as well) before
  each run, and removes it for a non-GitLab remote so a pod that switches
  repositories cannot leave another provider's token behind.

Also adds the e2e spec for the tool surface: a repository is org-scoped and
keyed by (host, path) case-insensitively, GitLab subgroups survive a
merge-request URL, and one org can neither list nor delete another's row.
Repositories were only reachable through the MCP tools; this gives them a
surface, and points the two existing repo consumers at it.

- **Settings → Repositories** lists the org's provider accounts and linked
  repositories. Connecting through OAuth needs deployment credentials, so
  those buttons appear only when `GIT_PROVIDER_CAPABILITIES` reports them;
  connecting GitLab with an access token needs nothing and is always offered,
  which is the path a self-managed instance takes. Adding a repository either
  searches an account's projects or takes a pasted URL.
- **The repo picker** now reads the new model whenever the org has a
  serviceable account: it lists already-linked repositories first, links a new
  one through `REPOSITORY_LINK`, and stamps `metadata.githubRepo.repositoryId`
  on the agent it creates. An org still on `mcp-github` keeps the old flow
  untouched, so nothing changes until an account is connected.
- **Repo → volume sync** takes a `repositoryId` as its source instead of a
  repo-scoped connection, and fetches the archive through the provider client
  (`archiveTarball`), which makes a GitLab-hosted sync work. `connectionId`
  keeps working for configs that predate the model; migration 202 relaxes its
  NOT NULL and replaces it with "at least one source".
Driving the real Settings → Repositories page against gitlab.com surfaced
three defects the unit and e2e suites could not.

- `SANDBOX_START` always asks for a freshly-minted credential, and the token
  source passed that straight through as `force`. A personal or project access
  token has no refresh token and no expiry, so forcing it took
  `getValidDownstreamAccessToken`'s `expired_without_refresh` branch and
  yielded null: every sandbox backed by a GitLab token account would have
  failed to clone. Force now applies only to a grant that can be refreshed.
- The "add repository" button was disabled without a connected account, while
  the copy beside it offered to link a public repository by URL — which needs
  no account at all.
- The token dialog asked for `read_api` and `read_repository`, neither of
  which can push a branch or open a merge request. It asks for `api`.

Verified against a live GitLab account: connect by token, search the account's
projects, link one, and clone the private repository with the credential the
provider account minted.
…igration

The NOT NULL drop and the "at least one source" check belong with the
migration that introduces `repository_id` in the first place — they only
exist because a repository-backed sync names no connection. One migration
now carries the whole model, and its down() reverses it in the order the
constraint requires.
…ndbox

`TASK_ADD_REPO` and the secondary-checkout path only ever knew `mcp-github`
connections, so a linked GitLab repository was invisible to the agent — and a
repository-backed secondary was skipped silently, without an error, because
the loop required a `connectionId`.

- The tool lists repositories and legacy connections together, deduped by
  (host, path) with the repository winning, so an org mid-migration sees each
  repository once through the credential Studio can actually mint. Its
  identifier is now an opaque `id`; `connectionId` stays as a deprecated alias.
- Secondary checkouts resolve through either model, in `SANDBOX_START` and in
  the tool's own config push. Mixing is the point: the daemon clones each
  checkout from its own credentialed URL, so a GitHub primary alongside a
  GitLab secondary is just two independent clones.
- The in-pod CLI login is provider-aware (`gh` hosts.yml or glab's config,
  keyed by the remote's host) and now runs *in the checkout being added*.
  It used to run in the daemon's cwd — the primary — so a GitLab secondary
  would have configured `gh` from the primary's remote and left `glab`
  unauthenticated. The two providers keep separate config files, so a
  mixed-provider sandbox ends up with both working.

The agent-facing copy no longer promises `gh` specifically. `mergeRepoChoices`
and `cliAuthCommand` are pure and unit-tested, including the nested-namespace
owner split and the two-hosts-same-path case.
…h providers

`TASK_ADD_REPO` was converted last commit but its two siblings were not, so
which repos an agent could reach depended on which entry point it came in
through — `load_repo` (Decopilot chat) and the claude-code task-run dispatch
still listed only `mcp-github` connections.

The selection logic moves to `git-providers/repo-choices.ts` and all three
share it: repositories from any provider, plus legacy connections, deduped by
(host, path) with the repository winning. `load_repo` takes the same opaque
`id`, keys its sandbox off it, and writes `repositoryId` on the thread binding
so `SANDBOX_START` resolves credentials without minting here.

Two behaviours worth naming:

- `orgSharedFirst` is new and prevents a regression: `pickSoleTaskRepo` used to
  prefer the org-shared `mcp-github` connection when one repo had two, because
  the per-agent child dies with its agent. Dedup keeps the first entry, so the
  org-shared one has to sort ahead.
- A repository whose provider is not GitHub no longer opens on Site Editor,
  which reads the decofile over GitHub's Git Data API and cannot load. Those
  projects open on Chat — the coding agent, which does work — until the editor
  speaks both providers.

The agent-facing prompts stop claiming `gh` is the CLI. They still say
`gh pr create`, which is wrong on a GitLab run and is the next thing to fix.
…providers

The run prompt hardcoded `gh pr create` and "pull request", which is simply
wrong instructions on a GitLab repository — the agent would run a command the
checkout has no CLI for.

The provider now travels on the repo choice, and the instructions that name a
command take their vocabulary from it (`providerCli` in
`@decocms/shared/git-providers`: binary, create/checkout command, and what the
provider calls a proposed change).

Naming only the primary's CLI would be its own bug, though: `TASK_ADD_REPO`
accumulates checkouts and they can be on different hosts, so a run may hold a
GitHub repository and a GitLab one at once. Every prompt therefore also states
the rule — a checkout is authenticated for ITS OWN host, `git remote get-url
origin` settles which — and the reviewer prompt says the same.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant