agentic-teams: epics, tasks, git forges, agent authority #139
@@ -2797,9 +2797,9 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"119"
|
"119"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T15:42:40.442Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "133",
|
"id": "133",
|
||||||
@@ -2811,9 +2811,9 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"132"
|
"132"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T15:42:40.452Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "134",
|
"id": "134",
|
||||||
@@ -3247,13 +3247,27 @@
|
|||||||
"status": "pending",
|
"status": "pending",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"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": {
|
"metadata": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastModified": "2026-08-08T15:28:26.705Z",
|
"lastModified": "2026-08-08T15:42:40.452Z",
|
||||||
"taskCount": 162,
|
"taskCount": 163,
|
||||||
"completedCount": 116,
|
"completedCount": 119,
|
||||||
"tags": [
|
"tags": [
|
||||||
"master"
|
"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[]
|
ownedTasks Task[]
|
||||||
ownedRepos CodeRepo[]
|
ownedRepos CodeRepo[]
|
||||||
ownedSessions AgentSession[]
|
ownedSessions AgentSession[]
|
||||||
|
gitProviders GitProvider[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
}
|
}
|
||||||
@@ -178,6 +179,7 @@ model Secret {
|
|||||||
llms Llm[]
|
llms Llm[]
|
||||||
// Agentic teams: git-provider credentials for CodeRepo.
|
// Agentic teams: git-provider credentials for CodeRepo.
|
||||||
codeRepos CodeRepo[]
|
codeRepos CodeRepo[]
|
||||||
|
gitProviders GitProvider[]
|
||||||
|
|
||||||
@@index([name])
|
@@index([name])
|
||||||
@@index([backendId])
|
@@index([backendId])
|
||||||
@@ -1139,8 +1141,19 @@ model CodeRepo {
|
|||||||
defaultBranch String @default("main")
|
defaultBranch String @default("main")
|
||||||
// Relative path under the session workspace root. Defaults to `name`.
|
// Relative path under the session workspace root. Defaults to `name`.
|
||||||
path String @default("")
|
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")
|
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
|
// Secret holding the provider credential, resolved through SecretService at
|
||||||
// session-provision time — never stored in plaintext on the row.
|
// session-provision time — never stored in plaintext on the row.
|
||||||
credSecretId String?
|
credSecretId String?
|
||||||
@@ -1152,6 +1165,9 @@ model CodeRepo {
|
|||||||
|
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
cred Secret? @relation(fields: [credSecretId], references: [id])
|
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)
|
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
|
|
||||||
@@ -1225,3 +1241,35 @@ enum SessionState {
|
|||||||
ended
|
ended
|
||||||
failed
|
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 { registerEpicRoutes } from './routes/epics.js';
|
||||||
import { registerEpicTaskRoutes } from './routes/epictasks.js';
|
import { registerEpicTaskRoutes } from './routes/epictasks.js';
|
||||||
import { registerTaskRoutes } from './routes/tasks.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 { registerSkillRoutes } from './routes/skills.js';
|
||||||
import { registerGitBackupRoutes } from './routes/git-backup.js';
|
import { registerGitBackupRoutes } from './routes/git-backup.js';
|
||||||
import { PromptService } from './services/prompt.service.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 { EpicRepository } from './repositories/epic.repository.js';
|
||||||
import { EpicTaskRepository } from './repositories/epic-task.repository.js';
|
import { EpicTaskRepository } from './repositories/epic-task.repository.js';
|
||||||
import { TaskRepository } from './repositories/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 { SkillRepository } from './repositories/skill.repository.js';
|
||||||
import { EpicService } from './services/epic.service.js';
|
import { EpicService } from './services/epic.service.js';
|
||||||
import { EpicTaskService } from './services/epic-task.service.js';
|
import { EpicTaskService } from './services/epic-task.service.js';
|
||||||
import { TaskService } from './services/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 { SkillService } from './services/skill.service.js';
|
||||||
import { GitBackupService } from './services/backup/git-backup.service.js';
|
import { GitBackupService } from './services/backup/git-backup.service.js';
|
||||||
import type { BackupKind } from './services/backup/yaml-serializer.js';
|
import type { BackupKind } from './services/backup/yaml-serializer.js';
|
||||||
@@ -235,6 +242,8 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
|||||||
'epictasks': 'epictasks',
|
'epictasks': 'epictasks',
|
||||||
// Work items. NOT inference-tasks — see the rbac-tasks-backfill bootstrap.
|
// Work items. NOT inference-tasks — see the rbac-tasks-backfill bootstrap.
|
||||||
'tasks': 'tasks',
|
'tasks': 'tasks',
|
||||||
|
'repos': 'repos',
|
||||||
|
'gitproviders': 'gitproviders',
|
||||||
};
|
};
|
||||||
|
|
||||||
const resource = resourceMap[segment];
|
const resource = resourceMap[segment];
|
||||||
@@ -446,6 +455,8 @@ async function main(): Promise<void> {
|
|||||||
const epicRepo = new EpicRepository(prisma);
|
const epicRepo = new EpicRepository(prisma);
|
||||||
const epicTaskRepo = new EpicTaskRepository(prisma);
|
const epicTaskRepo = new EpicTaskRepository(prisma);
|
||||||
const workTaskRepo = new TaskRepository(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
|
// SecretBackend bootstrap: ensure a `plaintext` default row exists and any
|
||||||
// pre-existing `Secret` rows are pointed at it. Idempotent per run.
|
// pre-existing `Secret` rows are pointed at it. Idempotent per run.
|
||||||
@@ -465,6 +476,8 @@ async function main(): Promise<void> {
|
|||||||
epics: epicRepo,
|
epics: epicRepo,
|
||||||
epictasks: epicTaskRepo,
|
epictasks: epicTaskRepo,
|
||||||
tasks: workTaskRepo,
|
tasks: workTaskRepo,
|
||||||
|
repos: codeRepoRepo,
|
||||||
|
gitproviders: gitProviderRepo,
|
||||||
// Resolve user CUID → email so name-scoped `edit:users:<email>` bindings
|
// Resolve user CUID → email so name-scoped `edit:users:<email>` bindings
|
||||||
// match on PUT /api/v1/users/:id/password (admin reset).
|
// match on PUT /api/v1/users/:id/password (admin reset).
|
||||||
users: { findById: async (id: string) => {
|
users: { findById: async (id: string) => {
|
||||||
@@ -574,6 +587,12 @@ async function main(): Promise<void> {
|
|||||||
epicService.setRevisionService(resourceRevisionService);
|
epicService.setRevisionService(resourceRevisionService);
|
||||||
const epicTaskService = new EpicTaskService(epicTaskRepo, epicRepo);
|
const epicTaskService = new EpicTaskService(epicTaskRepo, epicRepo);
|
||||||
const workTaskService = new TaskService(workTaskRepo, epicTaskRepo);
|
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 personalityRepo = new PersonalityRepository(prisma);
|
||||||
const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo);
|
const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo);
|
||||||
const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo);
|
const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo);
|
||||||
@@ -785,6 +804,8 @@ async function main(): Promise<void> {
|
|||||||
registerEpicRoutes(app, epicService, projectRepo);
|
registerEpicRoutes(app, epicService, projectRepo);
|
||||||
registerEpicTaskRoutes(app, epicTaskService, epicRepo, epicTaskRepo, projectRepo);
|
registerEpicTaskRoutes(app, epicTaskService, epicRepo, epicTaskRepo, projectRepo);
|
||||||
registerTaskRoutes(app, workTaskService, epicTaskRepo);
|
registerTaskRoutes(app, workTaskService, epicTaskRepo);
|
||||||
|
registerCodeRepoRoutes(app, codeRepoService, projectRepo);
|
||||||
|
registerGitProviderRoutes(app, gitProviderService, projectRepo);
|
||||||
|
|
||||||
// ── Git-based backup ──
|
// ── Git-based backup ──
|
||||||
const gitBackup = new GitBackupService(prisma);
|
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';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const;
|
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. */
|
/** Singular→plural map for resource names. */
|
||||||
const RESOURCE_ALIASES: Record<string, string> = {
|
const RESOURCE_ALIASES: Record<string, string> = {
|
||||||
@@ -38,6 +38,8 @@ const RESOURCE_ALIASES: Record<string, string> = {
|
|||||||
repository: 'repos',
|
repository: 'repos',
|
||||||
repositories: 'repos',
|
repositories: 'repos',
|
||||||
session: 'sessions',
|
session: 'sessions',
|
||||||
|
gitprovider: 'gitproviders',
|
||||||
|
forge: 'gitproviders',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Normalize a resource name to its canonical plural form. */
|
/** 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