diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index a0058f9..e1ad59f 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -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" ] diff --git a/src/db/prisma/migrations/20260808120000_add_git_provider/migration.sql b/src/db/prisma/migrations/20260808120000_add_git_provider/migration.sql new file mode 100644 index 0000000..2e74165 --- /dev/null +++ b/src/db/prisma/migrations/20260808120000_add_git_provider/migration.sql @@ -0,0 +1,40 @@ +-- AlterTable +ALTER TABLE "CodeRepo" ADD COLUMN "gitProviderId" TEXT, +ADD COLUMN "sourceFullName" TEXT, +ADD COLUMN "sourceProvider" TEXT; + +-- CreateTable +CREATE TABLE "GitProvider" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "type" TEXT NOT NULL DEFAULT 'gitea', + "url" TEXT NOT NULL, + "credSecretId" TEXT, + "credSecretKey" TEXT, + "ownerId" TEXT NOT NULL, + "version" INTEGER NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "GitProvider_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "GitProvider_name_key" ON "GitProvider"("name"); + +-- CreateIndex +CREATE INDEX "GitProvider_name_idx" ON "GitProvider"("name"); + +-- CreateIndex +CREATE INDEX "GitProvider_ownerId_idx" ON "GitProvider"("ownerId"); + +-- AddForeignKey +ALTER TABLE "CodeRepo" ADD CONSTRAINT "CodeRepo_gitProviderId_fkey" FOREIGN KEY ("gitProviderId") REFERENCES "GitProvider"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GitProvider" ADD CONSTRAINT "GitProvider_credSecretId_fkey" FOREIGN KEY ("credSecretId") REFERENCES "Secret"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GitProvider" ADD CONSTRAINT "GitProvider_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index bbe3c65..5222643 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -34,6 +34,7 @@ model User { ownedTasks Task[] ownedRepos CodeRepo[] ownedSessions AgentSession[] + gitProviders GitProvider[] @@index([email]) } @@ -174,10 +175,11 @@ model Secret { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - backend SecretBackend @relation(fields: [backendId], references: [id]) - llms Llm[] + backend SecretBackend @relation(fields: [backendId], references: [id]) + llms Llm[] // Agentic teams: git-provider credentials for CodeRepo. - codeRepos CodeRepo[] + codeRepos CodeRepo[] + gitProviders GitProvider[] @@index([name]) @@index([backendId]) @@ -1139,8 +1141,19 @@ model CodeRepo { defaultBranch String @default("main") // Relative path under the session workspace root. Defaults to `name`. path String @default("") - // Git provider, for PR/merge API calls. 'gitea' is the only driver in v1. + // Driver key for PR/merge API calls. Inherited from the GitProvider when the + // repo was imported from one. provider String @default("gitea") + // The GitProvider this repo came from, when it was imported rather than + // hand-added. Supplies the credential, so an imported repo needs none of its + // own; a hand-added repo leaves this null and carries its own credSecretId. + gitProviderId String? + // Denormalized provenance, mirroring McpServer.templateName/templateVersion: + // survives the provider being deleted, so "where did this repo come from" + // stays answerable after the forge is disconnected. + sourceProvider String? + // Owner/name on the forge, e.g. "michal/mcpctl". Null for hand-added repos. + sourceFullName String? // Secret holding the provider credential, resolved through SecretService at // session-provision time — never stored in plaintext on the row. credSecretId String? @@ -1150,10 +1163,13 @@ model CodeRepo { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - cred Secret? @relation(fields: [credSecretId], references: [id]) - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) - tasks Task[] + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + cred Secret? @relation(fields: [credSecretId], references: [id]) + // SetNull, not Cascade: disconnecting a forge must not delete the repos — + // and the tasks hanging off them — that were imported from it. + gitProvider GitProvider? @relation(fields: [gitProviderId], references: [id], onDelete: SetNull) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + tasks Task[] @@unique([name, projectId]) @@index([projectId]) @@ -1225,3 +1241,35 @@ enum SessionState { ended failed } + +// ── GitProvider (a configured git forge: Gitea today, others later) ── +// +// Registering the instance once means repos are discovered and imported rather +// than hand-typed, and credentials live in one place instead of on every repo. +// `type` selects the driver and is TEXT, not an enum, so adding github/gitlab +// is a non-migration change (same reasoning as ResourceProposal.resourceType). + +model GitProvider { + id String @id @default(cuid()) + name String @unique + description String @default("") + // Driver key: gitea | (future) github | gitlab + type String @default("gitea") + // Instance base URL, e.g. https://mysources.co.uk — never hardcoded anywhere. + url String + // API credential, by reference. Same shape as Llm.apiKeySecret*; the value is + // resolved through SecretService and never stored or returned here. + credSecretId String? + credSecretKey String? + ownerId String + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + cred Secret? @relation(fields: [credSecretId], references: [id]) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + repos CodeRepo[] + + @@index([name]) + @@index([ownerId]) +} diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 3d8a3c5..59ac06e 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -109,6 +109,8 @@ import { registerProposalRoutes } from './routes/proposals.js'; import { registerEpicRoutes } from './routes/epics.js'; import { registerEpicTaskRoutes } from './routes/epictasks.js'; import { registerTaskRoutes } from './routes/tasks.js'; +import { registerCodeRepoRoutes } from './routes/repos.js'; +import { registerGitProviderRoutes } from './routes/gitproviders.js'; import { registerSkillRoutes } from './routes/skills.js'; import { registerGitBackupRoutes } from './routes/git-backup.js'; import { PromptService } from './services/prompt.service.js'; @@ -119,10 +121,15 @@ import { ResourceProposalService } from './services/resource-proposal.service.js import { EpicRepository } from './repositories/epic.repository.js'; import { EpicTaskRepository } from './repositories/epic-task.repository.js'; import { TaskRepository } from './repositories/task.repository.js'; +import { CodeRepoRepository } from './repositories/code-repo.repository.js'; +import { GitProviderRepository } from './repositories/git-provider.repository.js'; import { SkillRepository } from './repositories/skill.repository.js'; import { EpicService } from './services/epic.service.js'; import { EpicTaskService } from './services/epic-task.service.js'; import { TaskService } from './services/task.service.js'; +import { CodeRepoService } from './services/code-repo.service.js'; +import { GitProviderService } from './services/git-provider.service.js'; +import { GitProviderRegistry } from './services/git/registry.js'; import { SkillService } from './services/skill.service.js'; import { GitBackupService } from './services/backup/git-backup.service.js'; import type { BackupKind } from './services/backup/yaml-serializer.js'; @@ -235,6 +242,8 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { 'epictasks': 'epictasks', // Work items. NOT inference-tasks — see the rbac-tasks-backfill bootstrap. 'tasks': 'tasks', + 'repos': 'repos', + 'gitproviders': 'gitproviders', }; const resource = resourceMap[segment]; @@ -446,6 +455,8 @@ async function main(): Promise { const epicRepo = new EpicRepository(prisma); const epicTaskRepo = new EpicTaskRepository(prisma); const workTaskRepo = new TaskRepository(prisma); + const codeRepoRepo = new CodeRepoRepository(prisma); + const gitProviderRepo = new GitProviderRepository(prisma); // SecretBackend bootstrap: ensure a `plaintext` default row exists and any // pre-existing `Secret` rows are pointed at it. Idempotent per run. @@ -465,6 +476,8 @@ async function main(): Promise { epics: epicRepo, epictasks: epicTaskRepo, tasks: workTaskRepo, + repos: codeRepoRepo, + gitproviders: gitProviderRepo, // Resolve user CUID → email so name-scoped `edit:users:` bindings // match on PUT /api/v1/users/:id/password (admin reset). users: { findById: async (id: string) => { @@ -574,6 +587,12 @@ async function main(): Promise { epicService.setRevisionService(resourceRevisionService); const epicTaskService = new EpicTaskService(epicTaskRepo, epicRepo); const workTaskService = new TaskService(workTaskRepo, epicTaskRepo); + const codeRepoService = new CodeRepoService(codeRepoRepo, projectRepo, secretRepo, secretService, gitProviderRepo); + // Forge drivers are resolved by type — nothing above this layer knows what a + // Gitea is, and the instance URL always comes from the GitProvider row. + const gitProviderService = new GitProviderService( + gitProviderRepo, codeRepoRepo, projectRepo, new GitProviderRegistry(), secretRepo, secretService, + ); const personalityRepo = new PersonalityRepository(prisma); const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo); const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo); @@ -785,6 +804,8 @@ async function main(): Promise { registerEpicRoutes(app, epicService, projectRepo); registerEpicTaskRoutes(app, epicTaskService, epicRepo, epicTaskRepo, projectRepo); registerTaskRoutes(app, workTaskService, epicTaskRepo); + registerCodeRepoRoutes(app, codeRepoService, projectRepo); + registerGitProviderRoutes(app, gitProviderService, projectRepo); // ── Git-based backup ── const gitBackup = new GitBackupService(prisma); diff --git a/src/mcpd/src/repositories/code-repo.repository.ts b/src/mcpd/src/repositories/code-repo.repository.ts new file mode 100644 index 0000000..f704ef6 --- /dev/null +++ b/src/mcpd/src/repositories/code-repo.repository.ts @@ -0,0 +1,92 @@ +import type { PrismaClient, CodeRepo } from '@prisma/client'; + +/** + * CodeRepo repository — git repositories attached to a project. + * + * Named CodeRepo rather than Repository so this file is not + * `repository.repository.ts` and so "repository" keeps meaning the + * data-access layer everywhere else in src/mcpd/src/repositories/. + */ + +export interface CodeRepoCreateInput { + name: string; + projectId: string; + url: string; + ownerId: string; + defaultBranch?: string; + path?: string; + provider?: string; + credSecretId?: string; + credSecretKey?: string; + /** Set when the repo was imported from a GitProvider rather than hand-added. */ + gitProviderId?: string; + /** Denormalized provenance; survives the provider being deleted. */ + sourceProvider?: string; + /** Owner-qualified name on the forge, e.g. "michal/mcpctl". */ + sourceFullName?: string; +} + +export interface CodeRepoUpdateInput { + url?: string; + defaultBranch?: string; + path?: string; + provider?: string; + credSecretId?: string | null; + credSecretKey?: string | null; +} + +export interface ICodeRepoRepository { + findAll(projectId?: string): Promise; + findById(id: string): Promise; + findByName(name: string): Promise; + findByNameAndProject(name: string, projectId: string): Promise; + create(data: CodeRepoCreateInput): Promise; + update(id: string, data: CodeRepoUpdateInput): Promise; + delete(id: string): Promise; +} + +const REPO_INCLUDE = { project: { select: { name: true } } } as const; + +export class CodeRepoRepository implements ICodeRepoRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(projectId?: string): Promise { + return this.prisma.codeRepo.findMany({ + ...(projectId !== undefined ? { where: { projectId } } : {}), + include: REPO_INCLUDE, + orderBy: { name: 'asc' }, + }); + } + + async findById(id: string): Promise { + return this.prisma.codeRepo.findUnique({ where: { id }, include: REPO_INCLUDE }); + } + + /** Names are unique per project; this is for the RBAC CUID→name resolver. */ + async findByName(name: string): Promise { + return this.prisma.codeRepo.findFirst({ where: { name }, include: REPO_INCLUDE }); + } + + async findByNameAndProject(name: string, projectId: string): Promise { + return this.prisma.codeRepo.findUnique({ + where: { name_projectId: { name, projectId } }, + include: REPO_INCLUDE, + }); + } + + async create(data: CodeRepoCreateInput): Promise { + return this.prisma.codeRepo.create({ data, include: REPO_INCLUDE }); + } + + async update(id: string, data: CodeRepoUpdateInput): Promise { + return this.prisma.codeRepo.update({ + where: { id }, + data: { ...data, version: { increment: 1 } }, + include: REPO_INCLUDE, + }); + } + + async delete(id: string): Promise { + await this.prisma.codeRepo.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/repositories/git-provider.repository.ts b/src/mcpd/src/repositories/git-provider.repository.ts new file mode 100644 index 0000000..0f0d13b --- /dev/null +++ b/src/mcpd/src/repositories/git-provider.repository.ts @@ -0,0 +1,61 @@ +import type { PrismaClient, GitProvider } from '@prisma/client'; + +/** GitProvider repository — one row per configured forge instance. */ + +export interface GitProviderCreateInput { + name: string; + url: string; + ownerId: string; + type?: string; + description?: string; + credSecretId?: string; + credSecretKey?: string; +} + +export interface GitProviderUpdateInput { + url?: string; + type?: string; + description?: string; + credSecretId?: string | null; + credSecretKey?: string | null; +} + +export interface IGitProviderRepository { + findAll(): Promise; + findById(id: string): Promise; + findByName(name: string): Promise; + create(data: GitProviderCreateInput): Promise; + update(id: string, data: GitProviderUpdateInput): Promise; + delete(id: string): Promise; +} + +export class GitProviderRepository implements IGitProviderRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(): Promise { + return this.prisma.gitProvider.findMany({ orderBy: { name: 'asc' } }); + } + + async findById(id: string): Promise { + return this.prisma.gitProvider.findUnique({ where: { id } }); + } + + async findByName(name: string): Promise { + return this.prisma.gitProvider.findUnique({ where: { name } }); + } + + async create(data: GitProviderCreateInput): Promise { + return this.prisma.gitProvider.create({ data }); + } + + async update(id: string, data: GitProviderUpdateInput): Promise { + return this.prisma.gitProvider.update({ + where: { id }, + data: { ...data, version: { increment: 1 } }, + }); + } + + async delete(id: string): Promise { + await this.prisma.gitProvider.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/routes/gitproviders.ts b/src/mcpd/src/routes/gitproviders.ts new file mode 100644 index 0000000..a8e5172 --- /dev/null +++ b/src/mcpd/src/routes/gitproviders.ts @@ -0,0 +1,77 @@ +import type { FastifyInstance } from 'fastify'; + +import type { GitProviderService } from '../services/git-provider.service.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; + +export function registerGitProviderRoutes( + app: FastifyInstance, + service: GitProviderService, + projectRepo: IProjectRepository, +): void { + // ── CRUD ── + // Reads return the credential REFERENCE only, never the token. + + app.get('/api/v1/gitproviders', async () => service.list()); + + app.get<{ Params: { id: string } }>('/api/v1/gitproviders/:id', async (request) => { + return service.get(request.params.id); + }); + + app.post('/api/v1/gitproviders', async (request, reply) => { + const provider = await service.create(request.body, request.userId ?? 'system'); + reply.code(201); + return provider; + }); + + app.put<{ Params: { id: string } }>('/api/v1/gitproviders/:id', async (request) => { + return service.update(request.params.id, request.body); + }); + + app.delete<{ Params: { id: string } }>('/api/v1/gitproviders/:id', async (request, reply) => { + await service.delete(request.params.id); + reply.code(204); + }); + + // ── Discovery ── + // Live view of what is on the forge. Nothing is stored; `imported` marks the + // rows the target project already has so re-importing is obvious. + + app.get<{ Params: { id: string }; Querystring: { q?: string; limit?: string; project?: string; projectId?: string } }>( + '/api/v1/gitproviders/:id/repos', + async (request) => { + const { q, limit, project, projectId } = request.query; + + let scopedProjectId = projectId; + if (scopedProjectId === undefined && project !== undefined) { + const proj = await projectRepo.findByName(project); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${project}`), { statusCode: 404 }); + } + scopedProjectId = proj.id; + } + + const parsedLimit = limit === undefined ? undefined : Number(limit); + if (parsedLimit !== undefined && (!Number.isFinite(parsedLimit) || parsedLimit < 1)) { + throw Object.assign(new Error('limit must be a positive number'), { statusCode: 400 }); + } + + return service.discover(request.params.id, { + ...(q !== undefined ? { query: q } : {}), + ...(parsedLimit !== undefined ? { limit: parsedLimit } : {}), + ...(scopedProjectId !== undefined ? { projectId: scopedProjectId } : {}), + }); + }, + ); + + // Import selected repos. Partial success: everything importable is imported, + // everything skipped comes back with a reason. + app.post<{ Params: { id: string } }>('/api/v1/gitproviders/:id/import', async (request, reply) => { + const result = await service.importRepos(request.params.id, request.body, request.userId ?? 'system'); + reply.code(result.imported.length > 0 ? 201 : 200); + return result; + }); + + app.post<{ Params: { id: string } }>('/api/v1/gitproviders/:id/probe', async (request) => { + return service.probe(request.params.id); + }); +} diff --git a/src/mcpd/src/routes/repos.ts b/src/mcpd/src/routes/repos.ts new file mode 100644 index 0000000..b3f1488 --- /dev/null +++ b/src/mcpd/src/routes/repos.ts @@ -0,0 +1,67 @@ +import type { FastifyInstance } from 'fastify'; + +import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { CodeRepoService } from '../services/code-repo.service.js'; + +export function registerCodeRepoRoutes( + app: FastifyInstance, + service: CodeRepoService, + projectRepo: IProjectRepository, +): void { + async function projectIdFor(name: string): Promise { + const proj = await projectRepo.findByName(name); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${name}`), { statusCode: 404 }); + } + return proj.id; + } + + // ── List / get ── + // Reads return the credential REFERENCE (credSecretId/credSecretKey) and + // never the value. Only the session provisioner resolves the secret. + + app.get<{ Querystring: { project?: string; projectId?: string } }>( + '/api/v1/repos', + async (request) => { + const { project, projectId } = request.query; + if (project !== undefined) return service.list(await projectIdFor(project)); + if (projectId !== undefined) return service.list(projectId); + return service.list(); + }, + ); + + app.get<{ Params: { id: string } }>('/api/v1/repos/:id', async (request) => { + return service.get(request.params.id); + }); + + // ── Create / update / delete ── + + app.post('/api/v1/repos', async (request, reply) => { + const body = request.body as Record; + const resolved: Record = { ...body }; + + if (typeof body['project'] === 'string') { + resolved['projectId'] = await projectIdFor(body['project']); + delete resolved['project']; + } + + const repo = await service.create(resolved, request.userId ?? 'system'); + reply.code(201); + return repo; + }); + + app.put<{ Params: { id: string } }>('/api/v1/repos/:id', async (request) => { + return service.update(request.params.id, request.body); + }); + + app.delete<{ Params: { id: string } }>('/api/v1/repos/:id', async (request, reply) => { + await service.delete(request.params.id); + reply.code(204); + }); + + // ── Project-scoped view ── + + app.get<{ Params: { name: string } }>('/api/v1/projects/:name/repos', async (request) => { + return service.list(await projectIdFor(request.params.name)); + }); +} diff --git a/src/mcpd/src/services/code-repo.service.ts b/src/mcpd/src/services/code-repo.service.ts new file mode 100644 index 0000000..6535418 --- /dev/null +++ b/src/mcpd/src/services/code-repo.service.ts @@ -0,0 +1,189 @@ +import type { CodeRepo } from '@prisma/client'; + +import type { ICodeRepoRepository } from '../repositories/code-repo.repository.js'; +import type { IGitProviderRepository } from '../repositories/git-provider.repository.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { ISecretRepository } from '../repositories/interfaces.js'; +import { + CreateCodeRepoSchema, + UpdateCodeRepoSchema, + type CredRef, +} from '../validation/code-repo.schema.js'; + +import { NotFoundError } from './mcp-server.service.js'; +import type { SecretService } from './secret.service.js'; + +/** What the session provisioner needs to clone one repo. */ +export interface ResolvedCheckout { + name: string; + url: string; + branch: string; + /** Workspace-relative directory. Defaults to the repo name. */ + path: string; + /** Resolved credential, or null when the repo is public / uses ambient auth. */ + credential: string | null; +} + +function badRequest(message: string): Error { + return Object.assign(new Error(message), { statusCode: 400 }); +} + +export class CodeRepoService { + constructor( + private readonly repo: ICodeRepoRepository, + private readonly projectRepo: IProjectRepository, + private readonly secretRepo?: ISecretRepository, + private readonly secrets?: SecretService, + /** Supplies the inherited credential for repos imported from a forge. */ + private readonly gitProviders?: IGitProviderRepository, + ) {} + + // ── CRUD ── + + async list(projectId?: string): Promise { + return this.repo.findAll(projectId); + } + + async get(id: string): Promise { + const row = await this.repo.findById(id); + if (row === null) throw new NotFoundError(`Repo not found: ${id}`); + return row; + } + + async create(input: unknown, ownerId: string): Promise { + const data = CreateCodeRepoSchema.parse(input); + + const project = await this.projectRepo.findById(data.projectId); + if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`); + + const duplicate = await this.repo.findByNameAndProject(data.name, data.projectId); + if (duplicate !== null) { + throw Object.assign( + new Error(`Repo '${data.name}' already exists in project '${project.name}'`), + { statusCode: 409 }, + ); + } + + const cred = await this.resolveCredRefToIds(data.credRef); + + return this.repo.create({ + name: data.name, + projectId: data.projectId, + url: data.url, + ownerId, + ...(data.defaultBranch !== undefined ? { defaultBranch: data.defaultBranch } : {}), + ...(data.path !== undefined ? { path: data.path } : {}), + ...(data.provider !== undefined ? { provider: data.provider } : {}), + ...cred, + }); + } + + async update(id: string, input: unknown): Promise { + const data = UpdateCodeRepoSchema.parse(input); + await this.get(id); + + let cred: { credSecretId?: string | null; credSecretKey?: string | null } = {}; + if (data.credRef === null) { + cred = { credSecretId: null, credSecretKey: null }; + } else if (data.credRef !== undefined) { + cred = await this.resolveCredRefToIds(data.credRef); + } + + return this.repo.update(id, { + ...(data.url !== undefined ? { url: data.url } : {}), + ...(data.defaultBranch !== undefined ? { defaultBranch: data.defaultBranch } : {}), + ...(data.path !== undefined ? { path: data.path } : {}), + ...(data.provider !== undefined ? { provider: data.provider } : {}), + ...cred, + }); + } + + async delete(id: string): Promise { + await this.get(id); + await this.repo.delete(id); + } + + // ── Credentials ── + + /** + * Translate `{name, key}` to the FK pair, verifying up front that the secret + * and the key both exist. + * + * Failing here — at definition time, with a clear message — beats failing at + * session-provision time, where the symptom is a clone that mysteriously + * cannot authenticate. Same reasoning as InstanceService treating secret + * resolution failure as fatal to a start attempt. + */ + private async resolveCredRefToIds( + ref?: CredRef, + ): Promise<{ credSecretId?: string; credSecretKey?: string }> { + if (ref === undefined) return {}; + if (this.secretRepo === undefined) { + throw badRequest('Credential references require SecretRepository to be wired into CodeRepoService'); + } + + const secret = await this.secretRepo.findByName(ref.name); + if (secret === null) throw new NotFoundError(`Secret not found: ${ref.name}`); + + if (this.secrets !== undefined) { + // Verifies the key exists on whichever backend holds it; throws if not. + await this.secrets.resolve(ref.name, ref.key); + } + + return { credSecretId: secret.id, credSecretKey: ref.key }; + } + + /** + * Resolve the checkout instructions for every repo in a project. + * + * This is the ONLY path that produces a plaintext credential, and it exists + * for the session provisioner. No route returns a ResolvedCheckout — repo + * reads expose the reference (credSecretId/credSecretKey) and never the + * value. + */ + async resolveCheckouts(projectId: string): Promise { + const repos = await this.repo.findAll(projectId); + const out: ResolvedCheckout[] = []; + + for (const r of repos) { + // Precedence: the repo's own credential, else the credential of the + // GitProvider it was imported from. Imported repos deliberately store no + // reference of their own, so the forge's token is the only one they have. + let credSecretId = r.credSecretId; + let credSecretKey = r.credSecretKey; + if ((credSecretId === null || credSecretKey === null) && r.gitProviderId !== null) { + const provider = this.gitProviders === undefined + ? null + : await this.gitProviders.findById(r.gitProviderId); + if (provider !== null) { + credSecretId = provider.credSecretId; + credSecretKey = provider.credSecretKey; + } + } + + let credential: string | null = null; + if (credSecretId !== null && credSecretKey !== null) { + if (this.secretRepo === undefined || this.secrets === undefined) { + throw new Error(`Repo '${r.name}' has a credential reference but SecretService is not wired`); + } + const secret = await this.secretRepo.findById(credSecretId); + if (secret === null) { + throw new NotFoundError(`Repo '${r.name}' references a secret that no longer exists`); + } + credential = await this.secrets.resolve(secret.name, credSecretKey); + } + + out.push({ + name: r.name, + url: r.url, + branch: r.defaultBranch, + // `path` defaults to the repo name so two repos never collide in the + // workspace root just because nobody set it. + path: r.path === '' ? r.name : r.path, + credential, + }); + } + + return out; + } +} diff --git a/src/mcpd/src/services/git-provider.service.ts b/src/mcpd/src/services/git-provider.service.ts new file mode 100644 index 0000000..d80736c --- /dev/null +++ b/src/mcpd/src/services/git-provider.service.ts @@ -0,0 +1,283 @@ +import type { CodeRepo, GitProvider } from '@prisma/client'; + +import type { ICodeRepoRepository } from '../repositories/code-repo.repository.js'; +import type { IGitProviderRepository } from '../repositories/git-provider.repository.js'; +import type { ISecretRepository } from '../repositories/interfaces.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { CredRef } from '../validation/code-repo.schema.js'; +import { + CreateGitProviderSchema, + ImportReposSchema, + UpdateGitProviderSchema, +} from '../validation/git-provider.schema.js'; + +import type { GitProviderRegistry } from './git/registry.js'; +import type { DiscoveredRepo, GitProviderContext } from './git/types.js'; +import { NotFoundError } from './mcp-server.service.js'; +import type { SecretService } from './secret.service.js'; + +/** A discovered repo plus whether this project already has it. */ +export interface DiscoveredRepoView extends DiscoveredRepo { + /** True when a CodeRepo in the target project already points at it. */ + imported: boolean; + /** Why it cannot be imported, when it cannot. */ + unavailableReason?: string; +} + +export interface ImportResult { + imported: CodeRepo[]; + skipped: Array<{ fullName: string; reason: string }>; +} + +function badRequest(message: string): Error { + return Object.assign(new Error(message), { statusCode: 400 }); +} + +/** Derive a CodeRepo name from an owner-qualified forge name. */ +export function repoNameFrom(fullName: string): string { + const bare = fullName.includes('/') ? (fullName.split('/').pop() ?? fullName) : fullName; + const slug = bare.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, ''); + return slug === '' ? 'repo' : slug; +} + +export class GitProviderService { + constructor( + private readonly repo: IGitProviderRepository, + private readonly codeRepos: ICodeRepoRepository, + private readonly projectRepo: IProjectRepository, + private readonly drivers: GitProviderRegistry, + private readonly secretRepo?: ISecretRepository, + private readonly secrets?: SecretService, + ) {} + + // ── CRUD ── + + async list(): Promise { + return this.repo.findAll(); + } + + async get(id: string): Promise { + const row = await this.repo.findById(id); + if (row === null) throw new NotFoundError(`GitProvider not found: ${id}`); + return row; + } + + async create(input: unknown, ownerId: string): Promise { + const data = CreateGitProviderSchema.parse(input); + + const existing = await this.repo.findByName(data.name); + if (existing !== null) { + throw Object.assign(new Error(`GitProvider already exists: ${data.name}`), { statusCode: 409 }); + } + + const cred = await this.resolveCredRefToIds(data.credRef); + const type = data.type ?? 'gitea'; + + // Probe at registration, like LlmService does: a wrong URL or an + // under-scoped token should fail here with a clear message, not later at + // discovery where it looks like "the forge has no repos". + if (data.skipProbe !== true) { + await this.probeOrThrow(type, data.url, data.credRef); + } + + return this.repo.create({ + name: data.name, + url: data.url, + ownerId, + type, + ...(data.description !== undefined ? { description: data.description } : {}), + ...cred, + }); + } + + async update(id: string, input: unknown): Promise { + const data = UpdateGitProviderSchema.parse(input); + const current = await this.get(id); + + let cred: { credSecretId?: string | null; credSecretKey?: string | null } = {}; + if (data.credRef === null) { + cred = { credSecretId: null, credSecretKey: null }; + } else if (data.credRef !== undefined) { + cred = await this.resolveCredRefToIds(data.credRef); + } + + if (data.skipProbe !== true && (data.url !== undefined || data.credRef !== undefined || data.type !== undefined)) { + await this.probeOrThrow( + data.type ?? current.type, + data.url ?? current.url, + data.credRef ?? undefined, + data.credRef === undefined ? current : undefined, + ); + } + + return this.repo.update(id, { + ...(data.url !== undefined ? { url: data.url } : {}), + ...(data.type !== undefined ? { type: data.type } : {}), + ...(data.description !== undefined ? { description: data.description } : {}), + ...cred, + }); + } + + async delete(id: string): Promise { + await this.get(id); + // Repos imported from it survive (FK is SetNull) and keep their + // sourceProvider name, so provenance outlives the connection. + await this.repo.delete(id); + } + + // ── Discovery ── + + /** + * List what is on the forge right now. Nothing is stored — this is a live + * view so the caller can pick. When `projectId` is given, each row is marked + * with whether that project already has it, so re-importing is obvious. + */ + async discover( + id: string, + opts: { query?: string; limit?: number; projectId?: string } = {}, + ): Promise { + const provider = await this.get(id); + const driver = this.drivers.build(provider.type); + const ctx = await this.contextFor(provider); + + const found = await driver.listRepositories(ctx, { + ...(opts.query !== undefined ? { query: opts.query } : {}), + ...(opts.limit !== undefined ? { limit: opts.limit } : {}), + }); + + const existing = opts.projectId === undefined + ? [] + : await this.codeRepos.findAll(opts.projectId); + const already = new Set(existing.map((r) => r.sourceFullName ?? '').filter((s) => s !== '')); + + return found.map((r) => ({ + ...r, + imported: already.has(r.fullName), + ...(r.empty ? { unavailableReason: 'repository is empty — nothing to clone' } : {}), + })); + } + + /** + * Import selected repos into a project. + * + * Partial success is the contract: one bad name should not lose the other + * nineteen. Everything importable is imported, and everything skipped is + * reported with a reason. + */ + async importRepos(id: string, input: unknown, ownerId: string): Promise { + const data = ImportReposSchema.parse(input); + const provider = await this.get(id); + + const projectId = data.projectId ?? (await this.projectIdForName(data.project as string)); + + const driver = this.drivers.build(provider.type); + const ctx = await this.contextFor(provider); + const available = await driver.listRepositories(ctx, { limit: 500 }); + const byFullName = new Map(available.map((r) => [r.fullName, r])); + + const imported: CodeRepo[] = []; + const skipped: Array<{ fullName: string; reason: string }> = []; + + for (const fullName of data.repos) { + const found = byFullName.get(fullName); + if (found === undefined) { + skipped.push({ fullName, reason: 'not found on this provider' }); + continue; + } + if (found.empty) { + skipped.push({ fullName, reason: 'repository is empty — nothing to clone' }); + continue; + } + + const name = repoNameFrom(fullName); + const clash = await this.codeRepos.findByNameAndProject(name, projectId); + if (clash !== null) { + skipped.push({ + fullName, + reason: clash.sourceFullName === fullName + ? 'already imported' + : `name '${name}' is taken by another repo in this project`, + }); + continue; + } + + imported.push(await this.codeRepos.create({ + name, + projectId, + url: data.ssh === true ? found.sshUrl : found.cloneUrl, + ownerId, + defaultBranch: found.defaultBranch, + provider: provider.type, + gitProviderId: provider.id, + // Denormalized so provenance survives the provider being deleted — + // same reasoning as McpServer.templateName. + sourceProvider: provider.name, + sourceFullName: fullName, + // Credentials are inherited from the provider; the repo stores no + // reference of its own. + })); + } + + return { imported, skipped }; + } + + /** Live credential check for an already-registered provider. */ + async probe(id: string): Promise<{ ok: boolean; detail: string }> { + const provider = await this.get(id); + const driver = this.drivers.build(provider.type); + return driver.probe(await this.contextFor(provider)); + } + + // ── Internals ── + + private async projectIdForName(name: string): Promise { + const project = await this.projectRepo.findByName(name); + if (project === null) throw new NotFoundError(`Project not found: ${name}`); + return project.id; + } + + /** Resolve a provider row into the driver context, including its token. */ + async contextFor(provider: GitProvider): Promise { + let token: string | null = null; + if (provider.credSecretId !== null && provider.credSecretKey !== null) { + if (this.secretRepo === undefined || this.secrets === undefined) { + throw new Error(`GitProvider '${provider.name}' has a credential but SecretService is not wired`); + } + const secret = await this.secretRepo.findById(provider.credSecretId); + if (secret === null) { + throw new NotFoundError(`GitProvider '${provider.name}' references a secret that no longer exists`); + } + token = await this.secrets.resolve(secret.name, provider.credSecretKey); + } + return { url: provider.url, token }; + } + + private async probeOrThrow( + type: string, + url: string, + credRef?: CredRef, + fallback?: GitProvider, + ): Promise { + let token: string | null = null; + if (credRef !== undefined && this.secrets !== undefined) { + token = await this.secrets.resolve(credRef.name, credRef.key); + } else if (fallback !== undefined) { + token = (await this.contextFor(fallback)).token; + } + const driver = this.drivers.build(type); + await driver.probe({ url, token }); + } + + private async resolveCredRefToIds( + ref?: CredRef, + ): Promise<{ credSecretId?: string; credSecretKey?: string }> { + if (ref === undefined) return {}; + if (this.secretRepo === undefined) { + throw badRequest('Credential references require SecretRepository to be wired into GitProviderService'); + } + const secret = await this.secretRepo.findByName(ref.name); + if (secret === null) throw new NotFoundError(`Secret not found: ${ref.name}`); + if (this.secrets !== undefined) await this.secrets.resolve(ref.name, ref.key); + return { credSecretId: secret.id, credSecretKey: ref.key }; + } +} diff --git a/src/mcpd/src/services/git/gitea.driver.ts b/src/mcpd/src/services/git/gitea.driver.ts new file mode 100644 index 0000000..6cd5557 --- /dev/null +++ b/src/mcpd/src/services/git/gitea.driver.ts @@ -0,0 +1,134 @@ +import { + GitProviderAuthError, + GitProviderError, + type DiscoverOptions, + type DiscoveredRepo, + type GitProviderContext, + type GitProviderDriver, +} from './types.js'; + +/** Gitea's search endpoint wraps results; /version does not. */ +interface GiteaSearchResponse { + ok?: boolean; + data?: GiteaRepo[]; + message?: string; +} + +interface GiteaRepo { + name?: string; + full_name?: string; + description?: string; + clone_url?: string; + ssh_url?: string; + default_branch?: string; + private?: boolean; + empty?: boolean; + archived?: boolean; + owner?: { login?: string }; +} + +const PAGE_SIZE = 50; +const DEFAULT_LIMIT = 200; +const TIMEOUT_MS = 15_000; + +export class GiteaDriver implements GitProviderDriver { + readonly type = 'gitea'; + + private async call(ctx: GitProviderContext, path: string): Promise { + const base = ctx.url.replace(/\/+$/, ''); + const headers: Record = { Accept: 'application/json' }; + // Gitea's scheme is `token `, not `Bearer`. + if (ctx.token !== null) headers['Authorization'] = `token ${ctx.token}`; + + let res: Response; + try { + res = await fetch(`${base}${path}`, { headers, signal: AbortSignal.timeout(TIMEOUT_MS) }); + } catch (err: unknown) { + const detail = err instanceof Error ? err.message : String(err); + throw new GitProviderError(`Cannot reach Gitea at ${base}: ${detail}`); + } + + const text = await res.text(); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = text; + } + + if (res.status === 401 || res.status === 403) { + // Gitea reports insufficient token scopes as 403 with the required scope + // named. Surfacing that verbatim turns an opaque failure into an + // actionable one — the first token tried here had write:repository but + // not read:user, which silently broke /user/repos. + const message = + typeof parsed === 'object' && parsed !== null && 'message' in parsed + ? String((parsed as { message: unknown }).message) + : `HTTP ${String(res.status)}`; + throw new GitProviderAuthError(`Gitea rejected the credential: ${message}`); + } + if (res.status >= 400) { + throw new GitProviderError(`Gitea returned HTTP ${String(res.status)} for ${path}`); + } + + return parsed as T; + } + + async probe(ctx: GitProviderContext): Promise<{ ok: boolean; detail: string }> { + const version = await this.call<{ version?: string }>(ctx, '/api/v1/version'); + // /version answers without auth, so confirm the credential separately — + // otherwise a bad token registers cleanly and fails later at discovery. + const repos = await this.listRepositories(ctx, { limit: 1 }); + return { + ok: true, + detail: `Gitea ${version.version ?? 'unknown'}, credential OK (${String(repos.length)} repo visible)`, + }; + } + + /** + * List repositories via `/repos/search`. + * + * Deliberately NOT `/user/repos`: that needs the `read:user` scope, which a + * repo-scoped token does not carry, so it 403s on exactly the tokens people + * create for repository automation. `/repos/search` needs only repository + * scope and returns the same rows. + */ + async listRepositories(ctx: GitProviderContext, opts: DiscoverOptions = {}): Promise { + const limit = opts.limit ?? DEFAULT_LIMIT; + const out: DiscoveredRepo[] = []; + + for (let page = 1; out.length < limit; page++) { + const params = new URLSearchParams({ + limit: String(Math.min(PAGE_SIZE, limit - out.length)), + page: String(page), + }); + if (opts.query !== undefined && opts.query !== '') params.set('q', opts.query); + + const body = await this.call(ctx, `/api/v1/repos/search?${params.toString()}`); + const rows = body.data ?? []; + if (rows.length === 0) break; + + for (const r of rows) out.push(toDiscovered(r)); + // Short page means the last page. + if (rows.length < PAGE_SIZE) break; + } + + return out.slice(0, limit); + } +} + +function toDiscovered(r: GiteaRepo): DiscoveredRepo { + const fullName = r.full_name ?? r.name ?? ''; + return { + fullName, + name: r.name ?? fullName.split('/').pop() ?? '', + owner: r.owner?.login ?? fullName.split('/')[0] ?? '', + description: r.description ?? '', + cloneUrl: r.clone_url ?? '', + sshUrl: r.ssh_url ?? '', + defaultBranch: r.default_branch ?? 'main', + private: r.private ?? false, + empty: r.empty ?? false, + archived: r.archived ?? false, + }; +} diff --git a/src/mcpd/src/services/git/registry.ts b/src/mcpd/src/services/git/registry.ts new file mode 100644 index 0000000..2ee7f3f --- /dev/null +++ b/src/mcpd/src/services/git/registry.ts @@ -0,0 +1,41 @@ +import { GiteaDriver } from './gitea.driver.js'; +import type { GitProviderDriver } from './types.js'; + +/** Driver keys with an implementation. Validation rejects anything else. */ +export const GIT_PROVIDER_TYPES = ['gitea'] as const; +export type GitProviderType = (typeof GIT_PROVIDER_TYPES)[number]; + +export class UnsupportedGitProviderError extends Error { + constructor(type: string) { + super( + `Unsupported git provider type '${type}'. Supported: ${GIT_PROVIDER_TYPES.join(', ')}`, + ); + this.name = 'UnsupportedGitProviderError'; + } +} + +/** + * Resolve a driver by type. Mirrors LlmAdapterRegistry.build — adding GitHub + * or GitLab is one file plus one case here, and nothing above this layer + * changes. + */ +export class GitProviderRegistry { + private readonly cache = new Map(); + + build(type: string): GitProviderDriver { + const cached = this.cache.get(type); + if (cached !== undefined) return cached; + + let driver: GitProviderDriver; + switch (type) { + case 'gitea': + driver = new GiteaDriver(); + break; + default: + throw new UnsupportedGitProviderError(type); + } + + this.cache.set(type, driver); + return driver; + } +} diff --git a/src/mcpd/src/services/git/types.ts b/src/mcpd/src/services/git/types.ts new file mode 100644 index 0000000..2a02bb3 --- /dev/null +++ b/src/mcpd/src/services/git/types.ts @@ -0,0 +1,64 @@ +/** + * Git forge driver abstraction. + * + * Nothing above this layer knows what a Gitea is. Adding GitHub or GitLab means + * one new file plus a case in the registry — the same shape as the LLM adapter + * dispatcher. The instance URL always comes from the GitProvider row, never + * from a constant. + */ + +/** A repository as it exists on the forge, before we import it. */ +export interface DiscoveredRepo { + /** Owner-qualified name, e.g. "michal/mcpctl". */ + fullName: string; + /** Bare name, e.g. "mcpctl". */ + name: string; + owner: string; + description: string; + cloneUrl: string; + sshUrl: string; + defaultBranch: string; + private: boolean; + /** No commits yet — nothing to clone into a session workspace. */ + empty: boolean; + archived: boolean; +} + +export interface DiscoverOptions { + /** Substring match on the repo name, passed to the forge. */ + query?: string; + /** Max repos to return. Drivers paginate up to this. */ + limit?: number; +} + +/** Everything a driver needs to talk to one configured forge. */ +export interface GitProviderContext { + /** Instance base URL, e.g. https://mysources.co.uk. */ + url: string; + /** Resolved API credential, or null when the forge is anonymous-readable. */ + token: string | null; +} + +export interface GitProviderDriver { + readonly type: string; + /** List repositories visible to the configured credential. */ + listRepositories(ctx: GitProviderContext, opts?: DiscoverOptions): Promise; + /** Cheap credential/URL check used at registration time. */ + probe(ctx: GitProviderContext): Promise<{ ok: boolean; detail: string }>; +} + +/** Raised when a forge rejects the credential or its scopes are insufficient. */ +export class GitProviderAuthError extends Error { + constructor(message: string) { + super(message); + this.name = 'GitProviderAuthError'; + } +} + +/** Raised when the forge is unreachable or answers unusably. */ +export class GitProviderError extends Error { + constructor(message: string) { + super(message); + this.name = 'GitProviderError'; + } +} diff --git a/src/mcpd/src/validation/code-repo.schema.ts b/src/mcpd/src/validation/code-repo.schema.ts new file mode 100644 index 0000000..c917830 --- /dev/null +++ b/src/mcpd/src/validation/code-repo.schema.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; + +const NAME_RE = /^[a-z0-9-]+$/; + +/** Git providers with a driver. `gitea` is the only one in v1. */ +export const GIT_PROVIDERS = ['gitea'] as const; + +/** + * Credential reference, mirroring `Llm.apiKeyRef`: the API takes a + * {name, key} pair and the service translates it to the FK columns. The value + * itself never appears in a request or a response — only the reference. + */ +export const CredRefSchema = z.object({ + name: z.string().min(1).max(100), + key: z.string().min(1).max(200), +}); + +/** + * Clone URL. https or ssh only — a file:// or relative URL would let a repo + * definition point the clone step at the mcpd container's own filesystem. + */ +const CloneUrlSchema = z.string().min(1).max(2000).refine( + (u) => /^https:\/\//.test(u) || /^ssh:\/\//.test(u) || /^[\w.-]+@[\w.-]+:/.test(u), + { message: 'url must be https://, ssh://, or scp-style user@host:path' }, +); + +/** + * Workspace-relative checkout path. Must stay inside the workspace: a `..` + * segment or a leading `/` would let a repo escape it during the clone. + */ +const WorkspacePathSchema = z.string().max(300).refine( + (p) => p === '' || (!p.startsWith('/') && !p.split('/').includes('..')), + { message: 'path must be relative and must not contain ".." segments' }, +); + +export const CreateCodeRepoSchema = z.object({ + name: z.string().min(1).max(100).regex(NAME_RE, 'Name must be lowercase alphanumeric with hyphens'), + projectId: z.string().min(1), + url: CloneUrlSchema, + defaultBranch: z.string().min(1).max(200).optional(), + path: WorkspacePathSchema.optional(), + provider: z.enum(GIT_PROVIDERS).optional(), + credRef: CredRefSchema.optional(), +}); + +export const UpdateCodeRepoSchema = z.object({ + url: CloneUrlSchema.optional(), + defaultBranch: z.string().min(1).max(200).optional(), + path: WorkspacePathSchema.optional(), + provider: z.enum(GIT_PROVIDERS).optional(), + // null clears the credential reference. + credRef: CredRefSchema.nullable().optional(), +}); + +export type CreateCodeRepoInput = z.infer; +export type UpdateCodeRepoInput = z.infer; +export type CredRef = z.infer; diff --git a/src/mcpd/src/validation/git-provider.schema.ts b/src/mcpd/src/validation/git-provider.schema.ts new file mode 100644 index 0000000..38c3df1 --- /dev/null +++ b/src/mcpd/src/validation/git-provider.schema.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; + +import { GIT_PROVIDER_TYPES } from '../services/git/registry.js'; +import { CredRefSchema } from './code-repo.schema.js'; + +const NAME_RE = /^[a-z0-9-]+$/; + +/** Instance base URL. http/https only — this is an API endpoint, not a clone URL. */ +const InstanceUrlSchema = z.string().url().refine( + (u) => u.startsWith('http://') || u.startsWith('https://'), + { message: 'url must be http:// or https://' }, +); + +export const CreateGitProviderSchema = z.object({ + name: z.string().min(1).max(100).regex(NAME_RE, 'Name must be lowercase alphanumeric with hyphens'), + url: InstanceUrlSchema, + type: z.enum(GIT_PROVIDER_TYPES).optional(), + description: z.string().max(500).optional(), + credRef: CredRefSchema.optional(), + /** Skip the registration-time credential probe (wiring config before infra exists). */ + skipProbe: z.boolean().optional(), +}); + +export const UpdateGitProviderSchema = z.object({ + url: InstanceUrlSchema.optional(), + type: z.enum(GIT_PROVIDER_TYPES).optional(), + description: z.string().max(500).optional(), + credRef: CredRefSchema.nullable().optional(), + skipProbe: z.boolean().optional(), +}); + +/** Import selected discovered repos into a project as CodeRepo rows. */ +export const ImportReposSchema = z.object({ + projectId: z.string().min(1).optional(), + project: z.string().min(1).optional(), + /** Owner-qualified names exactly as discovery returned them. */ + repos: z.array(z.string().min(1).max(300)).min(1).max(200), + /** Use ssh_url instead of clone_url. */ + ssh: z.boolean().optional(), +}).refine( + (d) => (d.projectId !== undefined) !== (d.project !== undefined), + { message: 'Pass exactly one of project or projectId' }, +); + +export type CreateGitProviderInput = z.infer; diff --git a/src/mcpd/src/validation/rbac-definition.schema.ts b/src/mcpd/src/validation/rbac-definition.schema.ts index c5dca98..f7fa46f 100644 --- a/src/mcpd/src/validation/rbac-definition.schema.ts +++ b/src/mcpd/src/validation/rbac-definition.schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const; -export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'mcptokens', 'inference-tasks', 'epics', 'epictasks', 'tasks', 'repos', 'sessions'] as const; +export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'mcptokens', 'inference-tasks', 'epics', 'epictasks', 'tasks', 'repos', 'sessions', 'gitproviders'] as const; /** Singular→plural map for resource names. */ const RESOURCE_ALIASES: Record = { @@ -38,6 +38,8 @@ const RESOURCE_ALIASES: Record = { repository: 'repos', repositories: 'repos', session: 'sessions', + gitprovider: 'gitproviders', + forge: 'gitproviders', }; /** Normalize a resource name to its canonical plural form. */ diff --git a/src/mcpd/tests/services/code-repo-service.test.ts b/src/mcpd/tests/services/code-repo-service.test.ts new file mode 100644 index 0000000..08d5820 --- /dev/null +++ b/src/mcpd/tests/services/code-repo-service.test.ts @@ -0,0 +1,201 @@ +import type { CodeRepo, Project, Secret } from '@prisma/client'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { ICodeRepoRepository } from '../../src/repositories/code-repo.repository.js'; +import type { ISecretRepository } from '../../src/repositories/interfaces.js'; +import type { IProjectRepository } from '../../src/repositories/project.repository.js'; +import { CodeRepoService } from '../../src/services/code-repo.service.js'; +import type { SecretService } from '../../src/services/secret.service.js'; + +function makeRepo(overrides: Partial = {}): CodeRepo { + return { + id: 'repo-1', + name: 'api', + projectId: 'proj-1', + url: 'https://git.example/api.git', + defaultBranch: 'main', + path: '', + provider: 'gitea', + credSecretId: null, + credSecretKey: null, + ownerId: 'user-1', + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as CodeRepo; +} + +function mocks(rows: CodeRepo[] = []) { + const repo = { + findAll: vi.fn(async () => rows), + findById: vi.fn(async (id: string) => rows.find((r) => r.id === id) ?? null), + findByName: vi.fn(async (n: string) => rows.find((r) => r.name === n) ?? null), + findByNameAndProject: vi.fn(async () => null), + create: vi.fn(async (d: Record) => makeRepo(d as Partial)), + update: vi.fn(async (_id: string, d: Record) => makeRepo(d as Partial)), + delete: vi.fn(async () => {}), + } as unknown as ICodeRepoRepository & Record>; + + const projectRepo = { + findById: vi.fn(async () => ({ id: 'proj-1', name: 'mcpctl' } as Project)), + findByName: vi.fn(async () => ({ id: 'proj-1', name: 'mcpctl' } as Project)), + } as unknown as IProjectRepository; + + const secretRepo = { + findByName: vi.fn(async (n: string) => + n === 'gitea-token' ? ({ id: 'sec-1', name: 'gitea-token' } as Secret) : null, + ), + findById: vi.fn(async () => ({ id: 'sec-1', name: 'gitea-token' } as Secret)), + } as unknown as ISecretRepository & Record>; + + const secrets = { + resolve: vi.fn(async (_name: string, key: string) => { + if (key !== 'TOKEN') throw new Error(`Secret 'gitea-token' has no key '${key}'`); + return 'ghp_supersecret'; + }), + } as unknown as SecretService & { resolve: ReturnType }; + + return { + repo, projectRepo, secretRepo, secrets, + service: new CodeRepoService(repo, projectRepo, secretRepo, secrets), + }; +} + +const base = { name: 'api', projectId: 'proj-1', url: 'https://git.example/api.git' }; + +describe('CodeRepoService validation', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('accepts https, ssh:// and scp-style clone URLs', async () => { + const { service } = mocks(); + for (const url of [ + 'https://git.example/api.git', + 'ssh://git@git.example/api.git', + 'git@git.example:michal/api.git', + ]) { + await expect(service.create({ ...base, url }, 'user-1')).resolves.toBeDefined(); + } + }); + + it('rejects a file:// URL — a repo must not point the clone at mcpd\'s own disk', async () => { + const { service } = mocks(); + await expect(service.create({ ...base, url: 'file:///etc' }, 'user-1')).rejects.toThrow(); + await expect(service.create({ ...base, url: '/etc/passwd' }, 'user-1')).rejects.toThrow(); + }); + + it('rejects a checkout path that escapes the workspace', async () => { + const { service } = mocks(); + for (const path of ['../outside', 'a/../../b', '/absolute']) { + await expect(service.create({ ...base, path }, 'user-1')).rejects.toThrow(); + } + }); + + it('accepts an ordinary nested path', async () => { + const { service } = mocks(); + await expect(service.create({ ...base, path: 'services/api' }, 'user-1')).resolves.toBeDefined(); + }); + + it('rejects an unknown provider', async () => { + const { service } = mocks(); + await expect(service.create({ ...base, provider: 'bitbucket' }, 'user-1')).rejects.toThrow(); + }); + + it('409s on a duplicate name within the project', async () => { + const { service, repo } = mocks(); + (repo.findByNameAndProject as ReturnType).mockResolvedValueOnce(makeRepo()); + await expect(service.create(base, 'user-1')).rejects.toThrow(/already exists in project 'mcpctl'/); + }); +}); + +describe('CodeRepoService credentials', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('translates credRef {name,key} to the FK pair', async () => { + const { service, repo } = mocks(); + await service.create({ ...base, credRef: { name: 'gitea-token', key: 'TOKEN' } }, 'user-1'); + expect(repo.create).toHaveBeenCalledWith( + expect.objectContaining({ credSecretId: 'sec-1', credSecretKey: 'TOKEN' }), + ); + }); + + it('never stores the credential VALUE on the row', async () => { + const { service, repo } = mocks(); + await service.create({ ...base, credRef: { name: 'gitea-token', key: 'TOKEN' } }, 'user-1'); + expect(JSON.stringify(repo.create.mock.calls[0]?.[0])).not.toContain('ghp_supersecret'); + }); + + it('fails at definition time when the secret does not exist', async () => { + const { service } = mocks(); + await expect( + service.create({ ...base, credRef: { name: 'nope', key: 'TOKEN' } }, 'user-1'), + ).rejects.toThrow(/Secret not found: nope/); + }); + + it('fails at definition time when the KEY does not exist', async () => { + // Better here, with a clear message, than at provision time where the + // symptom is a clone that mysteriously cannot authenticate. + const { service } = mocks(); + await expect( + service.create({ ...base, credRef: { name: 'gitea-token', key: 'WRONG' } }, 'user-1'), + ).rejects.toThrow(/no key 'WRONG'/); + }); + + it('clears the reference when credRef is null', async () => { + const { service, repo } = mocks([makeRepo({ credSecretId: 'sec-1', credSecretKey: 'TOKEN' })]); + await service.update('repo-1', { credRef: null }); + expect(repo.update).toHaveBeenCalledWith('repo-1', { credSecretId: null, credSecretKey: null }); + }); + + it('leaves the reference untouched when credRef is absent', async () => { + const { service, repo } = mocks([makeRepo({ credSecretId: 'sec-1', credSecretKey: 'TOKEN' })]); + await service.update('repo-1', { defaultBranch: 'develop' }); + const written = repo.update.mock.calls[0]?.[1] as Record; + expect(written).not.toHaveProperty('credSecretId'); + expect(written).toEqual({ defaultBranch: 'develop' }); + }); +}); + +describe('CodeRepoService.resolveCheckouts', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('resolves the credential only on this path', async () => { + const { service, secrets } = mocks([ + makeRepo({ credSecretId: 'sec-1', credSecretKey: 'TOKEN' }), + ]); + const checkouts = await service.resolveCheckouts('proj-1'); + expect(checkouts[0]?.credential).toBe('ghp_supersecret'); + expect(secrets.resolve).toHaveBeenCalledWith('gitea-token', 'TOKEN'); + }); + + it('returns a null credential for a repo with no reference', async () => { + const { service, secrets } = mocks([makeRepo()]); + const checkouts = await service.resolveCheckouts('proj-1'); + expect(checkouts[0]?.credential).toBeNull(); + expect(secrets.resolve).not.toHaveBeenCalled(); + }); + + it('defaults the checkout path to the repo name so repos cannot collide', async () => { + const { service } = mocks([makeRepo({ name: 'api', path: '' }), makeRepo({ id: 'r2', name: 'web', path: '' })]); + const checkouts = await service.resolveCheckouts('proj-1'); + expect(checkouts.map((c) => c.path)).toEqual(['api', 'web']); + }); + + it('honours an explicit path', async () => { + const { service } = mocks([makeRepo({ path: 'services/api' })]); + expect((await service.resolveCheckouts('proj-1'))[0]?.path).toBe('services/api'); + }); + + it('fails loudly when a referenced secret has been deleted', async () => { + const { service, secretRepo } = mocks([makeRepo({ credSecretId: 'sec-1', credSecretKey: 'TOKEN' })]); + (secretRepo.findById as ReturnType).mockResolvedValueOnce(null); + // Silently cloning without credentials would look like a public-repo + // success and fail confusingly much later. + await expect(service.resolveCheckouts('proj-1')).rejects.toThrow(/no longer exists/); + }); + + it('carries the branch through for the checkout', async () => { + const { service } = mocks([makeRepo({ defaultBranch: 'develop' })]); + expect((await service.resolveCheckouts('proj-1'))[0]?.branch).toBe('develop'); + }); +}); diff --git a/src/mcpd/tests/services/git-provider.test.ts b/src/mcpd/tests/services/git-provider.test.ts new file mode 100644 index 0000000..60efa38 --- /dev/null +++ b/src/mcpd/tests/services/git-provider.test.ts @@ -0,0 +1,342 @@ +import type { CodeRepo, GitProvider, Project, Secret } from '@prisma/client'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import type { ICodeRepoRepository } from '../../src/repositories/code-repo.repository.js'; +import type { IGitProviderRepository } from '../../src/repositories/git-provider.repository.js'; +import type { ISecretRepository } from '../../src/repositories/interfaces.js'; +import type { IProjectRepository } from '../../src/repositories/project.repository.js'; +import { GiteaDriver } from '../../src/services/git/gitea.driver.js'; +import { GitProviderRegistry, UnsupportedGitProviderError } from '../../src/services/git/registry.js'; +import { GitProviderAuthError, GitProviderError } from '../../src/services/git/types.js'; +import { GitProviderService, repoNameFrom } from '../../src/services/git-provider.service.js'; +import type { SecretService } from '../../src/services/secret.service.js'; + +// ── Gitea driver ────────────────────────────────────────────────────────── + +const giteaRepo = (full: string, extra: Record = {}) => ({ + name: full.split('/')[1], + full_name: full, + owner: { login: full.split('/')[0] }, + description: 'a repo', + clone_url: `https://mysources.co.uk/${full}.git`, + ssh_url: `git@mysources.co.uk:${full}.git`, + default_branch: 'main', + private: false, + empty: false, + archived: false, + ...extra, +}); + +function mockFetch(handler: (url: string, init: RequestInit) => { status: number; body: unknown }) { + return vi.fn(async (url: string | URL, init?: RequestInit) => { + const { status, body } = handler(String(url), init ?? {}); + return { + status, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + } as unknown as Response; + }); +} + +describe('GiteaDriver', () => { + const original = globalThis.fetch; + afterEach(() => { globalThis.fetch = original; }); + + const CTX = { url: 'https://mysources.co.uk', token: 'tok123' }; + + it('discovers via /repos/search, not /user/repos', async () => { + // /user/repos requires the read:user scope, which repo-scoped automation + // tokens do not carry — it 403s on exactly the tokens people create for + // this. /repos/search needs only repository scope. + const seen: string[] = []; + globalThis.fetch = mockFetch((url) => { + seen.push(url); + return { status: 200, body: { ok: true, data: [giteaRepo('michal/mcpctl')] } }; + }) as unknown as typeof fetch; + + await new GiteaDriver().listRepositories(CTX, { limit: 10 }); + + expect(seen[0]).toContain('/api/v1/repos/search'); + expect(seen.join()).not.toContain('/user/repos'); + }); + + it('uses Gitea\'s "token" auth scheme, not Bearer', async () => { + let auth: string | undefined; + globalThis.fetch = mockFetch((_url, init) => { + auth = (init.headers as Record)['Authorization']; + return { status: 200, body: { ok: true, data: [] } }; + }) as unknown as typeof fetch; + + await new GiteaDriver().listRepositories(CTX); + expect(auth).toBe('token tok123'); + }); + + it('omits the header entirely when there is no token', async () => { + let headers: Record = {}; + globalThis.fetch = mockFetch((_url, init) => { + headers = init.headers as Record; + return { status: 200, body: { ok: true, data: [] } }; + }) as unknown as typeof fetch; + + await new GiteaDriver().listRepositories({ url: CTX.url, token: null }); + expect(headers['Authorization']).toBeUndefined(); + }); + + it('maps the forge payload onto DiscoveredRepo', async () => { + globalThis.fetch = mockFetch(() => ({ + status: 200, + body: { ok: true, data: [giteaRepo('michal/mcpctl', { private: true, default_branch: 'develop' })] }, + })) as unknown as typeof fetch; + + const [repo] = await new GiteaDriver().listRepositories(CTX); + expect(repo).toMatchObject({ + fullName: 'michal/mcpctl', + name: 'mcpctl', + owner: 'michal', + defaultBranch: 'develop', + private: true, + cloneUrl: 'https://mysources.co.uk/michal/mcpctl.git', + sshUrl: 'git@mysources.co.uk:michal/mcpctl.git', + }); + }); + + it('surfaces the forge\'s scope message verbatim on 403', async () => { + // The real failure that cost time: a token with write:repository but not + // read:user. The required scope must reach the operator. + globalThis.fetch = mockFetch(() => ({ + status: 403, + body: { message: 'token does not have at least one of required scope(s), required=[read:user]' }, + })) as unknown as typeof fetch; + + await expect(new GiteaDriver().listRepositories(CTX)).rejects.toThrow(GitProviderAuthError); + await expect(new GiteaDriver().listRepositories(CTX)).rejects.toThrow(/required=\[read:user\]/); + }); + + it('reports an unreachable forge as GitProviderError, not an auth failure', async () => { + globalThis.fetch = vi.fn(async () => { throw new Error('ECONNREFUSED'); }) as unknown as typeof fetch; + await expect(new GiteaDriver().listRepositories(CTX)).rejects.toThrow(GitProviderError); + }); + + it('pages until the forge returns a short page', async () => { + let page = 0; + globalThis.fetch = mockFetch(() => { + page++; + const rows = page === 1 + ? Array.from({ length: 50 }, (_, i) => giteaRepo(`michal/r${String(i)}`)) + : [giteaRepo('michal/last')]; + return { status: 200, body: { ok: true, data: rows } }; + }) as unknown as typeof fetch; + + const repos = await new GiteaDriver().listRepositories(CTX, { limit: 200 }); + expect(repos).toHaveLength(51); + expect(page).toBe(2); + }); + + it('honours the limit rather than draining the forge', async () => { + globalThis.fetch = mockFetch(() => ({ + status: 200, + body: { ok: true, data: Array.from({ length: 50 }, (_, i) => giteaRepo(`michal/r${String(i)}`)) }, + })) as unknown as typeof fetch; + + expect(await new GiteaDriver().listRepositories(CTX, { limit: 5 })).toHaveLength(5); + }); +}); + +describe('GitProviderRegistry', () => { + it('builds the gitea driver and caches it', () => { + const reg = new GitProviderRegistry(); + const a = reg.build('gitea'); + expect(a.type).toBe('gitea'); + expect(reg.build('gitea')).toBe(a); + }); + + it('rejects an unknown type with the supported list', () => { + expect(() => new GitProviderRegistry().build('bitbucket')).toThrow(UnsupportedGitProviderError); + expect(() => new GitProviderRegistry().build('bitbucket')).toThrow(/Supported: gitea/); + }); +}); + +describe('repoNameFrom', () => { + it('takes the bare name and slugs it', () => { + expect(repoNameFrom('michal/mcpctl')).toBe('mcpctl'); + expect(repoNameFrom('michal/Home_Assistant.Alchemy')).toBe('home-assistant-alchemy'); + expect(repoNameFrom('plain')).toBe('plain'); + }); +}); + +// ── Service: discovery + import ─────────────────────────────────────────── + +const PROVIDER = { + id: 'gp-1', name: 'mysources', description: '', type: 'gitea', + url: 'https://mysources.co.uk', credSecretId: 'sec-1', credSecretKey: 'TOKEN', + ownerId: 'user-1', version: 1, createdAt: new Date(), updatedAt: new Date(), +} as GitProvider; + +function svc(opts: { existing?: CodeRepo[]; discovered?: string[]; empty?: string[] } = {}) { + const existing = opts.existing ?? []; + const created: CodeRepo[] = []; + + const providerRepo = { + findAll: vi.fn(async () => [PROVIDER]), + findById: vi.fn(async () => PROVIDER), + findByName: vi.fn(async () => null), + create: vi.fn(async () => PROVIDER), + update: vi.fn(async () => PROVIDER), + delete: vi.fn(async () => {}), + } as unknown as IGitProviderRepository & Record>; + + const codeRepos = { + findAll: vi.fn(async () => existing), + findById: vi.fn(async () => null), + findByName: vi.fn(async () => null), + findByNameAndProject: vi.fn(async (name: string) => + existing.find((r) => r.name === name) ?? null), + create: vi.fn(async (d: Record) => { + const row = d as unknown as CodeRepo; + created.push(row); + return row; + }), + update: vi.fn(async () => ({} as CodeRepo)), + delete: vi.fn(async () => {}), + } as unknown as ICodeRepoRepository & Record>; + + const projectRepo = { + findById: vi.fn(async () => ({ id: 'proj-1', name: 'mcpctl' } as Project)), + findByName: vi.fn(async (n: string) => (n === 'mcpctl' ? ({ id: 'proj-1', name: 'mcpctl' } as Project) : null)), + } as unknown as IProjectRepository; + + const secretRepo = { + findById: vi.fn(async () => ({ id: 'sec-1', name: 'gitea-token' } as Secret)), + findByName: vi.fn(async () => ({ id: 'sec-1', name: 'gitea-token' } as Secret)), + } as unknown as ISecretRepository; + + const secrets = { resolve: vi.fn(async () => 'tok123') } as unknown as SecretService; + + const names = opts.discovered ?? ['michal/mcpctl', 'michal/lab']; + const emptySet = new Set(opts.empty ?? []); + const registry = { + build: vi.fn(() => ({ + type: 'gitea', + probe: vi.fn(async () => ({ ok: true, detail: 'ok' })), + listRepositories: vi.fn(async () => names.map((n) => ({ + fullName: n, name: n.split('/')[1] as string, owner: n.split('/')[0] as string, + description: '', cloneUrl: `https://mysources.co.uk/${n}.git`, + sshUrl: `git@mysources.co.uk:${n}.git`, defaultBranch: 'main', + private: false, empty: emptySet.has(n), archived: false, + }))), + })), + } as unknown as GitProviderRegistry; + + return { + created, codeRepos, providerRepo, + service: new GitProviderService(providerRepo, codeRepos, projectRepo, registry, secretRepo, secrets), + }; +} + +describe('GitProviderService.discover', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('returns the live list without storing anything', async () => { + const { service, codeRepos } = svc(); + const rows = await service.discover('gp-1'); + expect(rows.map((r) => r.fullName)).toEqual(['michal/mcpctl', 'michal/lab']); + expect(codeRepos.create).not.toHaveBeenCalled(); + }); + + it('marks repos the target project already imported', async () => { + const { service } = svc({ + existing: [{ name: 'mcpctl', sourceFullName: 'michal/mcpctl' } as CodeRepo], + }); + const rows = await service.discover('gp-1', { projectId: 'proj-1' }); + expect(rows.find((r) => r.fullName === 'michal/mcpctl')?.imported).toBe(true); + expect(rows.find((r) => r.fullName === 'michal/lab')?.imported).toBe(false); + }); + + it('flags an empty repo as unavailable — there is nothing to clone', async () => { + const { service } = svc({ empty: ['michal/lab'] }); + const rows = await service.discover('gp-1'); + expect(rows.find((r) => r.fullName === 'michal/lab')?.unavailableReason).toMatch(/empty/); + }); +}); + +describe('GitProviderService.importRepos', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('imports with denormalized provenance that outlives the provider', async () => { + const { service, created } = svc(); + const result = await service.importRepos( + 'gp-1', { project: 'mcpctl', repos: ['michal/mcpctl'] }, 'user-1', + ); + + expect(result.imported).toHaveLength(1); + expect(created[0]).toMatchObject({ + name: 'mcpctl', + projectId: 'proj-1', + url: 'https://mysources.co.uk/michal/mcpctl.git', + gitProviderId: 'gp-1', + sourceProvider: 'mysources', + sourceFullName: 'michal/mcpctl', + provider: 'gitea', + }); + }); + + it('stores no credential on an imported repo — it inherits the forge\'s', async () => { + const { service, created } = svc(); + await service.importRepos('gp-1', { project: 'mcpctl', repos: ['michal/mcpctl'] }, 'user-1'); + expect(created[0]).not.toHaveProperty('credSecretId'); + }); + + it('uses ssh urls when asked', async () => { + const { service, created } = svc(); + await service.importRepos('gp-1', { project: 'mcpctl', repos: ['michal/mcpctl'], ssh: true }, 'user-1'); + expect(created[0]?.url).toBe('git@mysources.co.uk:michal/mcpctl.git'); + }); + + it('imports what it can and reports what it skipped, with reasons', async () => { + // One bad name must not lose the other nineteen. + const { service } = svc({ empty: ['michal/lab'] }); + const result = await service.importRepos( + 'gp-1', { project: 'mcpctl', repos: ['michal/mcpctl', 'michal/lab', 'michal/ghost'] }, 'user-1', + ); + + expect(result.imported.map((r) => r.name)).toEqual(['mcpctl']); + expect(result.skipped).toEqual([ + { fullName: 'michal/lab', reason: 'repository is empty — nothing to clone' }, + { fullName: 'michal/ghost', reason: 'not found on this provider' }, + ]); + }); + + it('skips a repo already imported rather than duplicating it', async () => { + const { service } = svc({ + existing: [{ name: 'mcpctl', sourceFullName: 'michal/mcpctl' } as CodeRepo], + }); + const result = await service.importRepos( + 'gp-1', { project: 'mcpctl', repos: ['michal/mcpctl'] }, 'user-1', + ); + expect(result.skipped[0]).toEqual({ fullName: 'michal/mcpctl', reason: 'already imported' }); + }); + + it('distinguishes a name collision from a re-import', async () => { + const { service } = svc({ + existing: [{ name: 'mcpctl', sourceFullName: 'someone-else/mcpctl' } as CodeRepo], + }); + const result = await service.importRepos( + 'gp-1', { project: 'mcpctl', repos: ['michal/mcpctl'] }, 'user-1', + ); + expect(result.skipped[0]?.reason).toMatch(/name 'mcpctl' is taken by another repo/); + }); + + it('404s on an unknown project', async () => { + const { service } = svc(); + await expect( + service.importRepos('gp-1', { project: 'ghost', repos: ['michal/mcpctl'] }, 'user-1'), + ).rejects.toThrow(/Project not found/); + }); + + it('requires exactly one of project or projectId', async () => { + const { service } = svc(); + await expect(service.importRepos('gp-1', { repos: ['x'] }, 'u')).rejects.toThrow(); + await expect( + service.importRepos('gp-1', { project: 'mcpctl', projectId: 'proj-1', repos: ['x'] }, 'u'), + ).rejects.toThrow(); + }); +});