Initial curated projectbrain source from 8a7b1e0651dbdab97d744cbd2e653ab3970ac6c5
This commit is contained in:
commit
5aa3e8d685
700 files changed
+132801
No files matched your search
@@ -0,0 +1,799 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import type { AppEnv } from "../config/env.js";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import { GiteaApiError } from "../integrations/gitea/client.js";
|
||||
import { assertChatGptBranch, assertExpectedFileSha, assertFileSize, assertReadablePath, assertSuccessfulActionRuns, assertWritablePath, exactHeadActionRuns, findReusableDeliveryPullRequest } from "../integrations/gitea/policy.js";
|
||||
import { finishExternalOperation, getGiteaClient, listLinkedRepositories, markExternalOperationExecuting, prepareExternalOperation, recordMcpAudit, type LinkedRepository } from "../integrations/gitea/service.js";
|
||||
import type { GiteaContent } from "../integrations/gitea/types.js";
|
||||
import { assertFeatureDeliveryActionEvidence, canSyncFeatureDeliveryValidation, getFeatureDelivery, listFeatureDeliveries, openFeatureDeliveryPullRequest, reconcileFeatureDeliveryPullRequest, recordFeatureDeliveryActionValidation, recordFeatureDeliveryProgress, startFeatureDelivery, updateFeatureDelivery } from "../repositories/featureDeliveryRepository.js";
|
||||
import { createBrainstormForProject, listBrainstormsForProject } from "../repositories/brainstormRepository.js";
|
||||
import { createContextPackForProject, listContextPacksForProject } from "../repositories/contextPacksRepository.js";
|
||||
import { createDocumentationItem, createKnowledgeItem, createMemoryRuleItem, listDocumentationForProject, listKnowledgeForProject, listMemoryRulesForProject } from "../repositories/p8KnowledgeMemoryRepository.js";
|
||||
import { listProjects } from "../repositories/projectsRepository.js";
|
||||
import { listReviewItemsFromDatabase } from "../repositories/reviewQueueRepository.js";
|
||||
import { importCodexOutputInDatabase } from "../repositories/p7RuntimeRepository.js";
|
||||
import { PROJECTBRAIN_WORKSPACE_HTML, PROJECTBRAIN_WORKSPACE_MIME_TYPE, PROJECTBRAIN_WORKSPACE_URI } from "./widget.js";
|
||||
import { resolveProjectContext } from "../services/contextResolver.js";
|
||||
import { cancelAnalysisJob, createPortfolioAnalysisJob, getAnalysisJob, getAnalysisJobResults, listAnalysisJobs } from "../repositories/analysisJobRepository.js";
|
||||
import { PROJECTBRAIN_ANALYSIS_WIDGET_HTML, PROJECTBRAIN_ANALYSIS_WIDGET_MIME_TYPE, PROJECTBRAIN_ANALYSIS_WIDGET_URI } from "./analysisWidget.js";
|
||||
import { createDeliveryFromFindingInvestigation, getFindingInvestigation, getRepositoryFinding, listRepositoryFindings, queueFindingVerificationForDelivery, saveFindingInvestigationConclusion, startFindingInvestigation, suppressRepositoryFinding, syncFindingIssue } from "../repositories/findingRepository.js";
|
||||
import { excludePortfolioRepository, getPortfolioControlPlane, listPortfolioRepositories, reconcilePortfolioRepositories, restorePortfolioRepository } from "../services/portfolioControlPlane.js";
|
||||
import { assertFeatureChangesetBudget, featureChangesetOperationSchema, mapBounded } from "./featureChangeset.js";
|
||||
import { asCompactResult, asResult, asStandardKnowledgeResult, compactDelivery, compactRecord, decodeFile, errorCode, operationRecovery, pageCollection, parseFetchId, type ToolResult } from "./resultHelpers.js";
|
||||
import { assertFindingProject, assertInvestigationProject, PartialExternalMutationError, requireLink } from "./scopeGuards.js";
|
||||
import { assertDeliveryWriteAllowed, cleanupDeliveryBranch, deliveryContinuation } from "../services/featureDeliveryFlow.js";
|
||||
import { createOrResumeAutonomyDelivery } from "../services/autonomyDelivery.js";
|
||||
import { refreshDeliveryFromGitea, resolveReconciledProjectContext } from "../services/deliveryReconciliation.js";
|
||||
|
||||
const withAudit = async (input: { actor: string; tool: string; projectId?: string; branch?: string; repositoryId?: string; deliveryId?: string }, operation: (link?: LinkedRepository) => Promise<ToolResult>, write = false): Promise<ToolResult> => {
|
||||
const started = Date.now();
|
||||
let link: LinkedRepository | undefined;
|
||||
let externalOperationId: string | undefined;
|
||||
let externalMutationReturned = false;
|
||||
let externalOperationFinished = false;
|
||||
try {
|
||||
const deliveryRepositoryId = input.projectId && input.deliveryId ? (await getFeatureDelivery(input.projectId, input.deliveryId)).spec.repositoryId : undefined;
|
||||
link = input.projectId ? await requireLink(input.projectId, write, input.repositoryId ?? deliveryRepositoryId) : undefined;
|
||||
if (write && link) {
|
||||
const prepared = await prepareExternalOperation({
|
||||
projectId: link.projectId, actor: input.actor, action: input.tool,
|
||||
target: { repository: link.fullName, branch: input.branch },
|
||||
});
|
||||
externalOperationId = prepared.operationId;
|
||||
await markExternalOperationExecuting(externalOperationId);
|
||||
}
|
||||
const result = await operation(link);
|
||||
externalMutationReturned = write;
|
||||
if (externalOperationId) {
|
||||
await finishExternalOperation(externalOperationId, { status: "succeeded" });
|
||||
externalOperationFinished = true;
|
||||
}
|
||||
const audit = recordMcpAudit({ operationId: externalOperationId, projectId: link?.projectId, actor: input.actor, toolName: input.tool, repository: link?.fullName, branch: input.branch, outcome: "succeeded", durationMs: Date.now() - started });
|
||||
if (write) await audit; else await audit.catch(() => undefined);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof PartialExternalMutationError) externalMutationReturned = true;
|
||||
const code = errorCode(error);
|
||||
if (externalOperationId && !externalOperationFinished) await finishExternalOperation(externalOperationId, { status: externalMutationReturned ? "unknown" : "failed", errorCode: code }).catch(() => undefined);
|
||||
await recordMcpAudit({ operationId: externalOperationId, projectId: link?.projectId, actor: input.actor, toolName: input.tool, repository: link?.fullName, branch: input.branch, outcome: ["GITEA_REPOSITORY_READ_ONLY", "MCP_BRANCH_FORBIDDEN", "DEFAULT_BRANCH_WRITE_FORBIDDEN", "SENSITIVE_PATH_FORBIDDEN", "REPOSITORY_PATH_FORBIDDEN"].includes(code) ? "denied" : "failed", errorCode: code, durationMs: Date.now() - started }).catch(() => undefined);
|
||||
return { content: [{ type: "text", text: `Operation failed: ${code}` }], structuredContent: { error: code, recovery: operationRecovery(code) }, isError: true };
|
||||
}
|
||||
};
|
||||
|
||||
export const createProjectBrainMcpServer = (env: AppEnv, actor: string) => {
|
||||
const server = new McpServer({ name: "ProjectBrain", version: "0.7.1", websiteUrl: env.PROJECTBRAIN_PUBLIC_URL });
|
||||
const client = getGiteaClient(env);
|
||||
|
||||
server.registerResource("ProjectBrain workspace", PROJECTBRAIN_WORKSPACE_URI, {
|
||||
title: "ProjectBrain workspace",
|
||||
description: "Interactive project-memory, review, and feature-delivery workspace.",
|
||||
mimeType: PROJECTBRAIN_WORKSPACE_MIME_TYPE,
|
||||
_meta: {
|
||||
ui: {
|
||||
prefersBorder: true,
|
||||
domain: env.PROJECTBRAIN_PUBLIC_URL,
|
||||
csp: { connectDomains: [], resourceDomains: [] },
|
||||
},
|
||||
"openai/widgetDescription": "Shows durable ProjectBrain context, pending reviews, and active feature deliveries.",
|
||||
},
|
||||
}, async () => ({ contents: [{
|
||||
uri: PROJECTBRAIN_WORKSPACE_URI,
|
||||
mimeType: PROJECTBRAIN_WORKSPACE_MIME_TYPE,
|
||||
text: PROJECTBRAIN_WORKSPACE_HTML,
|
||||
_meta: {
|
||||
ui: {
|
||||
prefersBorder: true,
|
||||
domain: env.PROJECTBRAIN_PUBLIC_URL,
|
||||
csp: { connectDomains: [], resourceDomains: [] },
|
||||
},
|
||||
"openai/widgetDescription": "Shows durable ProjectBrain context, pending reviews, and active feature deliveries.",
|
||||
},
|
||||
}] }));
|
||||
|
||||
server.registerResource("ProjectBrain repository analysis", PROJECTBRAIN_ANALYSIS_WIDGET_URI, {
|
||||
title: "ProjectBrain repository analysis",
|
||||
description: "Compact progress and result summary for a durable repository-analysis job.",
|
||||
mimeType: PROJECTBRAIN_ANALYSIS_WIDGET_MIME_TYPE,
|
||||
_meta: {
|
||||
ui: { prefersBorder: true, domain: env.PROJECTBRAIN_PUBLIC_URL, csp: { connectDomains: [], resourceDomains: [] } },
|
||||
"openai/widgetDescription": "Shows bounded progress for a server-side ProjectBrain repository analysis.",
|
||||
},
|
||||
}, async () => ({ contents: [{
|
||||
uri: PROJECTBRAIN_ANALYSIS_WIDGET_URI,
|
||||
mimeType: PROJECTBRAIN_ANALYSIS_WIDGET_MIME_TYPE,
|
||||
text: PROJECTBRAIN_ANALYSIS_WIDGET_HTML,
|
||||
_meta: {
|
||||
ui: { prefersBorder: true, domain: env.PROJECTBRAIN_PUBLIC_URL, csp: { connectDomains: [], resourceDomains: [] } },
|
||||
"openai/widgetDescription": "Shows bounded progress for a server-side ProjectBrain repository analysis.",
|
||||
},
|
||||
}] }));
|
||||
|
||||
server.registerTool("list_projects", {
|
||||
title: "List ProjectBrain projects", description: "Use this to choose a ProjectBrain project. Results are paginated so the complete portfolio is never returned to the browser at once.", inputSchema: { cursor: z.string().regex(/^\d+$/).default("0"), page_size: z.number().int().min(1).max(25).default(10) },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ cursor, page_size }) => withAudit({ actor, tool: "list_projects" }, async () => {
|
||||
const page = pageCollection(await listProjects(), Number.parseInt(cursor, 10), page_size);
|
||||
return asCompactResult({ projects: page.items, total: page.total, nextCursor: page.nextCursor }, `${page.items.length} project(en) geladen.${page.nextCursor ? ` Volgende cursor: ${page.nextCursor}.` : ""}`);
|
||||
}));
|
||||
|
||||
server.registerTool("list_project_repositories", {
|
||||
title: "List ProjectBrain repositories", description: "Use this to find a linked Gitea repository and access mode. Results are paginated for browser-safe portfolio growth.", inputSchema: { cursor: z.string().regex(/^\d+$/).default("0"), page_size: z.number().int().min(1).max(25).default(10) },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ cursor, page_size }) => withAudit({ actor, tool: "list_project_repositories" }, async () => {
|
||||
const page = pageCollection(await listLinkedRepositories(), Number.parseInt(cursor, 10), page_size);
|
||||
return asCompactResult({ repositories: page.items, total: page.total, nextCursor: page.nextCursor }, `${page.items.length} repositorykoppeling(en) geladen.${page.nextCursor ? ` Volgende cursor: ${page.nextCursor}.` : ""}`);
|
||||
}));
|
||||
|
||||
server.registerTool("get_portfolio_control_plane", {
|
||||
title: "Get ProjectBrain portfolio control plane",
|
||||
description: "Use this first whenever the user asks what needs attention across projects. It returns one recommended next action, a short actionable queue, live notifications, repository/project coverage and compact analysis state without loading repository contents.",
|
||||
inputSchema: {},
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
||||
}, async () => withAudit({ actor, tool: "get_portfolio_control_plane" }, async () => {
|
||||
const { repositories: _repositories, projects: _projects, ...summary } = await getPortfolioControlPlane(env);
|
||||
return asCompactResult(summary as unknown as Record<string, unknown>, `Portfolio geladen: volgende actie is '${summary.nextAction.label}'. ${summary.coverage.onboardedRepositories}/${summary.coverage.visibleRepositories} zichtbare repositories zijn gekoppeld; ${summary.findings.active} actieve finding(s).`);
|
||||
}));
|
||||
|
||||
server.registerTool("list_portfolio_repositories", {
|
||||
title: "List ProjectBrain portfolio repositories",
|
||||
description: "List one bounded page of Gitea repositories and their durable ProjectBrain onboarding status. Use get_portfolio_control_plane first and paginate only when repository-level detail is needed.",
|
||||
inputSchema: { status: z.enum(["discovered", "onboarded", "excluded", "unavailable"]).optional(), cursor: z.string().regex(/^\d+$/).default("0"), page_size: z.number().int().min(1).max(25).default(10) },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
||||
}, async ({ status, cursor, page_size }) => withAudit({ actor, tool: "list_portfolio_repositories" }, async () => {
|
||||
const repositories = (await listPortfolioRepositories(env)).filter((repository) => !status || repository.status === status);
|
||||
const page = pageCollection(repositories, Number.parseInt(cursor, 10), page_size);
|
||||
return asCompactResult(page as unknown as Record<string, unknown>, `${page.items.length} repositoryrecords geladen.${page.nextCursor ? ` Volgende cursor: ${page.nextCursor}.` : ""}`);
|
||||
}));
|
||||
|
||||
server.registerTool("reconcile_portfolio_repositories", {
|
||||
title: "Reconcile ProjectBrain portfolio repositories",
|
||||
description: "Preview or apply idempotent onboarding for newly discovered Gitea repositories. Applying creates or matches a ProjectBrain project, configures the signed webhook, grants the available read/write mode and enables conservative critical-finding issue automation.",
|
||||
inputSchema: { apply: z.boolean().default(false), repository_full_names: z.array(z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/)).max(50).optional() },
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
||||
_meta: { "openai/toolInvocation/invoking": "Portfolio reconciliëren…", "openai/toolInvocation/invoked": "Portfolio gereconcilieerd" },
|
||||
}, async ({ apply, repository_full_names }) => withAudit({ actor, tool: "reconcile_portfolio_repositories" }, async () => {
|
||||
const result = await reconcilePortfolioRepositories(env, actor, { dryRun: !apply, repositoryFullNames: repository_full_names });
|
||||
return asCompactResult(result as unknown as Record<string, unknown>, apply ? `${result.onboardedCount} repository(s) gekoppeld; ${result.failedCount} mislukt.` : `${result.candidateCount} repository(s) klaar voor onboarding. Roep opnieuw aan met apply=true na bevestiging.`);
|
||||
}));
|
||||
|
||||
server.registerTool("set_portfolio_repository_exclusion", {
|
||||
title: "Set ProjectBrain repository onboarding exclusion",
|
||||
description: "Exclude a discovered repository from current and future automatic onboarding, or restore it. Excluding an already onboarded repository is refused; unlinking remains a separate owner operation.",
|
||||
inputSchema: { repository_id: z.string().min(1), excluded: z.boolean(), reason: z.string().trim().min(3).max(2_000).optional() },
|
||||
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ repository_id, excluded, reason }) => withAudit({ actor, tool: "set_portfolio_repository_exclusion" }, async () => {
|
||||
if (excluded && !reason) throw new Error("PORTFOLIO_EXCLUSION_REASON_REQUIRED");
|
||||
const repository = excluded ? await excludePortfolioRepository(repository_id, reason!) : await restorePortfolioRepository(repository_id);
|
||||
return asCompactResult({ repository } as unknown as Record<string, unknown>, `${repository.fullName} is ${excluded ? "uitgesloten van" : "hersteld voor"} automatische onboarding.`);
|
||||
}));
|
||||
|
||||
server.registerTool("search", {
|
||||
title: "Search ProjectBrain repositories", description: "Search code in all Gitea repositories linked to ProjectBrain. Use fetch with a returned id to read the complete file.",
|
||||
inputSchema: { query: z.string().trim().min(2).max(200).describe("Plain-text code search query") },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ query }) => withAudit({ actor, tool: "search" }, async () => {
|
||||
const links = await listLinkedRepositories();
|
||||
const batches = await mapBounded(links, 6, async (link) => {
|
||||
try {
|
||||
const response = await client.searchCode(link.owner, link.repository, query, link.defaultBranch);
|
||||
const rows = Array.isArray(response) ? response : response.data ?? [];
|
||||
return rows.map((row) => ({ id: `gitea://${encodeURIComponent(link.projectSlug)}/${encodeURIComponent(link.defaultBranch)}/${encodeURIComponent(row.path)}`, title: `${link.fullName}: ${row.path}`, url: row.html_url }));
|
||||
} catch { return []; }
|
||||
});
|
||||
const results = batches.flat().slice(0, 50);
|
||||
return asStandardKnowledgeResult({ results });
|
||||
}));
|
||||
|
||||
server.registerTool("fetch", {
|
||||
title: "Fetch repository file", description: "Fetch a complete repository file by the canonical gitea:// id returned by search.",
|
||||
inputSchema: { id: z.string().startsWith("gitea://").describe("Canonical gitea://project/ref/path identifier") },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ id }) => {
|
||||
let parsed: ReturnType<typeof parseFetchId>;
|
||||
try { parsed = parseFetchId(id); } catch (error) { return asResult({ error: errorCode(error) }, "Invalid fetch id"); }
|
||||
return withAudit({ actor, tool: "fetch", projectId: parsed.projectId, branch: parsed.ref }, async (link) => {
|
||||
const path = assertReadablePath(parsed.path);
|
||||
const file = await client.getContents(link!.owner, link!.repository, path, parsed.ref);
|
||||
if (Array.isArray(file)) throw new Error("GITEA_CONTENT_NOT_FILE");
|
||||
return asStandardKnowledgeResult({ id, title: `${link!.fullName}: ${path}`, text: decodeFile(file), url: file.html_url, metadata: { projectId: link!.projectSlug, repository: link!.fullName, path, ref: parsed.ref, sha: file.sha, size: file.size } });
|
||||
});
|
||||
});
|
||||
|
||||
server.registerTool("get_project_context", {
|
||||
title: "Get ProjectBrain project context", description: "Use this first for one known project. It returns bounded approved context plus a stable resume state and one recommended next action; paginate only when resolver.nextCursor is present.",
|
||||
inputSchema: { project_id: z.string().min(1).describe("ProjectBrain project id or slug"), focus: z.string().trim().max(500).optional(), max_items: z.number().int().min(1).max(25).default(10), cursor: z.string().regex(/^\d+$/).optional(), character_budget: z.number().int().min(4000).max(80000).default(24000) },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ project_id, focus, max_items, cursor, character_budget }) => withAudit({ actor, tool: "get_project_context", projectId: project_id }, async (link) => {
|
||||
const context = await resolveReconciledProjectContext(client, link!.projectId, { focus, maxItems: max_items, cursor, characterBudget: character_budget });
|
||||
return asCompactResult(context as unknown as Record<string, unknown>, `Projectcontext geladen. Volgende actie: ${String((context.resume.nextAction as Record<string, unknown>).type)}.`);
|
||||
}));
|
||||
|
||||
server.registerTool("render_project_workspace", {
|
||||
title: "Open ProjectBrain workspace",
|
||||
description: "Render an interactive overview of a project's durable context, repository, pending reviews, and feature-delivery work.",
|
||||
inputSchema: { project_id: z.string().min(1).describe("ProjectBrain project id or slug") },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
_meta: { ui: { resourceUri: PROJECTBRAIN_WORKSPACE_URI }, "openai/outputTemplate": PROJECTBRAIN_WORKSPACE_URI },
|
||||
}, async ({ project_id }) => withAudit({ actor, tool: "render_project_workspace", projectId: project_id }, async (link) => {
|
||||
const project = await prisma.project.findUnique({ where: { id: link!.projectId }, select: { id: true, slug: true, name: true, description: true, updatedAt: true } });
|
||||
const [contextPackCount, deliveries, reviews] = await Promise.all([
|
||||
prisma.contextPack.count({ where: { projectId: link!.projectId, deletedAt: null } }),
|
||||
listFeatureDeliveries(project_id),
|
||||
listReviewItemsFromDatabase(project_id, { status: ["pending"] }, { page: 1, pageSize: 20 }),
|
||||
]);
|
||||
return asResult({ projectId: link!.projectSlug, project, repository: link, contextPackCount, featureDeliveries: deliveries.slice(0, 12).map(compactDelivery), pendingReviews: (reviews?.items ?? []).slice(0, 12).map(compactRecord) }, "ProjectBrain workspace");
|
||||
}));
|
||||
|
||||
server.registerTool("get_project_modules", {
|
||||
title: "Get ProjectBrain module state", description: "Load the current brainstorms, knowledge, documentation, memory rules, context packs, feature plans and pending reviews for a project. Use this to understand what already exists before adding or changing project knowledge.",
|
||||
inputSchema: { project_id: z.string().min(1), max_items: z.number().int().min(1).max(25).default(10), cursor: z.string().regex(/^\d+$/).default("0") },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ project_id, max_items, cursor }) => withAudit({ actor, tool: "get_project_modules", projectId: project_id }, async () => {
|
||||
const [brainstorms, knowledge, documentation, memoryRules, contextPacks, deliveries, reviews] = await Promise.all([
|
||||
listBrainstormsForProject(project_id),
|
||||
listKnowledgeForProject(project_id),
|
||||
listDocumentationForProject(project_id),
|
||||
listMemoryRulesForProject(project_id),
|
||||
listContextPacksForProject(project_id),
|
||||
listFeatureDeliveries(project_id),
|
||||
listReviewItemsFromDatabase(project_id, { status: ["pending"] }, { page: 1, pageSize: 25 }),
|
||||
]);
|
||||
const offset = Number.parseInt(cursor, 10);
|
||||
const compactPage = (items: unknown[]) => pageCollection(items.map(compactRecord), offset, max_items);
|
||||
return asCompactResult({ cursor, pageSize: max_items, brainstorms: compactPage(brainstorms), knowledge: compactPage(knowledge), documentation: compactPage(documentation), memoryRules: compactPage(memoryRules), contextPacks: compactPage(contextPacks ?? []), featureDeliveries: compactPage(deliveries), pendingReviews: compactPage(reviews?.items ?? []) }, "Compact, paginated ProjectBrain module inventory");
|
||||
}));
|
||||
|
||||
server.registerTool("start_repository_analysis", {
|
||||
title: "Start server-side repository analysis",
|
||||
description: "Use this when a repository or portfolio analysis would otherwise require many synchronous Gitea calls. It queues one durable, bounded server-side job and immediately returns its job id.",
|
||||
inputSchema: {
|
||||
project_ids: z.array(z.string().trim().min(1)).max(200).optional(),
|
||||
repository_ids: z.array(z.string().trim().min(1)).max(200).optional(),
|
||||
include_branches: z.boolean().default(true),
|
||||
include_pull_requests: z.boolean().default(true),
|
||||
include_actions: z.boolean().default(true),
|
||||
},
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
_meta: { "openai/toolInvocation/invoking": "Analyse in wachtrij plaatsen…", "openai/toolInvocation/invoked": "Analysejob aangemaakt" },
|
||||
}, async ({ project_ids, repository_ids, include_branches, include_pull_requests, include_actions }) => withAudit({ actor, tool: "start_repository_analysis" }, async () => {
|
||||
const result = await createPortfolioAnalysisJob(env, actor, { projectIds: project_ids, repositoryIds: repository_ids, includeBranches: include_branches, includePullRequests: include_pull_requests, includeActions: include_actions });
|
||||
const job = result.job as Record<string, unknown>;
|
||||
return asCompactResult({ job, deduplicated: result.deduplicated }, `Analysejob ${job.id} staat ${job.status === "queued" ? "in de wachtrij" : "al actief"}. Gebruik get_analysis_job voor de voortgang.`);
|
||||
}));
|
||||
|
||||
server.registerTool("list_analysis_jobs", {
|
||||
title: "List repository analysis jobs",
|
||||
description: "Use this to find recent server-side repository-analysis jobs created by the current caller.",
|
||||
inputSchema: { limit: z.number().int().min(1).max(25).default(10) },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ limit }) => withAudit({ actor, tool: "list_analysis_jobs" }, async () => {
|
||||
const jobs = await listAnalysisJobs(actor, limit);
|
||||
return asCompactResult({ jobs }, `${jobs.length} analysejob(s) gevonden.`);
|
||||
}));
|
||||
|
||||
server.registerTool("get_analysis_job", {
|
||||
title: "Get repository analysis status",
|
||||
description: "Use this to check compact progress for one analysis job. Respect pollAfterMs and repeat only while terminal=false; it never returns per-repository details.",
|
||||
inputSchema: { job_id: z.string().min(1) },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ job_id }) => withAudit({ actor, tool: "get_analysis_job" }, async () => {
|
||||
const job = await getAnalysisJob(actor, job_id) as Record<string, unknown>;
|
||||
return asCompactResult({ job }, `Analysejob ${job.id}: ${job.status}, ${job.progressCurrent}/${job.progressTotal}.`);
|
||||
}));
|
||||
|
||||
server.registerTool("get_analysis_job_results", {
|
||||
title: "Get paginated repository analysis results",
|
||||
description: "Use this only after get_analysis_job reports progress or completion. It returns a bounded page of repository results, never the entire portfolio at once.",
|
||||
inputSchema: { job_id: z.string().min(1), cursor: z.string().regex(/^\d+$/).optional(), page_size: z.number().int().min(1).max(10).default(5), character_budget: z.number().int().min(4000).max(32000).default(24000) },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ job_id, cursor, page_size, character_budget }) => withAudit({ actor, tool: "get_analysis_job_results" }, async () => {
|
||||
const result = await getAnalysisJobResults(actor, job_id, { cursor, pageSize: page_size, characterBudget: character_budget });
|
||||
return asCompactResult(result as unknown as Record<string, unknown>, `${result.items.length} begrensde repositoryresultaten geladen.${result.nextCursor ? ` Volgende cursor: ${result.nextCursor}.` : ""}`);
|
||||
}));
|
||||
|
||||
server.registerTool("cancel_analysis_job", {
|
||||
title: "Cancel repository analysis",
|
||||
description: "Use this when the user wants to stop a queued or running ProjectBrain repository-analysis job. Completed work remains available until retention expiry.",
|
||||
inputSchema: { job_id: z.string().min(1) },
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ job_id }) => withAudit({ actor, tool: "cancel_analysis_job" }, async () => {
|
||||
const job = await cancelAnalysisJob(actor, job_id) as Record<string, unknown>;
|
||||
return asCompactResult({ job }, `Annulering voor analysejob ${job.id} is verwerkt.`);
|
||||
}));
|
||||
|
||||
server.registerTool("render_analysis_job", {
|
||||
title: "Open repository analysis progress",
|
||||
description: "Render the compact progress card for an existing analysis job. Always call start_repository_analysis or get_analysis_job first.",
|
||||
inputSchema: { job_id: z.string().min(1) },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
_meta: { ui: { resourceUri: PROJECTBRAIN_ANALYSIS_WIDGET_URI }, "openai/outputTemplate": PROJECTBRAIN_ANALYSIS_WIDGET_URI },
|
||||
}, async ({ job_id }) => withAudit({ actor, tool: "render_analysis_job" }, async () => {
|
||||
const job = await getAnalysisJob(actor, job_id) as Record<string, unknown>;
|
||||
return asCompactResult({ job }, `Voortgangskaart voor analysejob ${job.id}.`);
|
||||
}));
|
||||
|
||||
server.registerTool("list_repository_findings", {
|
||||
title: "List durable repository findings",
|
||||
description: "Use this when the user wants new, active, regressed, resolved, or suppressed ProjectBrain findings. Results are risk-ranked and paginated.",
|
||||
inputSchema: {
|
||||
project_id: z.string().min(1).optional(),
|
||||
statuses: z.array(z.enum(["new", "triaged", "issue_open", "investigating", "planned", "fixing", "verifying", "resolved", "regressed", "suppressed"])).max(10).optional(),
|
||||
severities: z.array(z.enum(["info", "warning", "critical"])).max(3).optional(),
|
||||
cursor: z.string().regex(/^\d+$/).optional(),
|
||||
limit: z.number().int().min(1).max(50).default(20),
|
||||
},
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ project_id, statuses, severities, cursor, limit }) => withAudit({ actor, tool: "list_repository_findings", projectId: project_id }, async () => {
|
||||
const result = await listRepositoryFindings({ projectId: project_id, statuses, severities, cursor, limit });
|
||||
return asCompactResult(result as unknown as Record<string, unknown>, `${result.items.length} duurzame bevinding(en) geladen.${result.nextCursor ? ` Volgende cursor: ${result.nextCursor}.` : ""}`);
|
||||
}));
|
||||
|
||||
server.registerTool("get_repository_finding", {
|
||||
title: "Get repository finding",
|
||||
description: "Use this before creating an issue, starting an investigation, suppressing a finding, or planning a fix. Returns its lifecycle, occurrences, issue and recent investigations.",
|
||||
inputSchema: { finding_id: z.string().min(1) },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ finding_id }) => withAudit({ actor, tool: "get_repository_finding" }, async () => asCompactResult({ finding: await getRepositoryFinding(finding_id) } as unknown as Record<string, unknown>, `Bevinding ${finding_id} geladen.`)));
|
||||
|
||||
server.registerTool("create_finding_issue", {
|
||||
title: "Create or synchronize Gitea issue for finding",
|
||||
description: "Use this only when the user wants a durable Gitea issue for an existing ProjectBrain finding. The operation is idempotent: retries update or reopen the same marked issue instead of creating duplicates.",
|
||||
inputSchema: { project_id: z.string().min(1), finding_id: z.string().min(1) },
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
||||
_meta: { "openai/toolInvocation/invoking": "Gitea-issue synchroniseren…", "openai/toolInvocation/invoked": "Gitea-issue gesynchroniseerd" },
|
||||
}, async ({ project_id, finding_id }) => {
|
||||
const finding = await assertFindingProject(project_id, finding_id);
|
||||
return withAudit({ actor, tool: "create_finding_issue", projectId: project_id, repositoryId: finding.repositoryLinkId ?? undefined }, async () => {
|
||||
const result = await syncFindingIssue(env, finding_id, actor);
|
||||
return asCompactResult({ finding: result.finding, issue: result.issue } as unknown as Record<string, unknown>, `Gitea-issue #${result.issue.number} is gekoppeld aan bevinding ${finding_id}.`);
|
||||
}, true);
|
||||
});
|
||||
|
||||
server.registerTool("start_finding_investigation", {
|
||||
title: "Start finding investigation",
|
||||
description: "Use this when the user asks ChatGPT to investigate a ProjectBrain finding or its Gitea issue. ProjectBrain assembles one bounded evidence pack server-side and reuses an active investigation on retries.",
|
||||
inputSchema: { project_id: z.string().min(1), finding_id: z.string().min(1) },
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
||||
}, async ({ project_id, finding_id }) => {
|
||||
const finding = await assertFindingProject(project_id, finding_id);
|
||||
return withAudit({ actor, tool: "start_finding_investigation", projectId: project_id, repositoryId: finding.repositoryLinkId ?? undefined }, async () => {
|
||||
const investigation = await startFindingInvestigation(env, finding_id, actor);
|
||||
return asCompactResult({ investigation } as unknown as Record<string, unknown>, `Onderzoek ${investigation.id} is klaar voor analyse.`);
|
||||
});
|
||||
});
|
||||
|
||||
server.registerTool("get_finding_investigation", {
|
||||
title: "Get finding investigation",
|
||||
description: "Use this to load the bounded evidence pack, conclusion and linked delivery for one ProjectBrain finding investigation.",
|
||||
inputSchema: { investigation_id: z.string().min(1) },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ investigation_id }) => withAudit({ actor, tool: "get_finding_investigation" }, async () => asCompactResult({ investigation: await getFindingInvestigation(investigation_id) } as unknown as Record<string, unknown>, `Onderzoek ${investigation_id} geladen.`)));
|
||||
|
||||
server.registerTool("save_finding_investigation", {
|
||||
title: "Save finding investigation conclusion",
|
||||
description: "Use this after examining the evidence pack. Persists a concise diagnosis, root cause, proposed file-level changes, confidence and conclusion without starting repository changes.",
|
||||
inputSchema: {
|
||||
project_id: z.string().min(1), investigation_id: z.string().min(1), diagnosis: z.string().trim().min(8).max(12_000), root_cause: z.string().trim().min(8).max(12_000),
|
||||
proposed_changes: z.array(z.string().trim().min(1).max(2_000)).min(1).max(50), confidence: z.number().int().min(0).max(100), conclusion: z.string().trim().min(8).max(8_000),
|
||||
},
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ project_id, investigation_id, diagnosis, root_cause, proposed_changes, confidence, conclusion }) => {
|
||||
const investigation = await assertInvestigationProject(project_id, investigation_id);
|
||||
return withAudit({ actor, tool: "save_finding_investigation", projectId: project_id, repositoryId: investigation.finding.repositoryLinkId ?? undefined }, async () => asCompactResult({ investigation: await saveFindingInvestigationConclusion(investigation_id, { diagnosis, rootCause: root_cause, proposedChanges: proposed_changes, confidence, conclusion }) } as unknown as Record<string, unknown>, `Onderzoeksconclusie ${investigation_id} opgeslagen.`));
|
||||
});
|
||||
|
||||
server.registerTool("suppress_repository_finding", {
|
||||
title: "Suppress repository finding",
|
||||
description: "Use this only when the user explicitly accepts or defers a finding. A reason is mandatory and an optional expiry lets future scans reactivate it.",
|
||||
inputSchema: { project_id: z.string().min(1), finding_id: z.string().min(1), reason: z.string().trim().min(3).max(2_000), until: z.string().datetime().optional() },
|
||||
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ project_id, finding_id, reason, until }) => {
|
||||
const finding = await assertFindingProject(project_id, finding_id);
|
||||
return withAudit({ actor, tool: "suppress_repository_finding", projectId: project_id, repositoryId: finding.repositoryLinkId ?? undefined }, async () => asCompactResult({ finding: await suppressRepositoryFinding(finding_id, reason, until ? new Date(until) : undefined) } as unknown as Record<string, unknown>, `Bevinding ${finding_id} is onderdrukt.`));
|
||||
});
|
||||
|
||||
server.registerTool("save_brainstorm", {
|
||||
title: "Save ChatGPT brainstorm", description: "Persist an idea exploration in ProjectBrain's Brainstorm module. Use this for meaningful alternatives, trade-offs and next actions; use create_feature_delivery only after the user has chosen a direction.",
|
||||
inputSchema: {
|
||||
project_id: z.string().min(1), title: z.string().trim().min(3).max(160), prompt: z.string().trim().min(3).max(8000),
|
||||
context_summary: z.string().trim().min(3).max(8000), ideas: z.array(z.object({ id: z.string().min(1), title: z.string().min(1), description: z.string().min(1), score: z.number().min(0).max(100).optional() })).max(50).default([]),
|
||||
outcomes: z.array(z.string().min(1).max(2000)).max(50).default([]), selected_idea_ids: z.array(z.string().min(1)).max(50).default([]), next_actions: z.array(z.string().min(1).max(1000)).max(50).default([]),
|
||||
},
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
}, async ({ project_id, title, prompt, context_summary, ideas, outcomes, selected_idea_ids, next_actions }) => withAudit({ actor, tool: "save_brainstorm", projectId: project_id }, async () => asResult(await createBrainstormForProject(project_id, {
|
||||
title, prompt, scope: "project", mode: "chatgpt", status: selected_idea_ids.length ? "selected" : "exploring", generatedBy: "chatgpt_mcp", contextSummary: context_summary,
|
||||
ideas, outcomes: outcomes.map((summary, index) => ({ id: `outcome-${index + 1}`, summary })), selectedIdeaIds: selected_idea_ids, nextActions: next_actions,
|
||||
}), "Brainstorm saved in ProjectBrain")));
|
||||
|
||||
server.registerTool("save_project_knowledge", {
|
||||
title: "Save project knowledge", description: "Save a reusable fact, finding or technical explanation to ProjectBrain's Knowledge module. New knowledge remains pending for human review.",
|
||||
inputSchema: { project_id: z.string().min(1), title: z.string().trim().min(3).max(160), content: z.string().trim().min(3).max(30000), category: z.string().trim().min(1).max(80).default("Technical"), confidence: z.number().int().min(0).max(100).default(75) },
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
}, async ({ project_id, title, content, category, confidence }) => withAudit({ actor, tool: "save_project_knowledge", projectId: project_id }, async () => asResult(await createKnowledgeItem(project_id, { title, content, category, confidence, source: "ChatGPT via ProjectBrain MCP" }), "Pending knowledge saved")));
|
||||
|
||||
server.registerTool("save_documentation_draft", {
|
||||
title: "Save documentation draft", description: "Write a documentation draft into ProjectBrain. The draft remains pending until the user reviews it in the Documentation module.",
|
||||
inputSchema: { project_id: z.string().min(1), title: z.string().trim().min(3).max(160), content: z.string().trim().min(3).max(50000), kind: z.string().trim().min(1).max(80).default("design document") },
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
}, async ({ project_id, title, content, kind }) => withAudit({ actor, tool: "save_documentation_draft", projectId: project_id }, async () => asResult(await createDocumentationItem(project_id, { title, content, kind, status: "draft", approvalState: "pending" }), "Documentation draft saved")));
|
||||
|
||||
server.registerTool("save_memory_rule", {
|
||||
title: "Save project memory rule", description: "Save an enduring instruction or do-not-break rule in ProjectBrain. New rules remain pending for human review.",
|
||||
inputSchema: { project_id: z.string().min(1), title: z.string().trim().min(3).max(160), rule: z.string().trim().min(3).max(8000), scope: z.enum(["global", "module", "feature", "prompt_only"]).default("global"), category: z.enum(["UX", "Technical", "Architecture", "Do Not Break", "Naming", "Workflow", "Security"]).default("Technical"), severity: z.enum(["info", "important", "critical", "do_not_break"]).default("important") },
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
}, async ({ project_id, title, rule, scope, category, severity }) => withAudit({ actor, tool: "save_memory_rule", projectId: project_id }, async () => asResult(await createMemoryRuleItem(project_id, { title, rule, scope, category, severity, includeInContextPacks: true }), "Pending memory rule saved")));
|
||||
|
||||
server.registerTool("create_project_context_pack", {
|
||||
title: "Create project context pack", description: "Create a fresh ProjectBrain context pack from the project's approved DNA, features, evidence, memory and architecture before a substantial planning or implementation task.",
|
||||
inputSchema: { project_id: z.string().min(1), title: z.string().trim().min(3).max(160).optional() },
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
}, async ({ project_id, title }) => withAudit({ actor, tool: "create_project_context_pack", projectId: project_id }, async () => asResult(await createContextPackForProject(project_id, { sourceType: "workspace", title, includeDNA: true, includeFeatures: true, includeEvidence: true, includeMemoryRules: true, includeRisks: true, includeRegressions: true, includeArchitecture: true }), "Context pack created")));
|
||||
|
||||
server.registerTool("record_execution_feedback", {
|
||||
title: "Record execution feedback",
|
||||
description: "Import the outcome of a completed coding or automation run into ProjectBrain's Review Queue. Feedback stays pending until a human accepts it as project truth.",
|
||||
inputSchema: {
|
||||
project_id: z.string().min(1), codex_task_id: z.string().min(1), source_run: z.string().trim().min(1).max(240),
|
||||
summary: z.string().trim().min(3).max(8000), output_text: z.string().max(100_000).default(""),
|
||||
},
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
}, async ({ project_id, codex_task_id, source_run, summary, output_text }) => withAudit({ actor, tool: "record_execution_feedback", projectId: project_id }, async () => {
|
||||
const result = await importCodexOutputInDatabase(project_id, codex_task_id, { sourceRun: source_run, summary, outputText: output_text });
|
||||
if (!result) throw new Error("PROJECT_NOT_FOUND");
|
||||
if (result === "CODEX_TASK_NOT_FOUND") throw new Error(result);
|
||||
return asResult(result, "Execution feedback imported for human review");
|
||||
}));
|
||||
|
||||
server.registerTool("list_feature_deliveries", {
|
||||
title: "List feature deliveries", description: "Use this to find a delivery id or compact status. Results are paginated summaries; call get_feature_delivery only for the selected delivery's full plan and runtime evidence.",
|
||||
inputSchema: { project_id: z.string().min(1), cursor: z.string().regex(/^\d+$/).default("0"), page_size: z.number().int().min(1).max(25).default(10) },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ project_id, cursor, page_size }) => withAudit({ actor, tool: "list_feature_deliveries", projectId: project_id }, async () => {
|
||||
const deliveries = (await listFeatureDeliveries(project_id)).map(compactDelivery);
|
||||
const page = pageCollection(deliveries, Number.parseInt(cursor, 10), page_size);
|
||||
return asCompactResult(page as unknown as Record<string, unknown>, `${page.items.length} deliverysamenvatting(en) geladen.${page.nextCursor ? ` Volgende cursor: ${page.nextCursor}.` : ""}`);
|
||||
}));
|
||||
|
||||
server.registerTool("get_feature_delivery", {
|
||||
title: "Get feature delivery", description: "Use this before continuing a feature to load its current scope, authorization state, acceptance criteria, implementation plan, branch, progress, validations, and pull request. If approvalState is approved, ProjectBrain policy has already authorized execution; do not ask for redundant approval.",
|
||||
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1) },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ project_id, delivery_id }) => withAudit({ actor, tool: "get_feature_delivery", projectId: project_id }, async () => {
|
||||
const delivery = await refreshDeliveryFromGitea(client, project_id, delivery_id);
|
||||
return asResult({ ...delivery, continuation: deliveryContinuation(delivery) }, "Delivery reconciled with Gitea; follow continuation.");
|
||||
}));
|
||||
|
||||
const featurePlanSchema = {
|
||||
project_id: z.string().min(1), title: z.string().trim().min(3).max(160), intent: z.string().trim().min(8).max(4000),
|
||||
priority: z.enum(["critical", "important", "normal", "low"]).default("normal"), problem: z.string().trim().min(8).max(8000),
|
||||
desired_outcome: z.string().trim().min(8).max(8000), users: z.array(z.string().min(1).max(300)).max(30).default([]),
|
||||
goals: z.array(z.string().min(1).max(1000)).min(1).max(50), non_goals: z.array(z.string().min(1).max(1000)).max(50).default([]),
|
||||
acceptance_criteria: z.array(z.string().min(1).max(1000)).min(1).max(100), implementation_plan: z.array(z.string().min(1).max(2000)).min(1).max(100),
|
||||
risks: z.array(z.string().min(1).max(1000)).max(50).default([]), validation_commands: z.array(z.string().min(1).max(500)).min(1).max(30),
|
||||
risk_level: z.enum(["low", "medium", "high", "destructive"]).default("medium").describe("Classify execution risk. low/medium reversible work is policy-authorized automatically; high/destructive requires a real user decision before repository writes."),
|
||||
};
|
||||
|
||||
server.registerTool("create_delivery_from_finding", {
|
||||
title: "Create delivery plan from finding investigation",
|
||||
description: "Use this only after save_finding_investigation. Creates a reviewable ProjectBrain delivery linked to the finding and investigation; it never approves the plan or starts code changes.",
|
||||
inputSchema: { investigation_id: z.string().min(1), ...featurePlanSchema },
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
}, async ({ project_id, investigation_id, title, intent, priority, problem, desired_outcome, users, goals, non_goals, acceptance_criteria, implementation_plan, risks, validation_commands }) => {
|
||||
const investigation = await assertInvestigationProject(project_id, investigation_id);
|
||||
return withAudit({ actor, tool: "create_delivery_from_finding", projectId: project_id, repositoryId: investigation.finding.repositoryLinkId ?? undefined }, async () => {
|
||||
const result = await createDeliveryFromFindingInvestigation(investigation_id, {
|
||||
title, intent, priority, problem, desiredOutcome: desired_outcome, users, goals, nonGoals: non_goals,
|
||||
acceptanceCriteria: acceptance_criteria, implementationPlan: implementation_plan, risks, validationCommands: validation_commands,
|
||||
}, actor);
|
||||
return asCompactResult(result as unknown as Record<string, unknown>, `Delivery ${result.delivery.id} is aangemaakt en wacht op goedkeuring in ProjectBrain.`);
|
||||
});
|
||||
});
|
||||
|
||||
server.registerTool("verify_finding_resolution", {
|
||||
title: "Queue post-merge finding verification",
|
||||
description: "Use this after the finding-linked pull request is merged. Queues a bounded repository reanalysis; ProjectBrain closes the issue only when the original finding is absent.",
|
||||
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1) },
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
||||
}, async ({ project_id, delivery_id }) => withAudit({ actor, tool: "verify_finding_resolution", projectId: project_id, deliveryId: delivery_id }, async () => {
|
||||
const verification = await queueFindingVerificationForDelivery(env, project_id, delivery_id);
|
||||
if (!verification) throw new Error("FINDING_VERIFICATION_NOT_AVAILABLE");
|
||||
return asCompactResult(verification as unknown as Record<string, unknown>, `Gerichte verificatiejob ${(verification.job as Record<string, unknown>).id} staat in de wachtrij.`);
|
||||
}));
|
||||
|
||||
server.registerTool("create_feature_delivery", {
|
||||
title: "Create feature delivery plan",
|
||||
description: "Create the bounded execution plan for a user request. Classify risk explicitly. ProjectBrain auto-authorizes low/medium reversible work; high/destructive work remains pending and is surfaced under Jouw aandacht. Creating the plan never writes repository files by itself.",
|
||||
inputSchema: featurePlanSchema,
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
}, async ({ project_id, title, intent, priority, problem, desired_outcome, users, goals, non_goals, acceptance_criteria, implementation_plan, risks, validation_commands, risk_level }) => withAudit({ actor, tool: "create_feature_delivery", projectId: project_id }, async () => {
|
||||
const delivery = await createOrResumeAutonomyDelivery(project_id, {
|
||||
title, intent, priority, problem, desiredOutcome: desired_outcome, users, goals, nonGoals: non_goals, acceptanceCriteria: acceptance_criteria,
|
||||
implementationPlan: implementation_plan, risks, validationCommands: validation_commands, riskLevel: risk_level,
|
||||
}, actor);
|
||||
return asResult(delivery, delivery.approvalState === "approved"
|
||||
? "Feature delivery policy-authorized. Continue autonomously with start_feature_delivery; do not ask the user for redundant approval."
|
||||
: "Feature delivery requires a real user decision. Stop repository writes and surface the decision in ProjectBrain under Jouw aandacht.");
|
||||
}));
|
||||
|
||||
server.registerTool("update_feature_delivery", {
|
||||
title: "Update feature delivery plan",
|
||||
description: "Refine an unstarted feature plan and re-evaluate its risk policy. Low/medium reversible work is policy-authorized; high/destructive work returns to a real user-decision state. Active or completed plans are locked.",
|
||||
inputSchema: { delivery_id: z.string().min(1), ...featurePlanSchema },
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ project_id, delivery_id, title, intent, priority, problem, desired_outcome, users, goals, non_goals, acceptance_criteria, implementation_plan, risks, validation_commands, risk_level }) => withAudit({ actor, tool: "update_feature_delivery", projectId: project_id }, async () => {
|
||||
const delivery = await updateFeatureDelivery(project_id, delivery_id, {
|
||||
title, intent, priority, problem, desiredOutcome: desired_outcome, users, goals, nonGoals: non_goals, acceptanceCriteria: acceptance_criteria,
|
||||
implementationPlan: implementation_plan, risks, validationCommands: validation_commands, riskLevel: risk_level,
|
||||
}, actor);
|
||||
return asResult(delivery, delivery.approvalState === "approved"
|
||||
? "Feature delivery updated and policy-authorized. Continue autonomously without an extra approval round."
|
||||
: "Feature delivery updated and now requires a real user decision before repository execution.");
|
||||
}));
|
||||
|
||||
server.registerTool("list_repository_tree", {
|
||||
title: "List repository tree", description: "List files and directories at a repository path and ref.",
|
||||
inputSchema: { project_id: z.string().min(1), repository_id: z.string().min(1).optional(), path: z.string().default(""), ref: z.string().optional() },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ project_id, repository_id, path, ref }) => withAudit({ actor, tool: "list_repository_tree", projectId: project_id, repositoryId: repository_id, branch: ref }, async (link) => {
|
||||
const safePath = path ? assertReadablePath(path) : "";
|
||||
const resolvedRef = ref ?? link!.defaultBranch;
|
||||
const contents = await client.getContents(link!.owner, link!.repository, safePath, resolvedRef);
|
||||
const entries = (Array.isArray(contents) ? contents : [contents]).map(({ content: _content, ...entry }) => entry);
|
||||
return asResult({ projectId: link!.projectSlug, repository: link!.fullName, path: safePath, ref: resolvedRef, entries }, "Repository tree");
|
||||
}));
|
||||
|
||||
server.registerTool("get_file", {
|
||||
title: "Read repository file", description: "Read a text file from a linked repository at a branch, tag, or commit.",
|
||||
inputSchema: { project_id: z.string().min(1), repository_id: z.string().min(1).optional(), path: z.string().min(1), ref: z.string().optional() },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ project_id, repository_id, path, ref }) => withAudit({ actor, tool: "get_file", projectId: project_id, repositoryId: repository_id, branch: ref }, async (link) => {
|
||||
const safePath = assertReadablePath(path); const resolvedRef = ref ?? link!.defaultBranch;
|
||||
const file = await client.getContents(link!.owner, link!.repository, safePath, resolvedRef);
|
||||
if (Array.isArray(file)) throw new Error("GITEA_CONTENT_NOT_FILE");
|
||||
return asResult({ projectId: link!.projectSlug, repository: link!.fullName, path: safePath, ref: resolvedRef, sha: file.sha, size: file.size, content: decodeFile(file), htmlUrl: file.html_url }, "Repository file");
|
||||
}));
|
||||
|
||||
server.registerTool("list_branches", {
|
||||
title: "List repository branches", description: "List branches and protection status for a linked repository.", inputSchema: { project_id: z.string().min(1), repository_id: z.string().min(1).optional() },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ project_id, repository_id }) => withAudit({ actor, tool: "list_branches", projectId: project_id, repositoryId: repository_id }, async (link) => asResult({ defaultBranch: link!.defaultBranch, branches: await client.listBranches(link!.owner, link!.repository) }, "Repository branches")));
|
||||
|
||||
server.registerTool("list_pull_requests", {
|
||||
title: "List pull requests", description: "List pull requests for a linked repository.", inputSchema: { project_id: z.string().min(1), repository_id: z.string().min(1).optional(), state: z.enum(["open", "closed", "all"]).default("open") },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ project_id, repository_id, state }) => withAudit({ actor, tool: "list_pull_requests", projectId: project_id, repositoryId: repository_id }, async (link) => asResult({ pullRequests: await client.listPullRequests(link!.owner, link!.repository, state) }, "Pull requests")));
|
||||
|
||||
server.registerTool("start_feature_delivery", {
|
||||
title: "Start authorized feature delivery",
|
||||
description: "Start an isolated non-default delivery branch only when ProjectBrain policy already reports the delivery as approved. Low/medium work can arrive pre-approved automatically; high/destructive work cannot start until its real user decision is recorded. Never ask for a redundant approval when approvalState is already approved.",
|
||||
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1), repository_id: z.string().min(1).optional(), branch: z.string().min(1).max(300).describe("Unique valid non-default Git branch") },
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
}, async ({ project_id, delivery_id, repository_id, branch }) => withAudit({ actor, tool: "start_feature_delivery", projectId: project_id, repositoryId: repository_id, deliveryId: delivery_id, branch }, async (link) => {
|
||||
const delivery = await getFeatureDelivery(project_id, delivery_id);
|
||||
const safeBranch = assertChatGptBranch(branch, link!.defaultBranch);
|
||||
if (delivery.spec.branch === safeBranch && ["executing", "validating", "pull_request_open", "merged", "verified", "released"].includes(delivery.spec.status)) return asResult(delivery, "Feature delivery already started");
|
||||
if (delivery.approvalState !== "approved" || delivery.spec.status !== "approved") throw new Error("FEATURE_DELIVERY_APPROVAL_REQUIRED");
|
||||
const base = await client.getBranch(link!.owner, link!.repository, link!.defaultBranch);
|
||||
try { await client.createBranch(link!.owner, link!.repository, safeBranch, link!.defaultBranch); }
|
||||
catch (error) {
|
||||
if (!(error instanceof GiteaApiError && error.status === 409)) throw error;
|
||||
await client.getBranch(link!.owner, link!.repository, safeBranch);
|
||||
}
|
||||
const head = await client.getBranch(link!.owner, link!.repository, safeBranch);
|
||||
return asResult(await startFeatureDelivery(project_id, delivery_id, safeBranch, actor, { repositoryLinkId: link!.id, baseCommitSha: base.commit.id, headSha: head.commit.id }), "Authorized feature delivery started");
|
||||
}, true));
|
||||
|
||||
server.registerTool("write_feature_file", {
|
||||
title: "Write approved feature file", description: "Use this to create or update one text file for an active approved feature delivery. The delivery must own the supplied ChatGPT/ProjectBrain branch. Pass the SHA returned by get_file when updating an existing file.",
|
||||
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1), branch: z.string().min(9), path: z.string().min(1), content: z.string(), message: z.string().trim().min(3).max(200), expected_sha: z.string().min(1).max(200).optional(), progress_note: z.string().max(2000).optional() },
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
}, async ({ project_id, delivery_id, branch, path, content, message, expected_sha, progress_note }) => withAudit({ actor, tool: "write_feature_file", projectId: project_id, deliveryId: delivery_id, branch }, async (link) => {
|
||||
const delivery = await getFeatureDelivery(project_id, delivery_id);
|
||||
const safeBranch = assertChatGptBranch(branch, link!.defaultBranch);
|
||||
await assertDeliveryWriteAllowed(delivery, safeBranch, client, link!);
|
||||
const safePath = assertWritablePath(path); assertFileSize(content);
|
||||
let existing: GiteaContent | undefined;
|
||||
try { const value = await client.getContents(link!.owner, link!.repository, safePath, safeBranch); if (!Array.isArray(value)) existing = value; }
|
||||
catch (error) { if (!(error instanceof GiteaApiError && error.status === 404)) throw error; }
|
||||
assertExpectedFileSha(expected_sha, existing?.sha);
|
||||
const result = existing ? await client.updateFile(link!.owner, link!.repository, safePath, safeBranch, expected_sha!, content, message) : await client.createFile(link!.owner, link!.repository, safePath, safeBranch, content, message);
|
||||
const head = await client.getBranch(link!.owner, link!.repository, safeBranch);
|
||||
let written: GiteaContent | undefined;
|
||||
try { const value = await client.getContents(link!.owner, link!.repository, safePath, safeBranch); if (!Array.isArray(value)) written = value; } catch { /* The commit is still tracked by branch head. */ }
|
||||
const progress = await recordFeatureDeliveryProgress(project_id, delivery_id, { changes: [{ path: safePath, operation: existing ? "update" : "create", beforeSha: existing?.sha, afterSha: written?.sha, commitSha: head.commit.id }], note: progress_note, headSha: head.commit.id }, actor);
|
||||
return asResult({ repository: link!.fullName, branch: safeBranch, path: safePath, action: existing ? "updated" : "created", result, delivery: progress }, "Feature file committed and progress recorded");
|
||||
}, true));
|
||||
|
||||
server.registerTool("delete_feature_file", {
|
||||
title: "Delete approved feature file", description: "Delete one file from an active approved feature delivery branch and record that change. This is destructive and requires explicit confirmation.",
|
||||
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1), branch: z.string().min(9), path: z.string().min(1), message: z.string().trim().min(3).max(200), expected_sha: z.string().min(1).max(200), progress_note: z.string().max(2000).optional() },
|
||||
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
||||
}, async ({ project_id, delivery_id, branch, path, message, expected_sha, progress_note }) => withAudit({ actor, tool: "delete_feature_file", projectId: project_id, deliveryId: delivery_id, branch }, async (link) => {
|
||||
const delivery = await getFeatureDelivery(project_id, delivery_id);
|
||||
const safeBranch = assertChatGptBranch(branch, link!.defaultBranch);
|
||||
await assertDeliveryWriteAllowed(delivery, safeBranch, client, link!);
|
||||
const safePath = assertWritablePath(path);
|
||||
const file = await client.getContents(link!.owner, link!.repository, safePath, safeBranch); if (Array.isArray(file)) throw new Error("GITEA_CONTENT_NOT_FILE");
|
||||
assertExpectedFileSha(expected_sha, file.sha);
|
||||
const result = await client.deleteFile(link!.owner, link!.repository, safePath, safeBranch, expected_sha, message);
|
||||
const head = await client.getBranch(link!.owner, link!.repository, safeBranch);
|
||||
const progress = await recordFeatureDeliveryProgress(project_id, delivery_id, { changes: [{ path: safePath, operation: "delete", beforeSha: file.sha, commitSha: head.commit.id }], note: progress_note, headSha: head.commit.id }, actor);
|
||||
return asResult({ repository: link!.fullName, branch: safeBranch, path: safePath, deleted: true, result, delivery: progress }, "Feature file deleted and progress recorded");
|
||||
}, true));
|
||||
|
||||
server.registerTool("apply_feature_changeset", {
|
||||
title: "Apply approved feature changeset",
|
||||
description: "Create, update, and delete up to 25 text files for one approved delivery in a single bounded server-side call. Use this instead of repeated file tools for multi-file work. Every path is preflighted against its expected SHA and the exact branch head before the first write.",
|
||||
inputSchema: {
|
||||
project_id: z.string().min(1), delivery_id: z.string().min(1), branch: z.string().min(1),
|
||||
expected_head_sha: z.string().regex(/^[0-9a-f]{40,64}$/i), message: z.string().trim().min(3).max(200),
|
||||
operations: z.array(featureChangesetOperationSchema).min(1).max(25), progress_note: z.string().max(2_000).optional(),
|
||||
},
|
||||
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
||||
_meta: { "openai/toolInvocation/invoking": "Gebudgetteerde changeset toepassen…", "openai/toolInvocation/invoked": "Changeset toegepast" },
|
||||
}, async ({ project_id, delivery_id, branch, expected_head_sha, message, operations, progress_note }) => withAudit({ actor, tool: "apply_feature_changeset", projectId: project_id, deliveryId: delivery_id, branch }, async (link) => {
|
||||
const delivery = await getFeatureDelivery(project_id, delivery_id);
|
||||
const safeBranch = assertChatGptBranch(branch, link!.defaultBranch);
|
||||
await assertDeliveryWriteAllowed(delivery, safeBranch, client, link!);
|
||||
const paths = assertFeatureChangesetBudget(operations);
|
||||
const initialHead = await client.getBranch(link!.owner, link!.repository, safeBranch);
|
||||
if (initialHead.commit.id !== expected_head_sha) throw new Error("FEATURE_CHANGESET_HEAD_CONFLICT");
|
||||
|
||||
const preflight = await mapBounded(operations, 4, async (operation, index) => {
|
||||
let current: GiteaContent | undefined;
|
||||
try {
|
||||
const value = await client.getContents(link!.owner, link!.repository, paths[index], safeBranch);
|
||||
if (Array.isArray(value)) throw new Error("GITEA_CONTENT_NOT_FILE");
|
||||
current = value;
|
||||
} catch (error) {
|
||||
if (!(error instanceof GiteaApiError && error.status === 404)) throw error;
|
||||
}
|
||||
if (operation.operation === "create") assertExpectedFileSha(undefined, current?.sha);
|
||||
else assertExpectedFileSha(operation.expected_sha, current?.sha);
|
||||
return current;
|
||||
});
|
||||
const preflightHead = await client.getBranch(link!.owner, link!.repository, safeBranch);
|
||||
if (preflightHead.commit.id !== expected_head_sha) throw new Error("FEATURE_CHANGESET_HEAD_CONFLICT");
|
||||
|
||||
const appliedChanges: Array<{ path: string; operation: "create" | "update" | "delete"; beforeSha?: string; afterSha?: string; commitSha?: string }> = [];
|
||||
try {
|
||||
for (let index = 0; index < operations.length; index += 1) {
|
||||
const operation = operations[index]; const path = paths[index]; const before = preflight[index];
|
||||
if (operation.operation === "create") await client.createFile(link!.owner, link!.repository, path, safeBranch, operation.content, message);
|
||||
else if (operation.operation === "update") await client.updateFile(link!.owner, link!.repository, path, safeBranch, operation.expected_sha, operation.content, message);
|
||||
else await client.deleteFile(link!.owner, link!.repository, path, safeBranch, operation.expected_sha, message);
|
||||
const head = await client.getBranch(link!.owner, link!.repository, safeBranch);
|
||||
let afterSha: string | undefined;
|
||||
if (operation.operation !== "delete") {
|
||||
const written = await client.getContents(link!.owner, link!.repository, path, safeBranch);
|
||||
if (!Array.isArray(written)) afterSha = written.sha;
|
||||
}
|
||||
appliedChanges.push({ path, operation: operation.operation, beforeSha: before?.sha, afterSha, commitSha: head.commit.id });
|
||||
}
|
||||
} catch {
|
||||
if (appliedChanges.length) {
|
||||
const partialHead = await client.getBranch(link!.owner, link!.repository, safeBranch).catch(() => undefined);
|
||||
await recordFeatureDeliveryProgress(project_id, delivery_id, { changes: appliedChanges, note: `Partial changeset: ${appliedChanges.length}/${operations.length} operations applied before a remote failure.`, headSha: partialHead?.commit.id }, actor).catch(() => undefined);
|
||||
throw new PartialExternalMutationError("FEATURE_CHANGESET_PARTIAL");
|
||||
}
|
||||
throw new Error("FEATURE_CHANGESET_APPLY_FAILED");
|
||||
}
|
||||
|
||||
const finalHead = await client.getBranch(link!.owner, link!.repository, safeBranch);
|
||||
const progress = await recordFeatureDeliveryProgress(project_id, delivery_id, { changes: appliedChanges, note: progress_note, headSha: finalHead.commit.id }, actor);
|
||||
return asCompactResult({ repository: link!.fullName, deliveryId: progress.id, branch: safeBranch, previousHeadSha: initialHead.commit.id, headSha: finalHead.commit.id, applied: appliedChanges.map(({ path, operation }) => ({ path, operation })) }, `${appliedChanges.length} bestandsbewerkingen server-side toegepast op ${safeBranch}.`);
|
||||
}, true));
|
||||
|
||||
server.registerTool("record_feature_validation", {
|
||||
title: "Record reported feature validation", description: "Store reported, failed, or not-run checks for an active feature. Reported results are useful progress evidence but cannot authorize a pull request; use sync_feature_validation to verify a successful Gitea Actions run for the current branch head.",
|
||||
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1), results: z.array(z.object({ command: z.string().min(1).max(500), status: z.enum(["passed", "failed", "not_run"]), summary: z.string().max(2000).optional() })).min(1).max(30), note: z.string().max(4000).optional(), ready_for_review: z.boolean().default(false) },
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ project_id, delivery_id, results, note, ready_for_review }) => withAudit({ actor, tool: "record_feature_validation", projectId: project_id }, async () => asResult(await recordFeatureDeliveryProgress(project_id, delivery_id, { validationResults: results, note, readyForReview: ready_for_review }, actor), "Feature validation recorded")));
|
||||
|
||||
server.registerTool("get_feature_validation_runs", {
|
||||
title: "Get feature validation runs", description: "Inspect Gitea Actions runs for the active feature branch and identify whether the current branch head has completed successfully.",
|
||||
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1) },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ project_id, delivery_id }) => withAudit({ actor, tool: "get_feature_validation_runs", projectId: project_id, deliveryId: delivery_id }, async (link) => {
|
||||
const delivery = await getFeatureDelivery(project_id, delivery_id);
|
||||
if (!delivery.spec.branch) throw new Error("FEATURE_DELIVERY_NOT_STARTED");
|
||||
const branch = assertChatGptBranch(delivery.spec.branch, link!.defaultBranch);
|
||||
const head = await client.getBranch(link!.owner, link!.repository, branch);
|
||||
const response = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: head.commit.id, limit: 30 });
|
||||
const runs = exactHeadActionRuns(response.workflow_runs, branch, head.commit.id);
|
||||
let successful = true;
|
||||
try { assertSuccessfulActionRuns(runs, branch, head.commit.id); } catch { successful = false; }
|
||||
return asResult({ branch, headSha: head.commit.id, runs, successful }, "Feature validation runs");
|
||||
}));
|
||||
|
||||
server.registerTool("sync_feature_validation", {
|
||||
title: "Verify feature with Gitea Actions", description: "Verify and persist a successful Gitea Actions run for the exact current feature branch head. This authorizes opening a pull request and refreshes evidence after an open pull request branch changes.",
|
||||
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1) },
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ project_id, delivery_id }) => withAudit({ actor, tool: "sync_feature_validation", projectId: project_id, deliveryId: delivery_id }, async (link) => {
|
||||
const delivery = await getFeatureDelivery(project_id, delivery_id);
|
||||
if (!delivery.spec.branch || delivery.approvalState !== "approved" || !canSyncFeatureDeliveryValidation(delivery.spec.status)) throw new Error("FEATURE_DELIVERY_PROGRESS_FORBIDDEN");
|
||||
const branch = assertChatGptBranch(delivery.spec.branch, link!.defaultBranch);
|
||||
const head = await client.getBranch(link!.owner, link!.repository, branch);
|
||||
const response = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: head.commit.id, limit: 30 });
|
||||
const verifiedRuns = assertSuccessfulActionRuns(response.workflow_runs, branch, head.commit.id);
|
||||
const run = verifiedRuns.reduce((latest, candidate) => candidate.run_number > latest.run_number ? candidate : latest);
|
||||
const evidence = { provider: "gitea_actions" as const, runId: run.id, runNumber: run.run_number, url: run.html_url, workflowPath: run.path, headSha: run.head_sha, conclusion: "success" as const, verifiedAt: new Date().toISOString() };
|
||||
return asResult({ run, delivery: await recordFeatureDeliveryActionValidation(project_id, delivery_id, evidence, actor) }, "Gitea Actions validation verified and persisted");
|
||||
}, true));
|
||||
|
||||
server.registerTool("complete_feature_delivery", {
|
||||
title: "Open feature pull request", description: "Use this after an approved feature delivery is implemented and its validation results are recorded. Opens the pull request and links it back to the originating feature plan in ProjectBrain.",
|
||||
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1), title: z.string().trim().min(3).max(200), body: z.string().max(20_000).default("") },
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
}, async ({ project_id, delivery_id, title, body }) => withAudit({ actor, tool: "complete_feature_delivery", projectId: project_id, deliveryId: delivery_id }, async (link) => {
|
||||
const delivery = await getFeatureDelivery(project_id, delivery_id);
|
||||
if (!delivery.spec.branch || delivery.approvalState !== "approved") throw new Error("FEATURE_DELIVERY_NOT_STARTED");
|
||||
if (delivery.spec.pullRequest) return asResult(delivery, "Feature delivery already has a pull request");
|
||||
if (delivery.spec.validationResults.some((result) => result.status === "failed")) throw new Error("FEATURE_DELIVERY_VALIDATION_FAILED");
|
||||
// Opening review needs verified exact-head Actions evidence below. Planned
|
||||
// PR checks cannot have run before the PR exists, and a dispatch must never
|
||||
// manufacture passed results for those commands. Merge still uses Gitea's
|
||||
// separate protected-check gate; reported failures continue to block review.
|
||||
const branch = assertChatGptBranch(delivery.spec.branch, link!.defaultBranch);
|
||||
const head = await client.getBranch(link!.owner, link!.repository, branch);
|
||||
assertFeatureDeliveryActionEvidence(delivery.spec.validationEvidence, head.commit.id);
|
||||
const actionRuns = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: head.commit.id, limit: 30 });
|
||||
assertSuccessfulActionRuns(actionRuns.workflow_runs, branch, head.commit.id);
|
||||
const existing = findReusableDeliveryPullRequest(await client.listPullRequests(link!.owner, link!.repository, "all"), branch, link!.defaultBranch, head.commit.id);
|
||||
const pullRequest = existing ?? await client.createPullRequest(link!.owner, link!.repository, { title, body, head: branch, base: link!.defaultBranch });
|
||||
let updated = await openFeatureDeliveryPullRequest(project_id, delivery_id, { number: pullRequest.number, url: pullRequest.html_url, title: pullRequest.title }, actor);
|
||||
if (pullRequest.merged) updated = await reconcileFeatureDeliveryPullRequest({ owner: link!.owner, repository: link!.repository, number: pullRequest.number, state: "closed", merged: true, mergeCommitSha: pullRequest.merge_commit_sha, mergedAt: pullRequest.merged_at, headSha: pullRequest.head.sha, actor }) ?? updated;
|
||||
return asResult({ repository: link!.fullName, pullRequest, delivery: updated }, pullRequest.merged ? "Existing merged pull request reconciled with delivery" : "Feature pull request created; delivery is awaiting merge");
|
||||
}, true));
|
||||
|
||||
server.registerTool("merge_feature_delivery", {
|
||||
title: "Merge validated feature delivery",
|
||||
description: "Merge an open ProjectBrain delivery pull request after exact-head and Gitea Actions verification, reconcile the durable delivery, queue finding verification, and optionally remove the merged work branch. This never writes directly to the default branch outside the reviewed pull request.",
|
||||
inputSchema: {
|
||||
project_id: z.string().min(1), delivery_id: z.string().min(1), expected_head_sha: z.string().regex(/^[0-9a-f]{40,64}$/i),
|
||||
method: z.enum(["merge", "rebase", "rebase-merge", "squash", "fast-forward-only"]).default("squash"),
|
||||
delete_branch: z.boolean().default(true),
|
||||
},
|
||||
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
||||
_meta: { "openai/toolInvocation/invoking": "Gevalideerde delivery mergen…", "openai/toolInvocation/invoked": "Delivery gemerged" },
|
||||
}, async ({ project_id, delivery_id, expected_head_sha, method, delete_branch }) => withAudit({ actor, tool: "merge_feature_delivery", projectId: project_id, deliveryId: delivery_id }, async (link) => {
|
||||
const delivery = await getFeatureDelivery(project_id, delivery_id);
|
||||
const pullRequestNumber = delivery.spec.pullRequest?.number;
|
||||
if (!pullRequestNumber || !delivery.spec.branch || delivery.approvalState !== "approved") throw new Error("FEATURE_DELIVERY_MERGE_FORBIDDEN");
|
||||
const branch = assertChatGptBranch(delivery.spec.branch, link!.defaultBranch);
|
||||
const pullRequest = await client.getPullRequest(link!.owner, link!.repository, pullRequestNumber);
|
||||
if (pullRequest.head.ref !== branch || pullRequest.base.ref !== link!.defaultBranch || pullRequest.head.sha !== expected_head_sha) throw new Error("FEATURE_DELIVERY_MERGE_HEAD_CONFLICT");
|
||||
const cleanupBranch = () => cleanupDeliveryBranch(client, link!, branch, expected_head_sha, delete_branch);
|
||||
if (pullRequest.merged) {
|
||||
if (!["merged", "verified", "released"].includes(delivery.spec.status)) await reconcileFeatureDeliveryPullRequest({ owner: link!.owner, repository: link!.repository, number: pullRequest.number, state: "closed", merged: true, mergedAt: pullRequest.merged_at, mergeCommitSha: pullRequest.merge_commit_sha, headSha: pullRequest.head.sha, actor });
|
||||
await queueFindingVerificationForDelivery(env, project_id, delivery_id).catch(() => undefined);
|
||||
const cleanup = await cleanupBranch();
|
||||
return asCompactResult({ repository: link!.fullName, deliveryId: delivery.id, pullRequest: { number: pullRequest.number, url: pullRequest.html_url, mergeCommitSha: pullRequest.merge_commit_sha }, branch, alreadyMerged: true, ...cleanup }, `PR #${pullRequest.number} was al gemerged.${cleanup.branchDeleted ? " De werkbranch is opgeruimd." : ""}`);
|
||||
}
|
||||
if (delivery.spec.status !== "pull_request_open") throw new Error("FEATURE_DELIVERY_MERGE_FORBIDDEN");
|
||||
if (pullRequest.merged || pullRequest.state !== "open" || pullRequest.draft === true || pullRequest.mergeable === false) throw new Error("FEATURE_DELIVERY_PULL_REQUEST_NOT_READY");
|
||||
const head = await client.getBranch(link!.owner, link!.repository, branch);
|
||||
if (head.commit.id !== expected_head_sha) throw new Error("FEATURE_DELIVERY_MERGE_HEAD_CONFLICT");
|
||||
assertFeatureDeliveryActionEvidence(delivery.spec.validationEvidence, expected_head_sha);
|
||||
const actionRuns = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: expected_head_sha, limit: 30 });
|
||||
assertSuccessfulActionRuns(actionRuns.workflow_runs, branch, expected_head_sha);
|
||||
|
||||
await client.waitForRequiredPullRequestChecks(link!.owner, link!.repository, link!.defaultBranch, expected_head_sha);
|
||||
const mergeReadyPullRequest = await client.getPullRequest(link!.owner, link!.repository, pullRequestNumber);
|
||||
const mergeReadyHead = await client.getBranch(link!.owner, link!.repository, branch);
|
||||
if (mergeReadyPullRequest.state !== "open" || mergeReadyPullRequest.merged || mergeReadyPullRequest.head.sha !== expected_head_sha || mergeReadyPullRequest.base.ref !== link!.defaultBranch || mergeReadyHead.commit.id !== expected_head_sha) throw new Error("FEATURE_DELIVERY_MERGE_HEAD_CONFLICT");
|
||||
|
||||
await client.mergePullRequest(link!.owner, link!.repository, pullRequestNumber, { method, headCommitId: expected_head_sha });
|
||||
const merged = await client.getPullRequest(link!.owner, link!.repository, pullRequestNumber);
|
||||
if (!merged.merged) throw new PartialExternalMutationError("FEATURE_DELIVERY_MERGE_UNCONFIRMED");
|
||||
const reconciled = await reconcileFeatureDeliveryPullRequest({ owner: link!.owner, repository: link!.repository, number: merged.number, state: "closed", merged: true, mergedAt: merged.merged_at, mergeCommitSha: merged.merge_commit_sha, headSha: merged.head.sha, actor });
|
||||
const verification = await queueFindingVerificationForDelivery(env, project_id, delivery_id).catch(() => undefined);
|
||||
|
||||
const { branchDeleted, branchCleanupError } = await cleanupBranch();
|
||||
return asCompactResult({ repository: link!.fullName, deliveryId: reconciled?.id ?? delivery_id, pullRequest: { number: merged.number, url: merged.html_url, mergeCommitSha: merged.merge_commit_sha }, mergeMethod: method, branch, branchDeleted, ...(branchCleanupError ? { branchCleanupError } : {}), verificationQueued: Boolean(verification) }, `PR #${merged.number} is via ${method} gemerged.${branchDeleted ? " De werkbranch is opgeruimd." : branchCleanupError ? " De merge slaagde; branchopruiming kan afzonderlijk worden herhaald." : ""}`);
|
||||
}, true));
|
||||
|
||||
return server;
|
||||
};
|
||||
Reference in new issue
Block a user