feat(git-providers): the task board and the PR panel speak both providers - #7022
Merged
Conversation
…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
force-pushed
the
t3code/git-providers-change-requests
branch
from
September 8, 2026 17:45
85b758e to
d94f3ea
Compare
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.
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.
This was referenced Sep 9, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
ChangeRequestClientstates exactly that. Nothing in itnames a pull request, a check run, a pipeline or a job.
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-githubMCP serverand named its tools —
pull_request_read,merge_pull_request,list_pull_requests,GET_CHECK_RUN— from the server and from thebrowser. 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
get, aget_status, aget_check_runsand aget_commentsper card, whose answersdescribed four different moments. GitHub's rollup carries commit statuses and
check runs together and both map to one
CheckRun, which is what collapsedtwo of those reads into none.
405for a forbidden merge method, a conflict and a branch rule alike, so the merge
ladder and the classification moved into the GitHub implementation — and
conflictcame back as a first-class outcome. The approval path now drops onemergeability read per merge, plus the phrase-matching that stood in for it.
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.
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.
linkPrtakes aRepoRefand derives boththe legacy
owner/namesplit andrepository_idat the write, so everycaller — 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 / MERGEandREPOSITORY_SEARCH_BRANCHESare Studio tools over the interface, replacing theGitHub-named ones and the browser's direct MCP calls. Branch browsing and branch
search were two paths (a paged MCP
list_branchesplus a GitHub-only searchtool) 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
suites next to each implementation (
change-requests/{github,gitlab}.test.ts)and the shared reducer's own.
task-board-pr-link.spec.ts: a GitLab merge request nested in subgroups linkswith 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 cleanorigin/maininfeat(git-providers): the CMS reads and writes through a provider interface #7015).
check,fmt,lint,knipclean.Known limits
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.compareDetailedis already theright seam for it.
had one, and
resolveLegacyGithubConnectionanswers null for a non-GitHub refrather than borrowing a GitHub installation that cannot see it.
in provider-neutral terms so a
merge_requesthook 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
ChangeRequestClientwith GitHub and GitLab implementations, replacing the browser's directmcp-githubcalls 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, andREPOSITORY_SEARCH_BRANCHESare new Studio tools over the interface, replacingGITHUB_PR_STATE,GITHUB_LAST_PUBLISHED_PR,GITHUB_SEARCH_BRANCHES, and the MCP pull-request calls.conflictis now a first-class merge outcome instead of phrase-matching a405; the approval path drops one mergeability read per merge.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.git-providers/github/andgit-providers/gitlab/— with provider state (App signer, env config, legacy connections) moved in beside it andgit-providers/index.tsas 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
Written for commit c0b73c2. Summary will update on new commits.