feat(mcpd): CodeRepo + GitProvider — register a forge, discover and import repos
Two resources landing together, because a repo you have to hand-type is a repo nobody adds. CodeRepo is a git repository attached to a project. GitProvider is a configured forge instance you register once — URL and credential in one place — and then list and import from. Nothing above the driver layer knows what a Gitea is. src/mcpd/src/services/git/ holds the GitProviderDriver interface, the Gitea implementation and a registry that resolves by type, mirroring LlmAdapterRegistry.build. Adding GitHub or GitLab is one file plus one case. The instance URL always comes from the GitProvider row; there is no hardcoded host anywhere. Follows the mcpservertemplates convention: a declarative resource applied via apply -f, with instances keeping denormalized provenance that outlives the source. CodeRepo stores sourceProvider + sourceFullName alongside the FK, so "where did this repo come from" stays answerable after the forge is disconnected — the same reasoning as McpServer.templateName/templateVersion. The FK is SetNull, so disconnecting a forge never deletes the repos, or the tasks hanging off them, that came from it. Discovery uses /repos/search, deliberately NOT /user/repos. The latter needs the read:user scope, which repo-scoped automation tokens do not carry — the token in this repo's .env has write:repository and 403s on /user/repos. The driver also passes Gitea's 403 scope message through verbatim, so an under-scoped token says which scope it is missing instead of looking like an empty forge. Import is partial-success by contract: one bad name must not lose the other nineteen. Everything importable is imported; everything skipped comes back with a reason, and a re-import is distinguished from a name collision with an unrelated repo. Imported repos store no credential of their own and inherit the forge's, so rotating one token covers every repo from that forge. Credentials never leave as plaintext: reads return the reference only, and resolveCheckouts() — the single path that produces a token — exists for the session provisioner and is called by no route. References are verified at definition time, so a wrong key fails with "secret has no key 'X'" rather than as a clone that mysteriously cannot authenticate. Clone URLs must be https/ssh/scp-style and checkout paths must be relative with no ".." segment: both are inputs to the clone step, and neither should be able to point it at mcpd's own filesystem or escape the workspace. Tests: 18 CodeRepo + 22 GitProvider unit tests. Gitea driver additionally verified live against https://mysources.co.uk (Gitea 1.25.4, 10 repos discovered, query filter working). Local: build clean, workspace 2440 passed (+40), db 128 passed (same 4 pre-existing Secret failures), lint unchanged at 869. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
This commit is contained in:
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
epics: epicRepo,
|
||||
epictasks: epicTaskRepo,
|
||||
tasks: workTaskRepo,
|
||||
repos: codeRepoRepo,
|
||||
gitproviders: gitProviderRepo,
|
||||
// Resolve user CUID → email so name-scoped `edit:users:<email>` bindings
|
||||
// match on PUT /api/v1/users/:id/password (admin reset).
|
||||
users: { findById: async (id: string) => {
|
||||
@@ -574,6 +587,12 @@ async function main(): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
92
src/mcpd/src/repositories/code-repo.repository.ts
Normal file
92
src/mcpd/src/repositories/code-repo.repository.ts
Normal file
@@ -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<CodeRepo[]>;
|
||||
findById(id: string): Promise<CodeRepo | null>;
|
||||
findByName(name: string): Promise<CodeRepo | null>;
|
||||
findByNameAndProject(name: string, projectId: string): Promise<CodeRepo | null>;
|
||||
create(data: CodeRepoCreateInput): Promise<CodeRepo>;
|
||||
update(id: string, data: CodeRepoUpdateInput): Promise<CodeRepo>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
const REPO_INCLUDE = { project: { select: { name: true } } } as const;
|
||||
|
||||
export class CodeRepoRepository implements ICodeRepoRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async findAll(projectId?: string): Promise<CodeRepo[]> {
|
||||
return this.prisma.codeRepo.findMany({
|
||||
...(projectId !== undefined ? { where: { projectId } } : {}),
|
||||
include: REPO_INCLUDE,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<CodeRepo | null> {
|
||||
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<CodeRepo | null> {
|
||||
return this.prisma.codeRepo.findFirst({ where: { name }, include: REPO_INCLUDE });
|
||||
}
|
||||
|
||||
async findByNameAndProject(name: string, projectId: string): Promise<CodeRepo | null> {
|
||||
return this.prisma.codeRepo.findUnique({
|
||||
where: { name_projectId: { name, projectId } },
|
||||
include: REPO_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: CodeRepoCreateInput): Promise<CodeRepo> {
|
||||
return this.prisma.codeRepo.create({ data, include: REPO_INCLUDE });
|
||||
}
|
||||
|
||||
async update(id: string, data: CodeRepoUpdateInput): Promise<CodeRepo> {
|
||||
return this.prisma.codeRepo.update({
|
||||
where: { id },
|
||||
data: { ...data, version: { increment: 1 } },
|
||||
include: REPO_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.codeRepo.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
61
src/mcpd/src/repositories/git-provider.repository.ts
Normal file
61
src/mcpd/src/repositories/git-provider.repository.ts
Normal file
@@ -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<GitProvider[]>;
|
||||
findById(id: string): Promise<GitProvider | null>;
|
||||
findByName(name: string): Promise<GitProvider | null>;
|
||||
create(data: GitProviderCreateInput): Promise<GitProvider>;
|
||||
update(id: string, data: GitProviderUpdateInput): Promise<GitProvider>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class GitProviderRepository implements IGitProviderRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async findAll(): Promise<GitProvider[]> {
|
||||
return this.prisma.gitProvider.findMany({ orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<GitProvider | null> {
|
||||
return this.prisma.gitProvider.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<GitProvider | null> {
|
||||
return this.prisma.gitProvider.findUnique({ where: { name } });
|
||||
}
|
||||
|
||||
async create(data: GitProviderCreateInput): Promise<GitProvider> {
|
||||
return this.prisma.gitProvider.create({ data });
|
||||
}
|
||||
|
||||
async update(id: string, data: GitProviderUpdateInput): Promise<GitProvider> {
|
||||
return this.prisma.gitProvider.update({
|
||||
where: { id },
|
||||
data: { ...data, version: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.gitProvider.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
77
src/mcpd/src/routes/gitproviders.ts
Normal file
77
src/mcpd/src/routes/gitproviders.ts
Normal file
@@ -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);
|
||||
});
|
||||
}
|
||||
67
src/mcpd/src/routes/repos.ts
Normal file
67
src/mcpd/src/routes/repos.ts
Normal file
@@ -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<string> {
|
||||
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<string, unknown>;
|
||||
const resolved: Record<string, unknown> = { ...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));
|
||||
});
|
||||
}
|
||||
189
src/mcpd/src/services/code-repo.service.ts
Normal file
189
src/mcpd/src/services/code-repo.service.ts
Normal file
@@ -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<CodeRepo[]> {
|
||||
return this.repo.findAll(projectId);
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CodeRepo> {
|
||||
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<CodeRepo> {
|
||||
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<CodeRepo> {
|
||||
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<void> {
|
||||
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<ResolvedCheckout[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
283
src/mcpd/src/services/git-provider.service.ts
Normal file
283
src/mcpd/src/services/git-provider.service.ts
Normal file
@@ -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<GitProvider[]> {
|
||||
return this.repo.findAll();
|
||||
}
|
||||
|
||||
async get(id: string): Promise<GitProvider> {
|
||||
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<GitProvider> {
|
||||
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<GitProvider> {
|
||||
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<void> {
|
||||
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<DiscoveredRepoView[]> {
|
||||
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<ImportResult> {
|
||||
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<string> {
|
||||
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<GitProviderContext> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
134
src/mcpd/src/services/git/gitea.driver.ts
Normal file
134
src/mcpd/src/services/git/gitea.driver.ts
Normal file
@@ -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<T>(ctx: GitProviderContext, path: string): Promise<T> {
|
||||
const base = ctx.url.replace(/\/+$/, '');
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
// Gitea's scheme is `token <value>`, 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<DiscoveredRepo[]> {
|
||||
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<GiteaSearchResponse>(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,
|
||||
};
|
||||
}
|
||||
41
src/mcpd/src/services/git/registry.ts
Normal file
41
src/mcpd/src/services/git/registry.ts
Normal file
@@ -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<string, GitProviderDriver>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
64
src/mcpd/src/services/git/types.ts
Normal file
64
src/mcpd/src/services/git/types.ts
Normal file
@@ -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<DiscoveredRepo[]>;
|
||||
/** 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';
|
||||
}
|
||||
}
|
||||
57
src/mcpd/src/validation/code-repo.schema.ts
Normal file
57
src/mcpd/src/validation/code-repo.schema.ts
Normal file
@@ -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<typeof CreateCodeRepoSchema>;
|
||||
export type UpdateCodeRepoInput = z.infer<typeof UpdateCodeRepoSchema>;
|
||||
export type CredRef = z.infer<typeof CredRefSchema>;
|
||||
45
src/mcpd/src/validation/git-provider.schema.ts
Normal file
45
src/mcpd/src/validation/git-provider.schema.ts
Normal file
@@ -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<typeof CreateGitProviderSchema>;
|
||||
@@ -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<string, string> = {
|
||||
@@ -38,6 +38,8 @@ const RESOURCE_ALIASES: Record<string, string> = {
|
||||
repository: 'repos',
|
||||
repositories: 'repos',
|
||||
session: 'sessions',
|
||||
gitprovider: 'gitproviders',
|
||||
forge: 'gitproviders',
|
||||
};
|
||||
|
||||
/** Normalize a resource name to its canonical plural form. */
|
||||
|
||||
201
src/mcpd/tests/services/code-repo-service.test.ts
Normal file
201
src/mcpd/tests/services/code-repo-service.test.ts
Normal file
@@ -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> = {}): 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<string, unknown>) => makeRepo(d as Partial<CodeRepo>)),
|
||||
update: vi.fn(async (_id: string, d: Record<string, unknown>) => makeRepo(d as Partial<CodeRepo>)),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ICodeRepoRepository & Record<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
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<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
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<typeof vi.fn> };
|
||||
|
||||
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<typeof vi.fn>).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<string, unknown>;
|
||||
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<typeof vi.fn>).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');
|
||||
});
|
||||
});
|
||||
342
src/mcpd/tests/services/git-provider.test.ts
Normal file
342
src/mcpd/tests/services/git-provider.test.ts
Normal file
@@ -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<string, unknown> = {}) => ({
|
||||
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<string, string>)['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<string, string> = {};
|
||||
globalThis.fetch = mockFetch((_url, init) => {
|
||||
headers = init.headers as Record<string, string>;
|
||||
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<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
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<string, unknown>) => {
|
||||
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<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user