feat(mcpd): CodeRepo + GitProvider — register a forge, discover and import repos

Two resources landing together, because a repo you have to hand-type is a repo
nobody adds.

CodeRepo is a git repository attached to a project. GitProvider is a configured
forge instance you register once — URL and credential in one place — and then
list and import from.

Nothing above the driver layer knows what a Gitea is. src/mcpd/src/services/git/
holds the GitProviderDriver interface, the Gitea implementation and a registry
that resolves by type, mirroring LlmAdapterRegistry.build. Adding GitHub or
GitLab is one file plus one case. The instance URL always comes from the
GitProvider row; there is no hardcoded host anywhere.

Follows the mcpservertemplates convention: a declarative resource applied via
apply -f, with instances keeping denormalized provenance that outlives the
source. CodeRepo stores sourceProvider + sourceFullName alongside the FK, so
"where did this repo come from" stays answerable after the forge is
disconnected — the same reasoning as McpServer.templateName/templateVersion.
The FK is SetNull, so disconnecting a forge never deletes the repos, or the
tasks hanging off them, that came from it.

Discovery uses /repos/search, deliberately NOT /user/repos. The latter needs the
read:user scope, which repo-scoped automation tokens do not carry — the token
in this repo's .env has write:repository and 403s on /user/repos. The driver
also passes Gitea's 403 scope message through verbatim, so an under-scoped
token says which scope it is missing instead of looking like an empty forge.

Import is partial-success by contract: one bad name must not lose the other
nineteen. Everything importable is imported; everything skipped comes back with
a reason, and a re-import is distinguished from a name collision with an
unrelated repo. Imported repos store no credential of their own and inherit the
forge's, so rotating one token covers every repo from that forge.

Credentials never leave as plaintext: reads return the reference only, and
resolveCheckouts() — the single path that produces a token — exists for the
session provisioner and is called by no route. References are verified at
definition time, so a wrong key fails with "secret has no key 'X'" rather than
as a clone that mysteriously cannot authenticate.

Clone URLs must be https/ssh/scp-style and checkout paths must be relative with
no ".." segment: both are inputs to the clone step, and neither should be able
to point it at mcpd's own filesystem or escape the workspace.

Tests: 18 CodeRepo + 22 GitProvider unit tests. Gitea driver additionally
verified live against https://mysources.co.uk (Gitea 1.25.4, 10 repos
discovered, query filter working).

Local: build clean, workspace 2440 passed (+40), db 128 passed (same 4
pre-existing Secret failures), lint unchanged at 869.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
This commit is contained in:
Michal
2026-08-08 16:42:40 +01:00
parent 05abe3522c
commit b70300d389
18 changed files with 1794 additions and 16 deletions

View File

@@ -2797,9 +2797,9 @@
"dependencies": [
"119"
],
"status": "pending",
"status": "done",
"subtasks": [],
"updatedAt": "2026-08-08T08:55:58.627Z"
"updatedAt": "2026-08-08T15:42:40.442Z"
},
{
"id": "133",
@@ -2811,9 +2811,9 @@
"dependencies": [
"132"
],
"status": "pending",
"status": "done",
"subtasks": [],
"updatedAt": "2026-08-08T08:55:58.627Z"
"updatedAt": "2026-08-08T15:42:40.452Z"
},
{
"id": "134",
@@ -3247,13 +3247,27 @@
"status": "pending",
"subtasks": [],
"updatedAt": "2026-08-08T08:55:58.627Z"
},
{
"id": "163",
"title": "GitProvider resource: register a forge, discover and import repos",
"description": "Register a git forge instance (Gitea today) as a first-class resource, list its repositories live, and import selected ones into a project as CodeRepo rows.",
"details": "Requested mid-epic: registering the instance once means repos are discovered and imported rather than hand-typed, and credentials live in one place.\n\n- Prisma `GitProvider` {name, type, url, credSecretId/Key, ownerId}; `CodeRepo` gains gitProviderId (SetNull) + denormalized sourceProvider/sourceFullName.\n- Driver abstraction in src/mcpd/src/services/git/ (types + gitea.driver + registry), mirroring LlmAdapterRegistry.build(type). Nothing above that layer knows what a Gitea is; the instance URL always comes from the row.\n- GET /api/v1/gitproviders/:id/repos discovers live (nothing stored) and marks rows the target project already has.\n- POST /api/v1/gitproviders/:id/import imports selected repos with partial success + per-repo skip reasons.\n- Discovery uses /repos/search, NOT /user/repos: the latter needs read:user scope which repo-scoped automation tokens do not carry.\n\nFollows the mcpservertemplates convention: declarative resource applied via apply -f, with instances keeping denormalized provenance that outlives the source (cf. McpServer.templateName).",
"testStrategy": "22 unit tests in src/mcpd/tests/services/git-provider.test.ts covering driver field mapping, the token auth scheme, 403 scope-message passthrough, pagination, registry dispatch, discovery marking, and import partial-success. Driver verified live against https://mysources.co.uk (Gitea 1.25.4).",
"priority": "high",
"dependencies": [
"132"
],
"status": "done",
"subtasks": [],
"updatedAt": "2026-08-08T15:42:14.594Z"
}
],
"metadata": {
"version": "1.0.0",
"lastModified": "2026-08-08T15:28:26.705Z",
"taskCount": 162,
"completedCount": 116,
"lastModified": "2026-08-08T15:42:40.452Z",
"taskCount": 163,
"completedCount": 119,
"tags": [
"master"
]