Skip to content

feat(git-providers): the task board and the PR panel speak both providers - #7022

Merged
viktormarinho merged 6 commits into
mainfrom
t3code/git-providers-change-requests
Sep 8, 2026
Merged

feat(git-providers): the task board and the PR panel speak both providers#7022
viktormarinho merged 6 commits into
mainfrom
t3code/git-providers-change-requests

Conversation

@viktormarinho

@viktormarinho viktormarinho commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Stacked on #7015. Phase 3 of making Studio work on GitLab: after the sandbox
(#6939) and the CMS (#7015), this is the task board and the change-request
panel — the last two surfaces that were GitHub by construction.

The interface

A change request — a pull request on GitHub, a merge request on GitLab — is the
same object on both: a numbered proposal with a lifecycle, a mergeability, some
CI and some comments. ChangeRequestClient states exactly that. Nothing in it
names a pull request, a check run, a pipeline or a job.

read(number)                          // one cheap call, for the sweeps
readDetailed({ number } | { branch }) // everything a review surface draws
listOpen(limit) / lastMergedInto(base)
open(params) / describe(number, body)
merge(number, { strategy })           // never throws for a refusal
readCheckLog(checkId) / readDeployedUrl(sha)

The cheap read and the detailed one are separate methods rather than one with a
flag, because the callers are budgeted in reads, not in round-trips: the
review sweep reads every candidate card on a timer, and that multiplier is what
held the GitHub App's rate limit shut for 17 hours once.

Call counts differ and that is fine — GitHub folds the detailed read into one
GraphQL query, GitLab needs a handful of REST hops.

Why the board could not see a GitLab MR

Both the board and the panel reached GitHub through the mcp-github MCP server
and named its tools — pull_request_read, merge_pull_request,
list_pull_requests, GET_CHECK_RUN — from the server and from the
browser. A GitLab project had nothing to call, so its merge request never
reached a card: no review cycle, no auto-merge, no publish.

What the interface bought beyond GitLab

  • One detailed read replaces four to six. The MCP path made a get, a
    get_status, a get_check_runs and a get_comments per card, whose answers
    described four different moments. GitHub's rollup carries commit statuses and
    check runs together and both map to one CheckRun, which is what collapsed
    two of those reads into none.
  • A refusal is classified where its vocabulary lives. GitHub answers 405
    for a forbidden merge method, a conflict and a branch rule alike, so the merge
    ladder and the classification moved into the GitHub implementation — and
    conflict came back as a first-class outcome. The approval path now drops one
    mergeability read per merge, plus the phrase-matching that stood in for it.
  • The read cache stores neutral shapes, keyed by repository rather than by
    connection. A busy repository's raw comments payload ran past the cache's
    value cap, so its put was rejected and that change request missed on every
    read, forever.
  • No client lifetime to manage. The provider clients are stateless HTTP, so
    the MCP dance of keeping a client open for background revalidation is gone —
    along with the bug where closing eagerly killed every refresh.

Identity is the URL

It carries the host, so it names the provider, and it is the only shape a GitLab
project nested in subgroups fits. linkPr takes a RepoRef and derives both
the legacy owner/name split and repository_id at the write, so every
caller — the tool hook, a bash-output scan, a pasted URL — records the
credential without knowing it has to.

The browser stops speaking GitHub

CHANGE_REQUEST_STATE / LAST_MERGED / LIST_OPEN / CHECK_LOG / OPEN / MERGE and
REPOSITORY_SEARCH_BRANCHES are Studio tools over the interface, replacing the
GitHub-named ones and the browser's direct MCP calls. Branch browsing and branch
search were two paths (a paged MCP list_branches plus a GitHub-only search
tool) and are now one call with a cursor.

Site Editor no longer opens GitLab projects on Chat: reading the decofile and
opening a change request both go through the interface now, so the gate #7015
left behind is gone.

Testing

  • 1404 unit tests across the touched areas, 0 failures — including new mapping
    suites next to each implementation (change-requests/{github,gitlab}.test.ts)
    and the shared reducer's own.
  • 255 e2e passed, 10 skipped. New black-box coverage in
    task-board-pr-link.spec.ts: a GitLab merge request nested in subgroups links
    with every namespace level intact, a sub-path URL still resolves, and neither
    provider's issue URL is accepted. The one failure,
    mcp-proxy-roundtrip, is pre-existing (verified on a clean origin/main in
    feat(git-providers): the CMS reads and writes through a provider interface #7015).
  • check, fmt, lint, knip clean.

Known limits

  • The Changes tab's fallback diff — used only when the sandbox has no diff
    of its own — still walks GitHub's contents API per file, so a GitLab project
    shows the sandbox diff alone rather than erroring. Making it neutral needs a
    compare-plus-blobs tool; RepoContentClient.compareDetailed is already the
    right seam for it.
  • A GitLab repository has no legacy-connection path. That is correct: it never
    had one, and resolveLegacyGithubConnection answers null for a non-GitHub ref
    rather than borrowing a GitHub installation that cannot see it.
  • Still reconcile-on-view, not webhooks — unchanged by this PR, and now stated
    in provider-neutral terms so a merge_request hook can join the same path.

Summary by cubic

Opens the task board and change-request panel to GitLab alongside GitHub, completing the last two Studio surfaces that were GitHub by construction. Both now go through a provider-neutral ChangeRequestClient with GitHub and GitLab implementations, replacing the browser's direct mcp-github calls and GitHub-only tools; GitLab merge requests now flow through review, auto-merge, and publishing the same way pull requests do. Also fixes the repository import flow, which 400'd on every import while the picker closed as though it had worked.

What changed

  • CHANGE_REQUEST_STATE, CHANGE_REQUEST_OPEN, CHANGE_REQUEST_MERGE, and REPOSITORY_SEARCH_BRANCHES are new Studio tools over the interface, replacing GITHUB_PR_STATE, GITHUB_LAST_PUBLISHED_PR, GITHUB_SEARCH_BRANCHES, and the MCP pull-request calls.
  • Merge refusals are classified inside each provider, so conflict is now a first-class merge outcome instead of phrase-matching a 405; the approval path drops one mergeability read per merge.
  • The read cache stores neutral shapes keyed by repository instead of by connection, fixing rejected payloads on busy repositories.
  • The repository import payload now always carries connections (empty when the repo has none), which was the missing field behind that silent 400; the error states distinguish "no account", "no legacy connection", and "dead token", and the import button copy is neutral instead of naming GitHub.
  • Branch browsing and search collapse into one paged server call with a cursor.
  • Provider code now lives one directory per provider — git-providers/github/ and git-providers/gitlab/ — with provider state (App signer, env config, legacy connections) moved in beside it and git-providers/index.ts as the only entry point; a new lint rule makes that boundary a build error, catching GitLab importing GitHub's CI summarizer (now hoisted to the shared contract). A GitHub App whose private key can't sign is treated as unconfigured, so a mangled PEM leaves the App dormant instead of routing every org to it.

Known limits

  • The Changes tab's fallback diff still walks GitHub's contents API when the sandbox has no diff; a GitLab project shows its sandbox diff alone.
  • The board still reconciles on view rather than via webhooks, though the hook path is now stated in provider-neutral terms.

Written for commit c0b73c2. Summary will update on new commits.

Review in cubic

Base automatically changed from t3code/git-providers-repo-content to main September 8, 2026 17:36
…ders

A change request — a pull request on GitHub, a merge request on GitLab — is
the same object on both: a numbered proposal with a lifecycle, a mergeability,
some CI and some comments. `ChangeRequestClient` states exactly that, and the
two implementations answer it however their provider can.

The board and the panel were GitHub by construction, not by oversight. Both
reached GitHub through the `mcp-github` MCP server and named its tools —
`pull_request_read`, `merge_pull_request`, `list_pull_requests`,
`GET_CHECK_RUN` — from the server AND from the browser. A GitLab project had
nothing to call, so its merge request never reached a card: no review cycle,
no auto-merge, no publish.

What the interface bought beyond GitLab:

- One detailed read replaces four to six. The MCP path made a `get`, a
  `get_status`, a `get_check_runs` and a `get_comments` per card, whose answers
  described four different moments; GitHub answers all of it in one GraphQL
  query and GitLab in a handful of REST hops. Commit statuses and check runs
  are one `CheckRun` now, which is what collapsed two of those reads.
- A refusal is classified where its vocabulary lives. `405` means a forbidden
  merge method, a conflict or a branch rule depending on prose, so the merge
  ladder and the classification moved into the GitHub implementation, and
  `conflict` came back as a first-class outcome. The approval path drops a
  mergeability read per merge, and the phrase-matching that stood in for it.
- The read cache stores neutral shapes, keyed by repository rather than by
  connection. A busy repository's raw comments payload ran past the value cap,
  so its put was rejected and that change request missed forever.
- The provider clients are stateless HTTP, so the MCP client lifetime dance
  around background revalidation is gone — with the bug where closing eagerly
  killed every refresh.

Identity is the URL. It carries the host, so it names the provider, and it is
the only shape a GitLab project nested in subgroups fits; `linkPr` derives the
legacy owner/name split and resolves `repository_id` at the write, so every
caller records the credential without knowing it has to.

The browser stops holding a GitHub MCP client entirely: `CHANGE_REQUEST_*` and
`REPOSITORY_SEARCH_BRANCHES` are Studio tools over the interface. Branch
browsing and branch search were two paths (a paged MCP `list_branches` plus a
GitHub-only search tool) and are now one call with a cursor.

Site Editor no longer opens GitLab projects on Chat: reading the decofile and
opening a change request both go through the interface now.

Known limits, unchanged by this: the Changes tab's fallback diff (used only
when the sandbox has none) still walks GitHub's contents API, so a GitLab
project shows the sandbox diff alone; and a GitLab repository still has no
legacy connection path, which is correct — it never had one.
The layout mixed two axes for no designed reason. Transport was
provider-first (`github/http.ts`), capabilities were capability-first
(`content/github.ts`), so `github/client.ts` and `content/github.ts` talked to
the same GitHub, for the same reason, from different places — an artefact of
the order the three PRs were written, not a decision.

Provider is now the primary axis, which turns "the only place GitHub is
hardcoded" from a convention into something you can grep:

  git-providers/
    index.ts          the only entry point the rest of the API imports
    types.ts  content.ts  change-requests.ts    the three contracts
    credentials.ts    which credential reaches which repository
    capabilities.ts   what this deployment can connect
    clients.ts        the composition root — the one module aware of both
    github/  gitlab/  one provider's vocabulary, and nothing else's

Four things were living in the neutral layer that had no business there:

- `env.ts` held both providers' config; it is `github/env.ts` and
  `gitlab/env.ts` now, and each provider answers `capability()` for itself so
  the capabilities tool names no env var and no host.
- The process-wide GitHub App signer sat in the credential ladder. It is in
  `github/app-auth.ts` beside the config it reads, so the ladder — which is
  provider-neutral — holds no GitHub state.
- `repo-choices.ts` rebuilt `https://github.com/owner/repo` by hand and read
  `mcp-github` connections. The legacy half moved to
  `github/legacy-connection.ts`, which hands back a `RepoRef`; the merge is now
  provider-neutral and a GitLab project flows through the same code.
- `findRepositoryForLegacyBinding` assumed `github.com` for its identity
  lookup — correct, but a GitHub fact, so it moved next to the other legacy
  logic. That module is now the single thing that knows a connection can stand
  for a repository, which makes it the whole deletion when the last org
  migrates.

`GIT_ACCOUNT_CONNECT_TOKEN` called `gitlabCurrentUser` directly and hardcoded
"GitHub connects through the App" as a tool-level guard. That is a statement
about what each provider offers, so it is `principalForToken` in the registry
now — and the refusal explains itself: a user PAT would silently widen every
repository to that user's blanket access, where an App mints one scoped to one
repository.

Two reach-throughs remain, both provider-specific BY CONSTRUCTION and both
named in the barrel: `api/routes/git-providers.ts` (an App installation and an
OAuth grant are different redirect dances) and `tools/github/list-user-orgs.ts`
(listing App installations has no counterpart to abstract over).

Pure movement — no behaviour changed. The two factories were merged into one
file and share a `legacyGithubToken` helper, which is what removes the last
copy of the legacy-token path; their different answers to "no credential"
(content throws, change requests return null) are unchanged and now documented
together.
The layout says `github/` and `gitlab/` are the only places a provider's name,
hosts, endpoints and error prose appear. Until now that was a convention, and
the sweep that preceded this found four places it had already drifted — an env
module holding both providers' config, the App signer inside the neutral
credential ladder, a repo-choice mapper hand-building `github.com` URLs, and a
lookup silently assuming that host.

Two rules, both about imports:

1. Code outside the layer may not import `git-providers/github/**` or
   `git-providers/gitlab/**` — the front door is `@/git-providers`. A caller
   that genuinely needs one provider wants a capability the interface does not
   express yet, and adding it there is the fix.
2. One provider's directory may not import another's. This half is not
   hypothetical: `gitlab/change-requests.ts` imported `summarizeChecks` from
   the GitHub side, which quietly made GitLab's CI summary GitHub's. The fix
   was to hoist it to the contract both implement, which is what the message
   now tells you to do.

Two allowlisted files, both provider-specific BY CONSTRUCTION rather than by
convenience: `api/routes/git-providers.ts` (an App installation and an OAuth
grant are different redirect dances, so there is no one flow to implement) and
`tools/github/list-user-orgs.ts` (listing App installations has no counterpart
to abstract over).

`error`, not `warn`, because the layer is clean today — matching
`ban-e2e-app-imports`, the other wall with an explicit allowlist. The tests are
fixtures rather than a reading of the real tree, since a boundary rule that
matches nothing is indistinguishable from a broken one; one of them pins the
case a name-based rule would get wrong, `api/routes/git-providers.ts`, which is
named for the layer without being in it.
Found by driving the real browser against a real GitHub repo and a real
private GitLab project, which is the only way this could have been found: the
e2e suite covers the `REPOSITORY_*` tools, not the surface that calls them.

Three defects, in descending order of how badly they lied to the user:

1. **The import created nothing.** `COLLECTION_VIRTUAL_MCP_CREATE` requires
   `connections`, and the repository-backed bridge never sent it — it has no
   per-repo connection to attach, so the field was simply forgotten rather
   than sent empty. Every import 400'd while the picker closed as though it
   had worked: no project, no error, no trace. The payload is now a pure
   `agentPayload()` with a test that says `connections` is present and empty,
   because that is a wire contract and this file already got it wrong once.

2. **"Reconnect the mcp-github integration"** was raised for a repository
   linked anonymously from a public URL — which has no integration to
   reconnect and never had one. The three cases (no account on the row, a
   non-GitHub repo with no connection, a GitHub connection whose token died)
   are genuinely different and now say so. A message naming an action the
   reader cannot take is worse than no message.

3. **"Import from GitHub"** on a button that imports GitLab. The copy is
   neutral now ("Import repository"), in both locales; the keys stay, per the
   convention that keys are code and values are what a person reads.

Verified against live providers, not stubs: linked a public GitHub repo
anonymously and a private GitLab project through a project access token;
imported both, each creating a project bound to its repository row;
`REPOSITORY_SEARCH_BRANCHES` read `main` off gitlab.com; `CHANGE_REQUEST_STATE`
read a real merge request into the neutral shape (conflicting false, checks
null with no pipeline, changedFiles parsed from `changes_count`);
`CHANGE_REQUEST_MERGE` squash-landed it, confirmed on GitLab as
`state=merged squash=true`; and the task board reconciled the card to done off
that merge, with both repositories in its filter. Test resources deleted and
the token revoked.
`readGithubAppConfig` only proves five environment variables are non-empty, and
`GithubAppAuth` does not touch the PEM until the first mint — so a key mangled
on the way into a secret store (newlines eaten, the classic) still made
`getGithubAppAuth()` non-null.

That is the switch. A non-null signer makes every backfilled account
`accountIsServable`, which takes every GitHub repository OFF its legacy
`mcp-github` connection and onto an App that cannot sign. A bad paste would
therefore not be a no-op, it would be an outage across every GitHub org, with
the working path already ruled out.

The gate now checks that the key can actually sign. A key that cannot leaves
the App disabled, every org on the connection it is already using, and one loud
log line naming the likely cause — which is what a misconfiguration should
cost. Verified against every shape a mangled PEM arrives in: newlines replaced
by spaces, unescaped `\n`, truncated, and not a key at all.
@viktormarinho
viktormarinho force-pushed the t3code/git-providers-change-requests branch from 85b758e to d94f3ea Compare September 8, 2026 17:45
The copy went neutral ("Import repository") because the same control now
imports a GitLab project, and `packages/e2e` was not grepped for it — the rule
this repo already writes down, missed. CI caught it; the spec asserted the old
label three times over its retries.

Two more of the same string were still out there: the dev-agent setup's import
button, which opens the very same picker, and a doc comment naming the control.
@viktormarinho
viktormarinho merged commit c3e51f9 into main Sep 8, 2026
34 checks passed
@viktormarinho
viktormarinho deleted the t3code/git-providers-change-requests branch September 8, 2026 19:16
decocms Bot pushed a commit that referenced this pull request Sep 8, 2026
PR: #7022 feat(git-providers): the task board and the PR panel speak both providers
Bump type: minor

- decocms (apps/api/package.json): 4.338.0 -> 4.339.0
- @decocms/native (apps/native/package.json): 4.338.0 -> 4.339.0
- @decocms/e2e (packages/e2e/package.json): 1.67.0 -> 1.68.0
- @decocms/shared (packages/shared/package.json): 0.87.0 -> 0.88.0

Deploy-Scope: both
pedrofrxncx added a commit that referenced this pull request Sep 9, 2026
…7048)

PR #7022 made the task board's PR panel provider-neutral, but PrCard
still hardcoded GitHubIcon for every linked change request. A GitLab
merge request now shows the wrong provider icon in its own card.

Derive the provider from the card's own URL with the already-shipped
parseChangeRequestUrl (github.com/.../pull/N vs .../-/merge_requests/N)
and pick GitLabIcon vs GitHubIcon accordingly — no wire-contract change
needed since the URL already carries the host.
pedrofrxncx added a commit that referenced this pull request Sep 9, 2026
…pository syncs (#7077)

The org-repo-sync / public-set sync path for a first-class repository (repositoryId-backed, via GitProviderClient.archiveTarball) sent one request and gave up on any failure — no retry. The legacy raw-fetch codeload path already retries transient 5xx/429/network errors (#7049); this only extended the same policy to the newer path, which #6939/#7022 made the default one.

Both provider clients (github/http.ts, gitlab/http.ts) fold a network failure into GitProviderError{status:0}, so the existing isRetriableTarballError classifier just needed a GitProviderError branch (0/5xx/429 retriable, 4xx not) alongside the TarballHttpError one it already has.
pedrofrxncx pushed a commit that referenced this pull request Sep 9, 2026
Ports #7039 onto post-#7022 main, on top of the #7094 revert.

Checks go green and the deploy bot posts a preview url, but the card shows
neither for 1-3 minutes. Polling harder is not the fix - it is the incident
#7094 just reverted. Use the events GitHub already sends instead:

- `/api/_github/webhook` gains `check_suite` / `issue_comment` consumers; they
  reverse-look-up the PR's cards (new `findPrLinks`, indexed by migration 207),
  re-read the provider bypassing the read cache, write the card cache and emit
  `task-board.item.prs.updated` on the org's SSE stream.
- The open dialog writes those cards straight into its query cache.
- A deploy check that prints its url in `summary` counts as a preview source,
  so a repo whose bot never comments still gets a Preview button.

Changes from #7039, both about not re-creating the rate limit:

- The dialog poll stays at 60s. #7039 kept a 10s tier for in-flight cards,
  which is what #7094 reverted, and the webhook is the freshness path now. A
  repo the App is not installed on falls back to the minute.
- `check_suite` is narrowed to `action: "completed"`. A suite fires three times
  per commit per app and every refresh is an UNCACHED provider read, so the
  other two tripled this path's cost to learn "pending" - which the card
  already shows.

Rebased onto the git-providers abstraction: `updatedAt` is now on the neutral
`ChangeRequest` (both adapters), the `fresh` bypass is a flag on the read cache
rather than a zero-stale-ceiling predicate, and the obsolete `get_status` /
`get_check_runs` window overrides are gone with the MCP read path.

Dormant until deployed: no `GITHUB_WEBHOOK_SECRET` means 503 and today's
polling, unchanged.
pedrofrxncx pushed a commit that referenced this pull request Sep 9, 2026
Ports #7039 onto post-#7022 main, on top of the #7094 revert.

Checks go green and the deploy bot posts a preview url, but the card shows
neither for 1-3 minutes. Polling harder is not the fix - it is the incident
#7094 just reverted. Use the events GitHub already sends instead:

- `/api/_github/webhook` gains `check_suite` / `issue_comment` consumers; they
  reverse-look-up the PR's cards (new `findPrLinks`, indexed by migration 207),
  re-read the provider bypassing the read cache, write the card cache and emit
  `task-board.item.prs.updated` on the org's SSE stream.
- The open dialog writes those cards straight into its query cache.
- A deploy check that prints its url in `summary` counts as a preview source,
  so a repo whose bot never comments still gets a Preview button.

Changes from #7039, both about not re-creating the rate limit:

- The dialog poll stays at 60s. #7039 kept a 10s tier for in-flight cards,
  which is what #7094 reverted, and the webhook is the freshness path now. A
  repo the App is not installed on falls back to the minute.
- `check_suite` is narrowed to `action: "completed"`. A suite fires three times
  per commit per app and every refresh is an UNCACHED provider read, so the
  other two tripled this path's cost to learn "pending" - which the card
  already shows.

Rebased onto the git-providers abstraction: `updatedAt` is now on the neutral
`ChangeRequest` (both adapters), the `fresh` bypass is a flag on the read cache
rather than a zero-stale-ceiling predicate, and the obsolete `get_status` /
`get_check_runs` window overrides are gone with the MCP read path.

Dormant until deployed: no `GITHUB_WEBHOOK_SECRET` means 503 and today's
polling, unchanged.
pedrofrxncx added a commit that referenced this pull request Sep 9, 2026
#7100)

Ports #7039 onto post-#7022 main, on top of the #7094 revert.

Checks go green and the deploy bot posts a preview url, but the card shows
neither for 1-3 minutes. Polling harder is not the fix - it is the incident
#7094 just reverted. Use the events GitHub already sends instead:

- `/api/_github/webhook` gains `check_suite` / `issue_comment` consumers; they
  reverse-look-up the PR's cards (new `findPrLinks`, indexed by migration 207),
  re-read the provider bypassing the read cache, write the card cache and emit
  `task-board.item.prs.updated` on the org's SSE stream.
- The open dialog writes those cards straight into its query cache.
- A deploy check that prints its url in `summary` counts as a preview source,
  so a repo whose bot never comments still gets a Preview button.

Changes from #7039, both about not re-creating the rate limit:

- The dialog poll stays at 60s. #7039 kept a 10s tier for in-flight cards,
  which is what #7094 reverted, and the webhook is the freshness path now. A
  repo the App is not installed on falls back to the minute.
- `check_suite` is narrowed to `action: "completed"`. A suite fires three times
  per commit per app and every refresh is an UNCACHED provider read, so the
  other two tripled this path's cost to learn "pending" - which the card
  already shows.

Rebased onto the git-providers abstraction: `updatedAt` is now on the neutral
`ChangeRequest` (both adapters), the `fresh` bypass is a flag on the read cache
rather than a zero-stale-ceiling predicate, and the obsolete `get_status` /
`get_check_runs` window overrides are gone with the MCP read path.

Dormant until deployed: no `GITHUB_WEBHOOK_SECRET` means 503 and today's
polling, unchanged.

Co-authored-by: Pedro França <pedrofrxncx@deco.cx>
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