From b713ebebdd62582b70205b2467f95b2e63389920 Mon Sep 17 00:00:00 2001 From: Contentrain Date: Fri, 17 Apr 2026 15:25:03 +0300 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20phase=208=20=E2=80=94=20GitLabProv?= =?UTF-8?q?ider=20(full=20integration)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a production-grade GitLab provider that implements the same RepoProvider contract as the existing GitHubProvider — full reader + writer + branch ops + merge. No feature gaps versus GitHub. Sits behind the same HTTP transport so Studio (and any agent driving MCP remotely) can point at gitlab.com or a self-hosted GitLab CE / EE instance with no tool-facing behaviour changes. Why gitbeaker / @gitbeaker/rest: The library is the only actively-maintained, TypeScript-native, feature-complete GitLab SDK on npm (v43.8.0 released Nov 2025, MIT, 1.7k stars). It mirrors @octokit/rest's role for GitHub and lets the codebase keep a single peer-dep + dynamic-import pattern across git hosts. The alternative — hand-rolled fetch against the v4 REST API — trades ~500 lines of typed client code for ~1,300 lines of manual pagination, response typing, and rate-limit handling without any concrete benefit. The GitLab docs' other listed clients (`gitlab-yaac`, `backbone-gitlab`) are both dead (last published 10–11 years ago). API shape differences versus GitHub: - Atomic commit: GitHub needs createBlob × N → createTree → createCommit → updateRef/createRef (4+N calls). GitLab's Commits API accepts a single POST with an actions array that covers create / update / delete — one call does the lot. applyPlan resolves each action's verb by probing file existence at the target ref so GitLab's strict server-side validation never rejects mixed-state plans. - Merge: GitLab has no direct branch-to-branch merge endpoint. `mergeBranch` opens an MR and immediately accepts it — the resulting MergeResult keeps the { merged, sha, pullRequestUrl } shape, with the MR URL available for audit. Callers see the same contract as GitHub's repos.merge result. - Reads: RepositoryFiles.showRaw returns UTF-8 text directly (no base64 decode), and fileExists falls back to a tree listing when the file endpoint 404s so directories resolve to `true` the same way LocalReader / GitHubReader do. Changes: - packages/mcp/src/providers/gitlab/ (new, 9 files, ~480 LOC) - types.ts, paths.ts, client.ts, capabilities.ts — small plumbing mirroring the GitHub provider shape. - reader.ts — RepoReader over RepositoryFiles.showRaw + Repositories.allRepositoryTrees. Blob fallback for edge / browser runtimes. - apply-plan.ts — single Commits.create call with create / update / delete action resolution. Filters no-op deletes (delete-nonexistent would be a 400). Uses startBranch for first commit on a new feature branch. - branch-ops.ts — listBranches (server `search` + client-side prefix guard), createBranch, deleteBranch, getBranchDiff (Repositories.compare), mergeBranch (MR create + accept), isMerged (compare with empty commits list), getDefaultBranch. - provider.ts — GitLabProvider class implementing RepoProvider, delegates to the helpers above. - factory.ts — createGitLabClient + createGitLabProvider with dynamic import of @gitbeaker/rest. Supports pat / oauth / job token auth. Accepts optional `host` for self-hosted GitLab. - index.ts — barrel. - packages/mcp/package.json - @gitbeaker/rest >=43.0.0 added as optional peer (same pattern as @octokit/rest). - ./providers/gitlab export added; build/dev tsdown targets extended. - packages/mcp/tests/providers/gitlab/ (new, 3 files, 31 tests) - reader.test.ts — readFile (string + Blob paths), contentRoot prefixing, default-branch resolution, listDirectory 404 → [], fileExists (file hit, directory fallback, empty tree, double-404, non-404 error propagation). 12 tests. - apply-plan.test.ts — new-branch commit with startBranch, existing-branch update/create action resolution, delete filtering, contentRoot prefixing, default-branch fallback, empty-plan throw, Commit envelope normalisation. 7 tests. - branch-ops.test.ts — getDefaultBranch, listBranches (no prefix + prefix with substring guard), createBranch, deleteBranch, getBranchDiff (added / modified / removed + empty diffs), mergeBranch (accepted + unaccepted), isMerged (merged + ahead). 12 tests. - packages/mcp/tests/server/http.test.ts - New E2E: `commits content_save through a GitLabProvider-like remote provider`. Mirrors the existing GitHub E2E — drives contentrain_content_save over HTTP against a mocked gitbeaker client, asserts pending-review workflow, captured Commits.create payload contains the expected action paths (content + meta + context.json), and startBranch is set for the new branch. - packages/mcp/README.md - New "Remote Providers" section listing the three backends and their capability differences. - GitLab installation + usage snippet (PAT auth + self-hosted host config). - "Bitbucket — coming soon" note. - providers/gitlab added to Core Exports. Bitbucket is intentionally not in this PR. Bitbucket Cloud's API shape is materially different (tree reads require a separate blob hash lookup; the commits endpoint is per-file rather than per-plan) and market share is lower. Shipping it later as its own PR keeps this change focused and releasable. Verification: - pnpm --filter @contentrain/mcp typecheck → 0 errors. - oxlint packages/mcp/src + tests → 0 warnings (135 files). - vitest run tests/core tests/conformance tests/serialization-parity tests/git tests/providers tests/server tests/util → 413/413 green, 2 skipped (31 new GitLab tests + 1 new HTTP E2E). - vitest run tests/tools (full suite) → 90/91 on the parallel run with one pre-existing flaky timeout (content.test.ts `blocks new writes when 80 active contentrain branches exist` — 80 sequential git checkouts inside a 120s budget gets squeezed under heavy parallel load). Re-run in isolation → 17/17 green in 450s. Independent of Phase 8; the new GitLab code is not exercised by that test. Phase 8 scope after this PR: - 8.1 GitLabProvider (full) ✓ (this PR) - 8.2 BitbucketProvider — deferred, see README. - 8.3 Conformance fixtures — the reader + apply-plan + branch-ops unit tests exercise every method in RepoProvider; a shared conformance harness across providers is a separate hardening pass (Phase 9 or later). What Phase 9 picks up next: - Changeset + release notes. - Top-level README updates calling out the three providers. - Final cleanup pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/mcp/README.md | 57 ++++ packages/mcp/package.json | 13 +- .../mcp/src/providers/gitlab/apply-plan.ts | 173 ++++++++++++ .../mcp/src/providers/gitlab/branch-ops.ts | 144 ++++++++++ .../mcp/src/providers/gitlab/capabilities.ts | 21 ++ packages/mcp/src/providers/gitlab/client.ts | 12 + packages/mcp/src/providers/gitlab/factory.ts | 63 +++++ packages/mcp/src/providers/gitlab/index.ts | 7 + packages/mcp/src/providers/gitlab/paths.ts | 22 ++ packages/mcp/src/providers/gitlab/provider.ts | 87 +++++++ packages/mcp/src/providers/gitlab/reader.ts | 110 ++++++++ packages/mcp/src/providers/gitlab/types.ts | 43 +++ .../tests/providers/gitlab/apply-plan.test.ts | 246 ++++++++++++++++++ .../tests/providers/gitlab/branch-ops.test.ts | 203 +++++++++++++++ .../mcp/tests/providers/gitlab/reader.test.ts | 180 +++++++++++++ packages/mcp/tests/server/http.test.ts | 116 +++++++++ pnpm-lock.yaml | 49 ++++ 17 files changed, 1544 insertions(+), 2 deletions(-) create mode 100644 packages/mcp/src/providers/gitlab/apply-plan.ts create mode 100644 packages/mcp/src/providers/gitlab/branch-ops.ts create mode 100644 packages/mcp/src/providers/gitlab/capabilities.ts create mode 100644 packages/mcp/src/providers/gitlab/client.ts create mode 100644 packages/mcp/src/providers/gitlab/factory.ts create mode 100644 packages/mcp/src/providers/gitlab/index.ts create mode 100644 packages/mcp/src/providers/gitlab/paths.ts create mode 100644 packages/mcp/src/providers/gitlab/provider.ts create mode 100644 packages/mcp/src/providers/gitlab/reader.ts create mode 100644 packages/mcp/src/providers/gitlab/types.ts create mode 100644 packages/mcp/tests/providers/gitlab/apply-plan.test.ts create mode 100644 packages/mcp/tests/providers/gitlab/branch-ops.test.ts create mode 100644 packages/mcp/tests/providers/gitlab/reader.test.ts diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 252f799..957124f 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -180,6 +180,61 @@ over a remote provider returns a uniform capability error: Agents driving a remote transport should fall back to a local transport (or a local checkout) before invoking normalize. +## 🌐 Remote Providers + +MCP supports three backends behind the same `RepoProvider` contract: + +- **LocalProvider** — simple-git + worktree. Every tool (normalize + included) works on it. Stdio transport defaults to this. +- **GitHubProvider** — Octokit over the Git Data + Repos APIs. No + clone, no worktree. `@octokit/rest` ships as an optional peer + dependency. +- **GitLabProvider** — gitbeaker over the GitLab REST API. No clone, + no worktree. `@gitbeaker/rest` ships as an optional peer + dependency. Supports gitlab.com and self-hosted CE / EE. + +Each remote provider implements the same surface: reader (readFile / +listDirectory / fileExists), writer (applyPlan — one atomic commit), +branch ops (list / create / delete / diff / merge / isMerged / +getDefaultBranch). `mergeBranch` goes straight through on GitHub; on +GitLab it opens an MR and immediately accepts it so the final +`MergeResult` shape matches either way. + +### GitLab — installation & usage + +```bash +pnpm add @gitbeaker/rest +``` + +```ts +import { createGitLabProvider } from '@contentrain/mcp/providers/gitlab' +import { createServer } from '@contentrain/mcp/server' + +const provider = await createGitLabProvider({ + auth: { type: 'pat', token: process.env.GITLAB_TOKEN! }, + project: { + projectId: 'acme/site', // or numeric project ID + host: 'https://gitlab.company.com', // omit for gitlab.com + }, +}) + +const server = createServer({ provider }) +// serve over stdio or the HTTP transport from @contentrain/mcp/server/http +``` + +Capabilities: `sourceRead`, `sourceWrite`, `astScan`, `localWorktree` +are all `false`; `pushRemote`, `branchProtection`, +`pullRequestFallback` are `true`. Normalize / scan / apply reject +with a capability error on GitLabProvider — fall back to a local +transport for those flows. + +### Bitbucket — coming soon + +Bitbucket Cloud + Data Center support is on the roadmap. Until the +provider ships, use the `contentrain_describe_format` tool to drive +Contentrain content operations manually from a Bitbucket checkout via +the LocalProvider path. + ## 📦 Core Exports The package also exposes low-level modules for embedding and advanced use: @@ -198,6 +253,8 @@ The package also exposes low-level modules for embedding and advanced use: - `@contentrain/mcp/git/transaction` - `@contentrain/mcp/git/branch-lifecycle` - `@contentrain/mcp/templates` +- `@contentrain/mcp/providers/github` +- `@contentrain/mcp/providers/gitlab` These are intended for Contentrain tooling and advanced integrations, not for direct manual editing of `.contentrain/` files. diff --git a/packages/mcp/package.json b/packages/mcp/package.json index e1c3495..e2b0375 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -107,6 +107,10 @@ "./providers/github": { "types": "./dist/providers/github/index.d.mts", "import": "./dist/providers/github/index.mjs" + }, + "./providers/gitlab": { + "types": "./dist/providers/gitlab/index.d.mts", + "import": "./dist/providers/gitlab/index.mjs" } }, "main": "./dist/index.mjs", @@ -115,8 +119,8 @@ "dist" ], "scripts": { - "build": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/util/serializer.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/providers/github/index.ts src/server/http/index.ts --format esm --dts --external typescript", - "dev": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/util/serializer.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/providers/github/index.ts src/server/http/index.ts --format esm --dts --external typescript --watch", + "build": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/util/serializer.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript", + "dev": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/util/serializer.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript --watch", "test": "vitest run", "typecheck": "tsc --noEmit", "clean": "rm -rf dist" @@ -134,15 +138,20 @@ "svelte": "^4.2.0" }, "peerDependencies": { + "@gitbeaker/rest": ">=43.0.0", "@octokit/rest": ">=20.0.0" }, "peerDependenciesMeta": { + "@gitbeaker/rest": { + "optional": true + }, "@octokit/rest": { "optional": true } }, "devDependencies": { "@astrojs/compiler": "^2.10.0", + "@gitbeaker/rest": "^43.8.0", "@octokit/rest": "^22.0.1", "@types/node": "^25.4.0", "@vue/compiler-sfc": "^3.5.30", diff --git a/packages/mcp/src/providers/gitlab/apply-plan.ts b/packages/mcp/src/providers/gitlab/apply-plan.ts new file mode 100644 index 0000000..71e6c51 --- /dev/null +++ b/packages/mcp/src/providers/gitlab/apply-plan.ts @@ -0,0 +1,173 @@ +import type { ApplyPlanInput, Commit, FileChange } from '../../core/contracts/index.js' +import type { GitLabClient } from './client.js' +import { resolveRepoPath } from './paths.js' +import type { ProjectRef } from './types.js' + +type CommitActionType = 'create' | 'update' | 'delete' + +interface CommitAction { + action: CommitActionType + filePath: string + content?: string + encoding?: 'text' | 'base64' +} + +/** + * Apply a plan to a GitLab repository as a single atomic commit via the + * Commits API. High-level flow: + * + * 1. Check whether the target branch exists. If it does not, GitLab's + * `startBranch` option forks a new branch from `input.base` (or the + * project's default branch) as part of the same commit. + * 2. For each `FileChange`, figure out the right GitLab action verb + * (`create` vs `update` vs `delete`) by probing the path against the + * resolved ref. GitLab validates this server-side and returns a 400 + * for mismatches — we avoid the round trip by getting it right up + * front. + * 3. POST `/repository/commits` once with the full action set. No + * working tree, no multi-call tree-assembly dance — GitLab exposes + * a richer primitive than the GitHub Git Data API for this case. + * + * The commit is durable as soon as the call returns; GitLab's ref + * update happens inside the same request. + */ +export async function applyPlanToGitLab( + client: GitLabClient, + project: ProjectRef, + input: ApplyPlanInput, +): Promise { + const branchExists = await branchHasRef(client, project, input.branch) + const baseBranch = input.base ?? await resolveDefaultBranch(client, project) + + // Actions are computed against the HEAD of the feature branch when it + // exists, otherwise against the fork point (baseBranch). GitLab + // treats `create` / `update` / `delete` as a strict check against the + // path's existence at that ref. + const refForActionResolution = branchExists ? input.branch : baseBranch + const rawActions = await Promise.all( + input.changes.map(change => resolveAction(client, project, refForActionResolution, change)), + ) + const actions = rawActions.filter((a): a is CommitAction => a !== null) + + if (actions.length === 0) { + // Nothing to apply. Callers don't generally build empty plans, but + // if they do we short-circuit before touching the API. + throw new Error('applyPlanToGitLab: plan contained no applicable actions') + } + + const options: Record = { + authorName: input.author.name, + authorEmail: input.author.email, + } + if (!branchExists) { + options.startBranch = baseBranch + } + + const response = await client.Commits.create( + project.projectId, + input.branch, + input.message, + actions, + options, + ) + + // Gitbeaker's generic-heavy response type widens string fields to + // `string | Camelize<…>` — at runtime they are always strings under + // the default (non-camelize) response mode we use. Cast narrows the + // shape we actually touch. + const commit = response as { + id: string + message?: string + author_name?: string + author_email?: string + created_at?: string + } + const commitTimestamp = toIsoTimestamp(commit.created_at) ?? new Date().toISOString() + return { + sha: commit.id, + message: commit.message ?? input.message, + author: { + name: commit.author_name ?? input.author.name, + email: commit.author_email ?? input.author.email, + }, + timestamp: commitTimestamp, + } +} + +async function resolveAction( + client: GitLabClient, + project: ProjectRef, + ref: string, + change: FileChange, +): Promise { + const filePath = resolveRepoPath(project.contentRoot, change.path) + const exists = await fileExistsAtRef(client, project, filePath, ref) + + if (change.content === null) { + // Filter out deletes against non-existent files — GitLab returns + // 400 otherwise. The plan author asked us to "make this file not + // exist", which is already satisfied. + if (!exists) return null + return { action: 'delete', filePath } + } + + return { + action: exists ? 'update' : 'create', + filePath, + content: change.content, + encoding: 'text', + } +} + +async function branchHasRef( + client: GitLabClient, + project: ProjectRef, + branch: string, +): Promise { + try { + await client.Branches.show(project.projectId, branch) + return true + } catch (error) { + if (isNotFound(error)) return false + throw error + } +} + +async function fileExistsAtRef( + client: GitLabClient, + project: ProjectRef, + filePath: string, + ref: string, +): Promise { + try { + await client.RepositoryFiles.show(project.projectId, filePath, ref) + return true + } catch (error) { + if (isNotFound(error)) return false + throw error + } +} + +async function resolveDefaultBranch( + client: GitLabClient, + project: ProjectRef, +): Promise { + const p = await client.Projects.show(project.projectId) as { default_branch?: string } + return p.default_branch ?? 'main' +} + +function toIsoTimestamp(raw: unknown): string | null { + if (typeof raw !== 'string') return null + const date = new Date(raw) + if (Number.isNaN(date.getTime())) return null + return date.toISOString() +} + +function isNotFound(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + const err = error as { cause?: { response?: { status?: number } }, description?: unknown } + const status = err.cause?.response?.status + if (status === 404) return true + const description = typeof err.description === 'string' ? err.description : '' + return /not found/i.test(description) +} diff --git a/packages/mcp/src/providers/gitlab/branch-ops.ts b/packages/mcp/src/providers/gitlab/branch-ops.ts new file mode 100644 index 0000000..946ecae --- /dev/null +++ b/packages/mcp/src/providers/gitlab/branch-ops.ts @@ -0,0 +1,144 @@ +import type { Branch, FileDiff, MergeResult } from '../../core/contracts/index.js' +import type { GitLabClient } from './client.js' +import type { ProjectRef } from './types.js' + +/** + * Branch / merge / diff helpers backed by the GitLab REST API. + * + * Pure functions so they can be composed into `GitLabProvider` or used + * standalone. 404s collapse to empty-ish results where that matches + * the `RepoProvider` contract (missing branch prefix → `[]`). + * + * Merge semantics: GitLab does not expose a direct branch-to-branch + * merge endpoint. Every merge flows through a merge request, so + * `mergeBranch` opens an MR and immediately accepts it. The resulting + * `MergeResult` mirrors GitHub's `repos.merge` return shape — callers + * see the same `{ merged, sha, pullRequestUrl }` envelope either way, + * with the MR URL available for audit. + */ + +export async function getDefaultBranch( + client: GitLabClient, + project: ProjectRef, +): Promise { + const p = await client.Projects.show(project.projectId) as { default_branch?: string } + return p.default_branch ?? 'main' +} + +export async function listBranches( + client: GitLabClient, + project: ProjectRef, + prefix?: string, +): Promise { + // Gitbeaker's `all` supports a `search` string (substring match). For + // Contentrain's `cr/*` naming the substring case is equivalent to a + // prefix because the slug never appears anywhere except at the start. + // Server-side filter + client-side prefix enforcement keeps us + // correct even if the substring coincidence breaks someday. + const options: Record = { + perPage: 100, + maxPages: 10, + } + if (prefix) options.search = prefix + + const rawBranches = await client.Branches.all(project.projectId, options) + const branches = Array.isArray(rawBranches) ? rawBranches : [] + + return branches + .filter((b: { name: string }) => !prefix || b.name.startsWith(prefix)) + .map((b: { name: string, commit: { id: string }, protected?: boolean }) => ({ + name: b.name, + sha: b.commit.id, + protected: b.protected ?? false, + })) +} + +export async function createBranch( + client: GitLabClient, + project: ProjectRef, + name: string, + fromRef: string, +): Promise { + await client.Branches.create(project.projectId, name, fromRef) +} + +export async function deleteBranch( + client: GitLabClient, + project: ProjectRef, + name: string, +): Promise { + await client.Branches.remove(project.projectId, name) +} + +export async function getBranchDiff( + client: GitLabClient, + project: ProjectRef, + branch: string, + base: string, +): Promise { + const response = await client.Repositories.compare( + project.projectId, + base, + branch, + { straight: false }, + ) + const diffs = Array.isArray(response.diffs) ? response.diffs : [] + return diffs.map((d: { new_path: string, old_path: string, new_file?: boolean, deleted_file?: boolean }) => ({ + path: d.new_file ? d.new_path : d.old_path, + status: d.deleted_file ? 'removed' : d.new_file ? 'added' : 'modified', + before: null, + after: null, + })) +} + +export async function mergeBranch( + client: GitLabClient, + project: ProjectRef, + branch: string, + into: string, +): Promise { + // 1. Open MR — GitLab rejects create when source === target or when + // an MR is already open for this pair. Let the error propagate in + // those cases; the caller retries or surfaces the message. + const mr = await client.MergeRequests.create( + project.projectId, + branch, + into, + `[contentrain] merge ${branch} → ${into}`, + { removeSourceBranch: false }, + ) + + // 2. Accept the MR immediately. `shouldRemoveSourceBranch: false` + // preserves the feature branch so audit/retry logic still has + // access to it; Studio's cleanup flow deletes it separately. + const accepted = await client.MergeRequests.accept( + project.projectId, + (mr as { iid: number }).iid, + { shouldRemoveSourceBranch: false, squash: false }, + ) + + const mergeSha = (accepted as { merge_commit_sha?: string | null, sha?: string | null }).merge_commit_sha + ?? (accepted as { sha?: string | null }).sha + ?? null + const webUrl = (mr as { web_url?: string }).web_url ?? null + + return { merged: mergeSha !== null, sha: mergeSha, pullRequestUrl: webUrl } +} + +export async function isMerged( + client: GitLabClient, + project: ProjectRef, + branch: string, + into: string, +): Promise { + // compare(from=into, to=branch) — commits list is empty when branch + // is fully contained in into (i.e. already merged). + const response = await client.Repositories.compare( + project.projectId, + into, + branch, + { straight: false }, + ) + const commits = Array.isArray(response.commits) ? response.commits : [] + return commits.length === 0 +} diff --git a/packages/mcp/src/providers/gitlab/capabilities.ts b/packages/mcp/src/providers/gitlab/capabilities.ts new file mode 100644 index 0000000..7c2613b --- /dev/null +++ b/packages/mcp/src/providers/gitlab/capabilities.ts @@ -0,0 +1,21 @@ +import type { ProviderCapabilities } from '../../core/contracts/index.js' + +/** + * Capability set for GitLabProvider. + * + * GitLab over REST has no working tree, so local worktree features, + * source-file access and AST scans are unavailable. Push, merge and + * branch-protection detection all work over the API. GitLab enforces + * merges through merge requests, so `pullRequestFallback` is `true` — + * `mergeBranch` opens an MR and auto-accepts it to match GitHub's + * `repos.merge` semantics while still leaving an audit trail. + */ +export const GITLAB_CAPABILITIES: ProviderCapabilities = { + localWorktree: false, + sourceRead: false, + sourceWrite: false, + pushRemote: true, + branchProtection: true, + pullRequestFallback: true, + astScan: false, +} diff --git a/packages/mcp/src/providers/gitlab/client.ts b/packages/mcp/src/providers/gitlab/client.ts new file mode 100644 index 0000000..bf798fe --- /dev/null +++ b/packages/mcp/src/providers/gitlab/client.ts @@ -0,0 +1,12 @@ +import type { Gitlab } from '@gitbeaker/rest' + +/** + * Type alias for a `Gitlab` instance from `@gitbeaker/rest`. + * + * Provider code operates against this narrow surface so callers can + * pass a fully constructed gitbeaker client, a carefully faked one for + * unit tests, or an instance created by {@link createGitLabClient} in + * `factory.ts`. The import is type-only so the runtime dependency + * stays a pure optional peer. + */ +export type GitLabClient = InstanceType diff --git a/packages/mcp/src/providers/gitlab/factory.ts b/packages/mcp/src/providers/gitlab/factory.ts new file mode 100644 index 0000000..4704ec7 --- /dev/null +++ b/packages/mcp/src/providers/gitlab/factory.ts @@ -0,0 +1,63 @@ +import type { GitLabClient } from './client.js' +import { GitLabProvider } from './provider.js' +import type { GitLabAuth, ProjectRef } from './types.js' + +/** + * Create a gitbeaker-backed `GitLabClient` from an auth configuration. + * + * The `@gitbeaker/rest` module is imported dynamically so it stays a + * pure optional peer dependency — self-hosted MCP on stdio runs fine + * without it. If the module is not installed, the import throws with + * a helpful hint pointing the operator at the peer dependency. + * + * Supported auth types: `pat`, `oauth`, `job`. All three are thin + * wrappers over gitbeaker's `token`, `oauthToken`, and `jobToken` + * constructor options. + */ +export async function createGitLabClient( + auth: GitLabAuth, + host?: string, +): Promise { + let GitlabCtor: typeof import('@gitbeaker/rest').Gitlab + try { + ({ Gitlab: GitlabCtor } = await import('@gitbeaker/rest')) + } catch (error) { + throw new Error( + '@gitbeaker/rest is required for the GitLabProvider but could not be loaded. ' + + 'Install it as a peer dependency: pnpm add @gitbeaker/rest.', + { cause: error }, + ) + } + + const config: Record = host ? { host } : {} + switch (auth.type) { + case 'pat': + config.token = auth.token + break + case 'oauth': + config.oauthToken = auth.oauthToken + break + case 'job': + config.jobToken = auth.jobToken + break + default: { + const { type } = auth as { type: string } + throw new Error(`Unsupported GitLab auth type: "${type}"`) + } + } + + return new GitlabCtor(config) as GitLabClient +} + +/** + * Factory for the full provider — instantiates a gitbeaker client and + * wraps it in a `GitLabProvider`. Consumers who already hold a + * gitbeaker instance (HTTP server injecting shared clients, tests, + * etc.) should instantiate `GitLabProvider` directly instead. + */ +export async function createGitLabProvider( + opts: { auth: GitLabAuth, project: ProjectRef }, +): Promise { + const client = await createGitLabClient(opts.auth, opts.project.host) + return new GitLabProvider(client, opts.project) +} diff --git a/packages/mcp/src/providers/gitlab/index.ts b/packages/mcp/src/providers/gitlab/index.ts new file mode 100644 index 0000000..63baa0e --- /dev/null +++ b/packages/mcp/src/providers/gitlab/index.ts @@ -0,0 +1,7 @@ +export { GITLAB_CAPABILITIES } from './capabilities.js' +export type { GitLabClient } from './client.js' +export { createGitLabClient, createGitLabProvider } from './factory.js' +export { GitLabProvider } from './provider.js' +export { GitLabReader } from './reader.js' +export type { GitLabAuth, ProjectRef } from './types.js' +export { DEFAULT_GITLAB_AUTHOR } from './types.js' diff --git a/packages/mcp/src/providers/gitlab/paths.ts b/packages/mcp/src/providers/gitlab/paths.ts new file mode 100644 index 0000000..37d15fb --- /dev/null +++ b/packages/mcp/src/providers/gitlab/paths.ts @@ -0,0 +1,22 @@ +/** + * Normalise an optional contentRoot — strip leading/trailing slashes, + * treat `''`, `/` and `undefined` as "no prefix". Mirrors the GitHub + * provider helper; kept in this package so the GitLab implementation + * is self-contained. + */ +export function normaliseContentRoot(raw?: string): string { + if (!raw || raw === '/' || raw === '') return '' + return raw.replace(/^\/+|\/+$/g, '') +} + +/** + * Resolve a content-root-relative path to a repo-relative path. Paths + * always use forward slashes and never lead with `/`, because that is + * what the GitLab REST API consumes for `file_path` and `path` query + * parameters. + */ +export function resolveRepoPath(contentRoot: string | undefined, relativePath: string): string { + const prefix = normaliseContentRoot(contentRoot) + const cleanPath = relativePath.replace(/^\/+/, '') + return prefix ? `${prefix}/${cleanPath}` : cleanPath +} diff --git a/packages/mcp/src/providers/gitlab/provider.ts b/packages/mcp/src/providers/gitlab/provider.ts new file mode 100644 index 0000000..ed32c38 --- /dev/null +++ b/packages/mcp/src/providers/gitlab/provider.ts @@ -0,0 +1,87 @@ +import type { + ApplyPlanInput, + Branch, + Commit, + FileDiff, + MergeResult, + ProviderCapabilities, + RepoProvider, +} from '../../core/contracts/index.js' +import { applyPlanToGitLab } from './apply-plan.js' +import { + createBranch as createBranchOp, + deleteBranch as deleteBranchOp, + getBranchDiff as getBranchDiffOp, + getDefaultBranch as getDefaultBranchOp, + isMerged as isMergedOp, + listBranches as listBranchesOp, + mergeBranch as mergeBranchOp, +} from './branch-ops.js' +import { GITLAB_CAPABILITIES } from './capabilities.js' +import type { GitLabClient } from './client.js' +import { GitLabReader } from './reader.js' +import type { ProjectRef } from './types.js' + +/** + * GitLabProvider — `RepoProvider` backed by the gitbeaker-driven GitLab + * REST API. + * + * Transport-agnostic: the provider only talks to a `GitLabClient` + * (a `@gitbeaker/rest` `Gitlab` instance). `createGitLabProvider` in + * `factory.ts` wraps the dynamic import so consumers never touch + * `@gitbeaker/rest` directly unless they want to. + * + * Capability gaps versus `GitHubProvider`: none — both providers + * expose the same set. GitLab's merge flow routes through an MR under + * the hood, but `mergeBranch` presents the same `MergeResult` shape. + */ +export class GitLabProvider implements RepoProvider { + readonly capabilities: ProviderCapabilities = GITLAB_CAPABILITIES + private readonly reader: GitLabReader + + constructor( + private readonly client: GitLabClient, + public readonly project: ProjectRef, + ) { + this.reader = new GitLabReader(client, project) + } + + readFile(path: string, ref?: string): Promise { + return this.reader.readFile(path, ref) + } + listDirectory(path: string, ref?: string): Promise { + return this.reader.listDirectory(path, ref) + } + fileExists(path: string, ref?: string): Promise { + return this.reader.fileExists(path, ref) + } + + applyPlan(input: ApplyPlanInput): Promise { + return applyPlanToGitLab(this.client, this.project, input) + } + + listBranches(prefix?: string): Promise { + return listBranchesOp(this.client, this.project, prefix) + } + async createBranch(name: string, fromRef?: string): Promise { + const resolved = fromRef ?? await getDefaultBranchOp(this.client, this.project) + await createBranchOp(this.client, this.project, name, resolved) + } + deleteBranch(name: string): Promise { + return deleteBranchOp(this.client, this.project, name) + } + async getBranchDiff(branch: string, base?: string): Promise { + const resolved = base ?? await getDefaultBranchOp(this.client, this.project) + return getBranchDiffOp(this.client, this.project, branch, resolved) + } + mergeBranch(branch: string, into: string): Promise { + return mergeBranchOp(this.client, this.project, branch, into) + } + async isMerged(branch: string, into?: string): Promise { + const resolved = into ?? await getDefaultBranchOp(this.client, this.project) + return isMergedOp(this.client, this.project, branch, resolved) + } + getDefaultBranch(): Promise { + return getDefaultBranchOp(this.client, this.project) + } +} diff --git a/packages/mcp/src/providers/gitlab/reader.ts b/packages/mcp/src/providers/gitlab/reader.ts new file mode 100644 index 0000000..004b106 --- /dev/null +++ b/packages/mcp/src/providers/gitlab/reader.ts @@ -0,0 +1,110 @@ +import type { RepoReader } from '../../core/contracts/index.js' +import type { GitLabClient } from './client.js' +import { resolveRepoPath } from './paths.js' +import type { ProjectRef } from './types.js' + +/** + * GitLabReader — `RepoReader` backed by the GitLab REST API. + * + * File reads go through `RepositoryFiles.showRaw`, which returns the + * file content as UTF-8 text (or a `Blob` on browser/edge runtimes we + * decode with `.text()`). Directory listings go through + * `Repositories.allRepositoryTrees`. `fileExists` tries the file + * endpoint first and falls back to a tree listing so directories + * resolve to `true` as well — matching LocalReader / GitHubReader. + * + * `ref` is forwarded verbatim and may be a branch name, tag name or + * commit SHA. Callers should always pass the explicit `contentrain` + * tracking branch; GitLab's default resolution is the project's + * default branch, which is usually wrong for Contentrain flows. + */ +export class GitLabReader implements RepoReader { + constructor( + private readonly client: GitLabClient, + private readonly project: ProjectRef, + ) {} + + async readFile(path: string, ref?: string): Promise { + const repoPath = resolveRepoPath(this.project.contentRoot, path) + const resolvedRef = ref ?? await this.resolveDefaultRef() + const raw = await this.client.RepositoryFiles.showRaw( + this.project.projectId, + repoPath, + resolvedRef, + ) + if (typeof raw === 'string') return raw + // Browser / edge runtimes return a Blob; decode as UTF-8. + return (raw as Blob).text() + } + + async listDirectory(path: string, ref?: string): Promise { + const repoPath = resolveRepoPath(this.project.contentRoot, path) + const resolvedRef = ref ?? await this.resolveDefaultRef() + try { + const entries = await this.client.Repositories.allRepositoryTrees( + this.project.projectId, + { + path: repoPath, + ref: resolvedRef, + perPage: 100, + recursive: false, + }, + ) + return Array.isArray(entries) ? entries.map(e => e.name) : [] + } catch (error) { + if (isNotFound(error)) return [] + throw error + } + } + + async fileExists(path: string, ref?: string): Promise { + const repoPath = resolveRepoPath(this.project.contentRoot, path) + const resolvedRef = ref ?? await this.resolveDefaultRef() + + // 1. Try as a file — cheap and most common case for Contentrain. + try { + await this.client.RepositoryFiles.show( + this.project.projectId, + repoPath, + resolvedRef, + ) + return true + } catch (error) { + if (!isNotFound(error)) throw error + } + + // 2. Fall back to a tree listing — directories and empty dirs show + // up here. Any non-404 result means the path resolves. + try { + const entries = await this.client.Repositories.allRepositoryTrees( + this.project.projectId, + { path: repoPath, ref: resolvedRef, perPage: 1 }, + ) + return Array.isArray(entries) && entries.length > 0 + } catch (error) { + if (isNotFound(error)) return false + throw error + } + } + + private async resolveDefaultRef(): Promise { + const project = await this.client.Projects.show(this.project.projectId) as { default_branch?: string } + return project.default_branch ?? 'main' + } +} + +/** + * Gitbeaker surfaces HTTP errors as a plain `Error` whose `.cause` + * includes `response.status`. 404 semantics are important enough to + * justify this dedicated check — we collapse them into "missing" + * instead of bubbling up. + */ +function isNotFound(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + const err = error as { cause?: { response?: { status?: number } }, description?: unknown } + const status = err.cause?.response?.status + if (status === 404) return true + // Fallback — some gitbeaker versions expose `description` with "Not Found". + const description = typeof err.description === 'string' ? err.description : '' + return /not found/i.test(description) +} diff --git a/packages/mcp/src/providers/gitlab/types.ts b/packages/mcp/src/providers/gitlab/types.ts new file mode 100644 index 0000000..36a9964 --- /dev/null +++ b/packages/mcp/src/providers/gitlab/types.ts @@ -0,0 +1,43 @@ +import type { CommitAuthor } from '../../core/contracts/index.js' + +/** + * A reference to a GitLab project. + * + * `projectId` accepts the numeric project ID or a URL-encoded path + * (`namespace/project`). Gitbeaker URL-encodes string paths internally, + * so either form is fine. + * + * `contentRoot` is the repo-relative directory prefix where Contentrain + * content lives. For a flat content repo it stays `''`; for a monorepo + * where Contentrain sits under `apps/web/.contentrain/` it holds that + * prefix. All reader/writer paths are joined against it. + * + * `host` points at a self-hosted GitLab instance. Leave undefined to + * use `https://gitlab.com` (gitbeaker's default). + */ +export interface ProjectRef { + projectId: string | number + contentRoot?: string + host?: string +} + +/** + * Authentication options for the GitLab provider. + * + * - `pat` — personal access token with the `api` scope. Simplest for + * self-hosted MCP or CI runners. + * - `oauth` — OAuth2 token. Used when the runner is driven by a GitLab + * OAuth flow. + * - `job` — CI job token (`CI_JOB_TOKEN`). Scoped to the running + * pipeline; useful for pipeline-driven content updates. + */ +export type GitLabAuth = + | { type: 'pat', token: string } + | { type: 'oauth', oauthToken: string } + | { type: 'job', jobToken: string } + +/** Default author used when a call does not provide one. */ +export const DEFAULT_GITLAB_AUTHOR: CommitAuthor = { + name: 'Contentrain', + email: 'mcp@contentrain.io', +} diff --git a/packages/mcp/tests/providers/gitlab/apply-plan.test.ts b/packages/mcp/tests/providers/gitlab/apply-plan.test.ts new file mode 100644 index 0000000..2c9089d --- /dev/null +++ b/packages/mcp/tests/providers/gitlab/apply-plan.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it, vi } from 'vitest' +import type { GitLabClient } from '../../../src/providers/gitlab/client.js' +import { applyPlanToGitLab } from '../../../src/providers/gitlab/apply-plan.js' + +/** + * applyPlanToGitLab unit tests — drive a mocked gitbeaker client and + * assert the exact Commits.create payload plus the returned Commit + * envelope. + */ + +interface Mocks { + branchShow?: ReturnType + projectShow?: ReturnType + fileShow?: ReturnType + commitsCreate?: ReturnType +} + +function mockClient(overrides: Mocks = {}): GitLabClient { + return { + Branches: { + show: overrides.branchShow ?? vi.fn().mockRejectedValue(notFound()), + }, + Projects: { + show: overrides.projectShow ?? vi.fn().mockResolvedValue({ default_branch: 'main' }), + }, + RepositoryFiles: { + show: overrides.fileShow ?? vi.fn().mockRejectedValue(notFound()), + }, + Commits: { + create: overrides.commitsCreate ?? vi.fn().mockResolvedValue({ + id: 'new-commit-sha', + message: 'test', + author_name: 'Contentrain', + author_email: 'mcp@contentrain.io', + created_at: '2026-04-17T12:00:00Z', + }), + }, + } as unknown as GitLabClient +} + +function notFound(): Error { + return Object.assign(new Error('Not Found'), { cause: { response: { status: 404 } } }) +} + +describe('applyPlanToGitLab', () => { + it('commits all changes in one Commits.create call on a new branch', async () => { + const commitsCreate = vi.fn().mockResolvedValue({ + id: 'sha-1', + message: '[contentrain] test', + author_name: 'Contentrain', + author_email: 'mcp@contentrain.io', + created_at: '2026-04-17T12:00:00Z', + }) + const client = mockClient({ commitsCreate }) + + const commit = await applyPlanToGitLab(client, { projectId: 'o/r' }, { + branch: 'cr/content/blog/1234', + base: 'contentrain', + message: '[contentrain] test', + author: { name: 'Contentrain', email: 'mcp@contentrain.io' }, + changes: [ + { path: '.contentrain/content/m/blog/en.json', content: '{"a":1}' }, + { path: '.contentrain/meta/blog/en.json', content: '{}' }, + ], + }) + + expect(commit.sha).toBe('sha-1') + expect(commitsCreate).toHaveBeenCalledTimes(1) + const [projectId, branch, message, actions, options] = commitsCreate.mock.calls[0]! + expect(projectId).toBe('o/r') + expect(branch).toBe('cr/content/blog/1234') + expect(message).toBe('[contentrain] test') + expect(actions).toEqual([ + { + action: 'create', + filePath: '.contentrain/content/m/blog/en.json', + content: '{"a":1}', + encoding: 'text', + }, + { + action: 'create', + filePath: '.contentrain/meta/blog/en.json', + content: '{}', + encoding: 'text', + }, + ]) + expect(options).toEqual({ + authorName: 'Contentrain', + authorEmail: 'mcp@contentrain.io', + startBranch: 'contentrain', + }) + }) + + it('omits startBranch when the branch already exists and uses update for existing files', async () => { + const branchShow = vi.fn().mockResolvedValue({ name: 'cr/content/blog/1234' }) + // First change targets an existing file, second a fresh one. + const fileShow = vi.fn() + .mockResolvedValueOnce({ file_path: '.contentrain/content/m/blog/en.json' }) + .mockRejectedValueOnce(notFound()) + const commitsCreate = vi.fn().mockResolvedValue({ + id: 'sha-2', + message: '[contentrain] update', + author_name: 'Contentrain', + author_email: 'mcp@contentrain.io', + created_at: '2026-04-17T12:00:00Z', + }) + + const client = mockClient({ branchShow, fileShow, commitsCreate }) + await applyPlanToGitLab(client, { projectId: 7 }, { + branch: 'cr/content/blog/1234', + message: '[contentrain] update', + author: { name: 'Contentrain', email: 'mcp@contentrain.io' }, + changes: [ + { path: '.contentrain/content/m/blog/en.json', content: '{"a":2}' }, + { path: '.contentrain/meta/blog/en.json', content: '{}' }, + ], + }) + + const [, , , actions, options] = commitsCreate.mock.calls[0]! + expect(actions[0].action).toBe('update') + expect(actions[1].action).toBe('create') + expect(options).not.toHaveProperty('startBranch') + }) + + it('emits delete actions for non-null→null changes and filters absent-file deletes', async () => { + const branchShow = vi.fn().mockResolvedValue({ name: 'cr/content/blog/1234' }) + const fileShow = vi.fn() + .mockResolvedValueOnce({ file_path: 'exists.json' }) // exists, will emit delete + .mockRejectedValueOnce(notFound()) // absent, filtered out + .mockResolvedValueOnce({ file_path: 'keep.json' }) // exists, update + const commitsCreate = vi.fn().mockResolvedValue({ + id: 'sha-3', + message: 'mix', + author_name: 'Contentrain', + author_email: 'mcp@contentrain.io', + created_at: '2026-04-17T12:00:00Z', + }) + const client = mockClient({ branchShow, fileShow, commitsCreate }) + + await applyPlanToGitLab(client, { projectId: 'o/r' }, { + branch: 'cr/content/blog/1234', + message: 'mix', + author: { name: 'Contentrain', email: 'mcp@contentrain.io' }, + changes: [ + { path: 'exists.json', content: null }, + { path: 'absent.json', content: null }, + { path: 'keep.json', content: '{"k":1}' }, + ], + }) + + const [, , , actions] = commitsCreate.mock.calls[0]! + expect(actions).toHaveLength(2) + expect(actions[0]).toEqual({ action: 'delete', filePath: 'exists.json' }) + expect(actions[1]).toEqual({ + action: 'update', + filePath: 'keep.json', + content: '{"k":1}', + encoding: 'text', + }) + }) + + it('applies the contentRoot prefix to every action path', async () => { + const commitsCreate = vi.fn().mockResolvedValue({ + id: 'sha-4', + message: 'root', + author_name: 'Contentrain', + author_email: 'mcp@contentrain.io', + created_at: '2026-04-17T12:00:00Z', + }) + const client = mockClient({ commitsCreate }) + + await applyPlanToGitLab(client, { projectId: 'o/r', contentRoot: 'apps/web' }, { + branch: 'new', + message: 'root', + author: { name: 'Contentrain', email: 'mcp@contentrain.io' }, + changes: [ + { path: '.contentrain/context.json', content: '{}' }, + ], + }) + + const [, , , actions] = commitsCreate.mock.calls[0]! + expect(actions[0].filePath).toBe('apps/web/.contentrain/context.json') + }) + + it('resolves the project default branch when input.base is absent', async () => { + const projectShow = vi.fn().mockResolvedValue({ default_branch: 'trunk' }) + const commitsCreate = vi.fn().mockResolvedValue({ + id: 'sha-5', + message: 'default', + author_name: 'Contentrain', + author_email: 'mcp@contentrain.io', + created_at: '2026-04-17T12:00:00Z', + }) + const client = mockClient({ projectShow, commitsCreate }) + + await applyPlanToGitLab(client, { projectId: 'o/r' }, { + branch: 'new', + message: 'default', + author: { name: 'Contentrain', email: 'mcp@contentrain.io' }, + changes: [{ path: 'x.json', content: '{}' }], + }) + + const [, , , , options] = commitsCreate.mock.calls[0]! + expect(options.startBranch).toBe('trunk') + }) + + it('throws when the plan reduces to zero actions', async () => { + const branchShow = vi.fn().mockResolvedValue({ name: 'exists' }) + const fileShow = vi.fn().mockRejectedValue(notFound()) // all deletes would be no-ops + const client = mockClient({ branchShow, fileShow }) + + await expect( + applyPlanToGitLab(client, { projectId: 'o/r' }, { + branch: 'exists', + message: 'empty', + author: { name: 'Contentrain', email: 'mcp@contentrain.io' }, + changes: [{ path: 'gone.json', content: null }], + }), + ).rejects.toThrow(/no applicable actions/) + }) + + it('returns a normalised Commit envelope from the GitLab response', async () => { + const commitsCreate = vi.fn().mockResolvedValue({ + id: 'sha-envelope', + message: 'resp msg', + author_name: 'Gitbeaker User', + author_email: 'user@example.com', + created_at: '2026-04-17T13:00:00Z', + }) + const client = mockClient({ commitsCreate }) + + const commit = await applyPlanToGitLab(client, { projectId: 'o/r' }, { + branch: 'new', + message: 'client msg', + author: { name: 'Contentrain', email: 'mcp@contentrain.io' }, + changes: [{ path: 'x.json', content: '{}' }], + }) + + expect(commit).toEqual({ + sha: 'sha-envelope', + message: 'resp msg', + author: { name: 'Gitbeaker User', email: 'user@example.com' }, + timestamp: '2026-04-17T13:00:00.000Z', + }) + }) +}) diff --git a/packages/mcp/tests/providers/gitlab/branch-ops.test.ts b/packages/mcp/tests/providers/gitlab/branch-ops.test.ts new file mode 100644 index 0000000..1bade81 --- /dev/null +++ b/packages/mcp/tests/providers/gitlab/branch-ops.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from 'vitest' +import type { GitLabClient } from '../../../src/providers/gitlab/client.js' +import { + createBranch, + deleteBranch, + getBranchDiff, + getDefaultBranch, + isMerged, + listBranches, + mergeBranch, +} from '../../../src/providers/gitlab/branch-ops.js' + +interface Mocks { + branchAll?: ReturnType + branchCreate?: ReturnType + branchRemove?: ReturnType + repoCompare?: ReturnType + projectShow?: ReturnType + mrCreate?: ReturnType + mrAccept?: ReturnType +} + +function mockClient(overrides: Mocks = {}): GitLabClient { + return { + Branches: { + all: overrides.branchAll ?? vi.fn().mockResolvedValue([]), + create: overrides.branchCreate ?? vi.fn(), + remove: overrides.branchRemove ?? vi.fn(), + show: vi.fn(), + }, + Repositories: { + compare: overrides.repoCompare ?? vi.fn(), + }, + Projects: { + show: overrides.projectShow ?? vi.fn().mockResolvedValue({ default_branch: 'main' }), + }, + MergeRequests: { + create: overrides.mrCreate ?? vi.fn(), + accept: overrides.mrAccept ?? vi.fn(), + }, + } as unknown as GitLabClient +} + +describe('getDefaultBranch', () => { + it('returns the project default branch', async () => { + const projectShow = vi.fn().mockResolvedValue({ default_branch: 'trunk' }) + const client = mockClient({ projectShow }) + expect(await getDefaultBranch(client, { projectId: 'o/r' })).toBe('trunk') + expect(projectShow).toHaveBeenCalledWith('o/r') + }) + + it('falls back to "main" when the API omits default_branch', async () => { + const projectShow = vi.fn().mockResolvedValue({}) + const client = mockClient({ projectShow }) + expect(await getDefaultBranch(client, { projectId: 'o/r' })).toBe('main') + }) +}) + +describe('listBranches', () => { + it('returns all branches when no prefix is given', async () => { + const branchAll = vi.fn().mockResolvedValue([ + { name: 'main', commit: { id: 'sha-main' }, protected: true }, + { name: 'cr/content/blog/a', commit: { id: 'sha-a' }, protected: false }, + ]) + const client = mockClient({ branchAll }) + + const branches = await listBranches(client, { projectId: 'o/r' }) + expect(branches).toEqual([ + { name: 'main', sha: 'sha-main', protected: true }, + { name: 'cr/content/blog/a', sha: 'sha-a', protected: false }, + ]) + expect(branchAll).toHaveBeenCalledWith('o/r', { perPage: 100, maxPages: 10 }) + }) + + it('filters to a prefix using both server search and client-side guard', async () => { + // Server returned a branch matching the substring but not the prefix; + // the client-side filter drops it. + const branchAll = vi.fn().mockResolvedValue([ + { name: 'cr/content/blog/a', commit: { id: 'sha-a' } }, + { name: 'feature/cr/docs', commit: { id: 'sha-b' } }, + ]) + const client = mockClient({ branchAll }) + + const branches = await listBranches(client, { projectId: 'o/r' }, 'cr/') + expect(branches.map(b => b.name)).toEqual(['cr/content/blog/a']) + expect(branchAll).toHaveBeenCalledWith('o/r', { + perPage: 100, + maxPages: 10, + search: 'cr/', + }) + }) +}) + +describe('createBranch', () => { + it('delegates to Branches.create with the source ref', async () => { + const branchCreate = vi.fn().mockResolvedValue({}) + const client = mockClient({ branchCreate }) + + await createBranch(client, { projectId: 9 }, 'cr/new', 'contentrain') + expect(branchCreate).toHaveBeenCalledWith(9, 'cr/new', 'contentrain') + }) +}) + +describe('deleteBranch', () => { + it('delegates to Branches.remove', async () => { + const branchRemove = vi.fn().mockResolvedValue({}) + const client = mockClient({ branchRemove }) + + await deleteBranch(client, { projectId: 'o/r' }, 'cr/old') + expect(branchRemove).toHaveBeenCalledWith('o/r', 'cr/old') + }) +}) + +describe('getBranchDiff', () => { + it('maps GitLab diffs to FileDiff[] with normalized status', async () => { + const repoCompare = vi.fn().mockResolvedValue({ + diffs: [ + { new_path: 'a.json', old_path: 'a.json', new_file: true }, + { new_path: 'b.json', old_path: 'b.json' }, // modified + { new_path: 'c.json', old_path: 'c.json', deleted_file: true }, + ], + }) + const client = mockClient({ repoCompare }) + + const diffs = await getBranchDiff(client, { projectId: 'o/r' }, 'feature', 'main') + expect(diffs).toEqual([ + { path: 'a.json', status: 'added', before: null, after: null }, + { path: 'b.json', status: 'modified', before: null, after: null }, + { path: 'c.json', status: 'removed', before: null, after: null }, + ]) + expect(repoCompare).toHaveBeenCalledWith('o/r', 'main', 'feature', { straight: false }) + }) + + it('returns [] when the compare response omits diffs', async () => { + const repoCompare = vi.fn().mockResolvedValue({}) + const client = mockClient({ repoCompare }) + expect(await getBranchDiff(client, { projectId: 'o/r' }, 'a', 'b')).toEqual([]) + }) +}) + +describe('mergeBranch', () => { + it('opens an MR and accepts it — returns merged: true with the merge SHA', async () => { + const mrCreate = vi.fn().mockResolvedValue({ + iid: 42, + web_url: 'https://gitlab.com/o/r/-/merge_requests/42', + }) + const mrAccept = vi.fn().mockResolvedValue({ + merge_commit_sha: 'merge-sha-1', + }) + const client = mockClient({ mrCreate, mrAccept }) + + const result = await mergeBranch(client, { projectId: 'o/r' }, 'cr/feat', 'contentrain') + + expect(mrCreate).toHaveBeenCalledWith( + 'o/r', + 'cr/feat', + 'contentrain', + '[contentrain] merge cr/feat → contentrain', + { removeSourceBranch: false }, + ) + expect(mrAccept).toHaveBeenCalledWith( + 'o/r', + 42, + { shouldRemoveSourceBranch: false, squash: false }, + ) + expect(result).toEqual({ + merged: true, + sha: 'merge-sha-1', + pullRequestUrl: 'https://gitlab.com/o/r/-/merge_requests/42', + }) + }) + + it('returns merged: false with the MR URL when no merge_commit_sha came back', async () => { + const mrCreate = vi.fn().mockResolvedValue({ + iid: 7, + web_url: 'https://gitlab.com/o/r/-/merge_requests/7', + }) + const mrAccept = vi.fn().mockResolvedValue({ merge_commit_sha: null, sha: null }) + const client = mockClient({ mrCreate, mrAccept }) + + const result = await mergeBranch(client, { projectId: 'o/r' }, 'cr/feat', 'contentrain') + expect(result).toEqual({ + merged: false, + sha: null, + pullRequestUrl: 'https://gitlab.com/o/r/-/merge_requests/7', + }) + }) +}) + +describe('isMerged', () => { + it('returns true when compare yields no commits', async () => { + const repoCompare = vi.fn().mockResolvedValue({ commits: [] }) + const client = mockClient({ repoCompare }) + expect(await isMerged(client, { projectId: 'o/r' }, 'cr/feat', 'contentrain')).toBe(true) + expect(repoCompare).toHaveBeenCalledWith('o/r', 'contentrain', 'cr/feat', { straight: false }) + }) + + it('returns false when the branch is ahead of the target', async () => { + const repoCompare = vi.fn().mockResolvedValue({ commits: [{ id: 'c1' }] }) + const client = mockClient({ repoCompare }) + expect(await isMerged(client, { projectId: 'o/r' }, 'cr/feat', 'contentrain')).toBe(false) + }) +}) diff --git a/packages/mcp/tests/providers/gitlab/reader.test.ts b/packages/mcp/tests/providers/gitlab/reader.test.ts new file mode 100644 index 0000000..bb38654 --- /dev/null +++ b/packages/mcp/tests/providers/gitlab/reader.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it, vi } from 'vitest' +import type { GitLabClient } from '../../../src/providers/gitlab/client.js' +import { GitLabReader } from '../../../src/providers/gitlab/reader.js' + +/** + * GitLabReader unit tests — exercise the read surface against a + * minimal mocked gitbeaker client. The mock implements only the + * methods the reader actually calls; anything else should be + * unreachable. + */ + +interface Mocks { + show?: ReturnType + showRaw?: ReturnType + allRepositoryTrees?: ReturnType + projectShow?: ReturnType +} + +function mockClient(overrides: Mocks = {}): GitLabClient { + return { + RepositoryFiles: { + show: overrides.show ?? vi.fn(), + showRaw: overrides.showRaw ?? vi.fn(), + }, + Repositories: { + allRepositoryTrees: overrides.allRepositoryTrees ?? vi.fn(), + }, + Projects: { + show: overrides.projectShow ?? vi.fn().mockResolvedValue({ default_branch: 'main' }), + }, + } as unknown as GitLabClient +} + +function notFound(): Error { + return Object.assign(new Error('Not Found'), { cause: { response: { status: 404 } } }) +} + +describe('GitLabReader.readFile', () => { + it('returns the raw string content from showRaw', async () => { + const showRaw = vi.fn().mockResolvedValue('{"hello":"world"}') + const reader = new GitLabReader( + mockClient({ showRaw }), + { projectId: 'acme/site' }, + ) + + const content = await reader.readFile('.contentrain/config.json', 'contentrain') + + expect(content).toBe('{"hello":"world"}') + expect(showRaw).toHaveBeenCalledWith('acme/site', '.contentrain/config.json', 'contentrain') + }) + + it('decodes a Blob response to text', async () => { + const showRaw = vi.fn().mockResolvedValue(new Blob(['blob content'], { type: 'text/plain' })) + const reader = new GitLabReader( + mockClient({ showRaw }), + { projectId: 42 }, + ) + + const content = await reader.readFile('any.json', 'contentrain') + expect(content).toBe('blob content') + }) + + it('prefixes paths with contentRoot', async () => { + const showRaw = vi.fn().mockResolvedValue('x') + const reader = new GitLabReader( + mockClient({ showRaw }), + { projectId: 'group/proj', contentRoot: 'apps/web' }, + ) + + await reader.readFile('.contentrain/config.json', 'contentrain') + expect(showRaw).toHaveBeenCalledWith('group/proj', 'apps/web/.contentrain/config.json', 'contentrain') + }) + + it('resolves the default branch when no ref is provided', async () => { + const showRaw = vi.fn().mockResolvedValue('y') + const projectShow = vi.fn().mockResolvedValue({ default_branch: 'trunk' }) + const reader = new GitLabReader( + mockClient({ showRaw, projectShow }), + { projectId: 1 }, + ) + + await reader.readFile('file.json') + expect(projectShow).toHaveBeenCalledWith(1) + expect(showRaw).toHaveBeenCalledWith(1, 'file.json', 'trunk') + }) +}) + +describe('GitLabReader.listDirectory', () => { + it('returns entry names for a directory', async () => { + const allRepositoryTrees = vi.fn().mockResolvedValue([ + { name: 'en.json', type: 'blob', path: 'dir/en.json' }, + { name: 'tr.json', type: 'blob', path: 'dir/tr.json' }, + ]) + const reader = new GitLabReader( + mockClient({ allRepositoryTrees }), + { projectId: 'o/r' }, + ) + + const names = await reader.listDirectory('.contentrain/content/blog', 'contentrain') + expect(names).toEqual(['en.json', 'tr.json']) + expect(allRepositoryTrees).toHaveBeenCalledWith('o/r', { + path: '.contentrain/content/blog', + ref: 'contentrain', + perPage: 100, + recursive: false, + }) + }) + + it('returns [] on 404', async () => { + const allRepositoryTrees = vi.fn().mockRejectedValue(notFound()) + const reader = new GitLabReader( + mockClient({ allRepositoryTrees }), + { projectId: 'o/r' }, + ) + + expect(await reader.listDirectory('.contentrain/missing', 'contentrain')).toEqual([]) + }) + + it('propagates non-404 errors', async () => { + const allRepositoryTrees = vi.fn().mockRejectedValue( + Object.assign(new Error('Server Error'), { cause: { response: { status: 500 } } }), + ) + const reader = new GitLabReader( + mockClient({ allRepositoryTrees }), + { projectId: 'o/r' }, + ) + + await expect(reader.listDirectory('.contentrain/models', 'contentrain')) + .rejects.toThrow(/Server Error/) + }) +}) + +describe('GitLabReader.fileExists', () => { + it('returns true when show succeeds (file case)', async () => { + const show = vi.fn().mockResolvedValue({ file_path: 'a.json' }) + const reader = new GitLabReader(mockClient({ show }), { projectId: 'o/r' }) + expect(await reader.fileExists('a.json', 'contentrain')).toBe(true) + }) + + it('falls back to directory listing when the path is a directory (show 404)', async () => { + const show = vi.fn().mockRejectedValue(notFound()) + const allRepositoryTrees = vi.fn().mockResolvedValue([ + { name: 'en.json', type: 'blob' }, + ]) + const reader = new GitLabReader( + mockClient({ show, allRepositoryTrees }), + { projectId: 'o/r' }, + ) + expect(await reader.fileExists('dir', 'contentrain')).toBe(true) + expect(allRepositoryTrees).toHaveBeenCalled() + }) + + it('returns false when both show and tree lookup 404', async () => { + const show = vi.fn().mockRejectedValue(notFound()) + const allRepositoryTrees = vi.fn().mockRejectedValue(notFound()) + const reader = new GitLabReader( + mockClient({ show, allRepositoryTrees }), + { projectId: 'o/r' }, + ) + expect(await reader.fileExists('missing', 'contentrain')).toBe(false) + }) + + it('returns false when tree listing resolves empty', async () => { + const show = vi.fn().mockRejectedValue(notFound()) + const allRepositoryTrees = vi.fn().mockResolvedValue([]) + const reader = new GitLabReader( + mockClient({ show, allRepositoryTrees }), + { projectId: 'o/r' }, + ) + expect(await reader.fileExists('empty-dir', 'contentrain')).toBe(false) + }) + + it('propagates non-404 errors from show', async () => { + const show = vi.fn().mockRejectedValue( + Object.assign(new Error('Rate limited'), { cause: { response: { status: 429 } } }), + ) + const reader = new GitLabReader(mockClient({ show }), { projectId: 'o/r' }) + await expect(reader.fileExists('any.json', 'contentrain')).rejects.toThrow(/Rate limited/) + }) +}) diff --git a/packages/mcp/tests/server/http.test.ts b/packages/mcp/tests/server/http.test.ts index 869c853..06d57af 100644 --- a/packages/mcp/tests/server/http.test.ts +++ b/packages/mcp/tests/server/http.test.ts @@ -264,6 +264,122 @@ describe('startHttpMcpServer', () => { } }) + it('commits content_save through a GitLabProvider-like remote provider', async () => { + // Seed the in-memory GitLab with config + model read responses. + const models: Record = { + blog: { + id: 'blog', + name: 'Blog', + kind: 'collection', + domain: 'marketing', + i18n: true, + fields: { title: { type: 'string', required: true }, body: { type: 'text' } }, + }, + } + const config = { + version: 1, + stack: 'vue-nuxt', + workflow: 'review', + locales: { default: 'en', supported: ['en', 'tr'] }, + domains: ['marketing'], + repository: { provider: 'gitlab', owner: 'acme', name: 'site', default_branch: 'contentrain' }, + } + const filesOnHead: Record = { + '.contentrain/config.json': JSON.stringify(config), + '.contentrain/models/blog.json': JSON.stringify(models['blog']), + } + + // Capture the Commits.create payload for the content save. + let capturedCommitsCall: { projectId: string | number, branch: string, message: string, actions: unknown[], options: Record } | undefined + + const gitlabClient = { + RepositoryFiles: { + async show(_projectId: string | number, filePath: string, _ref: string) { + if (filesOnHead[filePath]) return { file_path: filePath } + const err = Object.assign(new Error('Not Found'), { cause: { response: { status: 404 } } }) + throw err + }, + async showRaw(_projectId: string | number, filePath: string, _ref: string) { + if (filesOnHead[filePath]) return filesOnHead[filePath]! + const err = Object.assign(new Error('Not Found'), { cause: { response: { status: 404 } } }) + throw err + }, + }, + Repositories: { + async allRepositoryTrees(_projectId: string | number, opts: { path?: string }) { + // Minimal tree: `.contentrain/models` contains blog.json; everything else empty. + if (opts.path === '.contentrain/models') return [{ name: 'blog.json', type: 'blob' }] + return [] + }, + }, + Projects: { + async show() { return { default_branch: 'contentrain' } }, + }, + Branches: { + async show() { + const err = Object.assign(new Error('Not Found'), { cause: { response: { status: 404 } } }) + throw err + }, + }, + Commits: { + async create(projectId: string | number, branch: string, message: string, actions: unknown[], options: Record) { + capturedCommitsCall = { projectId, branch, message, actions, options } + return { + id: 'gitlab-commit-sha', + message, + author_name: 'Contentrain', + author_email: 'mcp@contentrain.io', + created_at: '2026-04-17T12:00:00Z', + } + }, + }, + } + + const { GitLabProvider } = await import('../../src/providers/gitlab/index.js') + const provider = new GitLabProvider( + gitlabClient as unknown as import('../../src/providers/gitlab/client.js').GitLabClient, + { projectId: 'acme/site' }, + ) + + const { startHttpMcpServerWith } = await import('../../src/server/http/index.js') + const handle = await startHttpMcpServerWith({ provider, port: 0 }) + try { + const mcpClient = new Client({ name: 'test-http-client', version: '1.0.0' }) + const transport = new StreamableHTTPClientTransport(new URL(handle.url)) + await mcpClient.connect(transport) + + try { + const result = await mcpClient.callTool({ + name: 'contentrain_content_save', + arguments: { + model: 'blog', + entries: [{ id: 'abc123def456', locale: 'en', data: { title: 'Hello', body: 'World' } }], + }, + }) + const parsed = parseResult(result) + expect(parsed['status']).toBe('committed') + const git = parsed['git'] as Record + expect(git['action']).toBe('pending-review') + expect(git['commit']).toBe('gitlab-commit-sha') + expect((git['branch'] as string).startsWith('cr/content/blog')).toBe(true) + + // One Commits.create call with content + meta + context.json actions. + expect(capturedCommitsCall).toBeDefined() + const actions = capturedCommitsCall!.actions as Array<{ filePath: string, action: string }> + const paths = actions.map(a => a.filePath) + expect(paths).toContain('.contentrain/content/marketing/blog/en.json') + expect(paths).toContain('.contentrain/meta/blog/en.json') + expect(paths).toContain('.contentrain/context.json') + // startBranch is used because the feature branch does not exist yet. + expect(capturedCommitsCall!.options.startBranch).toBe('contentrain') + } finally { + await mcpClient.close() + } + } finally { + await handle.close() + } + }) + it('returns capability error for write tools when projectRoot is absent', async () => { const readOnlyProvider = { capabilities: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d77c60a..cf0497c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,6 +183,9 @@ importers: specifier: ^4.2.0 version: 4.2.20 devDependencies: + '@gitbeaker/rest': + specifier: ^43.8.0 + version: 43.8.0 '@octokit/rest': specifier: ^22.0.1 version: 22.0.1 @@ -936,6 +939,18 @@ packages: '@floating-ui/vue@1.1.11': resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==} + '@gitbeaker/core@43.8.0': + resolution: {integrity: sha512-H+LfKuf4dExBinb79c+CXViRBvTVQNf5BYLNSizm2SiqdED5JruhKX88payefleY0szp7G/mySlFSXPyGRH1dQ==} + engines: {node: '>=18.20.0'} + + '@gitbeaker/requester-utils@43.8.0': + resolution: {integrity: sha512-d/SiJdxijc+aH5ZBQOw83XLxNSXqsBZNm5k3nPu1EHxGxK0fajXmxdMl0/vNXbKRggnIquFCxURkrQSEzfjqxQ==} + engines: {node: '>=18.20.0'} + + '@gitbeaker/rest@43.8.0': + resolution: {integrity: sha512-xxqsNsUXaFang9b2e/NTIgqUeuUlifA2Opy1mOVqTDuJZZNIOTgUNyziwBJoleBhMC0XuvY3JNVMWthufcVjRw==} + engines: {node: '>=18.20.0'} + '@hono/node-server@1.19.11': resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} engines: {node: '>=18.14.1'} @@ -2677,6 +2692,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch-browser@2.2.6: + resolution: {integrity: sha512-0ypsOQt9D4e3hziV8O4elD9uN0z/jtUEfxVRtNaAAtXIyUx9m/SzlO020i8YNL2aL/E6blOvvHQcin6HZlFy/w==} + engines: {node: '>=8.6'} + picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -2745,6 +2764,9 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + rate-limiter-flexible@8.3.0: + resolution: {integrity: sha512-mzwlfipDLlRinPgELqVDJetke6Snq26nL565m8nLWXIcWgosYSeNRgqwh7ZrZ4MfYs8CNfmLvR5SBVz3rISQsQ==} + raw-body@3.0.2: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} @@ -3338,6 +3360,9 @@ packages: utf-8-validate: optional: true + xcase@2.0.1: + resolution: {integrity: sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw==} + zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: @@ -3948,6 +3973,24 @@ snapshots: - '@vue/composition-api' - vue + '@gitbeaker/core@43.8.0': + dependencies: + '@gitbeaker/requester-utils': 43.8.0 + qs: 6.15.0 + xcase: 2.0.1 + + '@gitbeaker/requester-utils@43.8.0': + dependencies: + picomatch-browser: 2.2.6 + qs: 6.15.0 + rate-limiter-flexible: 8.3.0 + xcase: 2.0.1 + + '@gitbeaker/rest@43.8.0': + dependencies: + '@gitbeaker/core': 43.8.0 + '@gitbeaker/requester-utils': 43.8.0 + '@hono/node-server@1.19.11(hono@4.12.7)': dependencies: hono: 4.12.7 @@ -5618,6 +5661,8 @@ snapshots: picocolors@1.1.1: {} + picomatch-browser@2.2.6: {} + picomatch@2.3.1: {} picomatch@4.0.3: {} @@ -5669,6 +5714,8 @@ snapshots: range-parser@1.2.1: {} + rate-limiter-flexible@8.3.0: {} + raw-body@3.0.2: dependencies: bytes: 3.1.2 @@ -6383,6 +6430,8 @@ snapshots: ws@8.19.0: {} + xcase@2.0.1: {} + zod-to-json-schema@3.25.1(zod@3.25.76): dependencies: zod: 3.25.76