feat(lib): bundle MCP servers from .common/lib into factory/lib/
Copies source (no node_modules, dist, .tasks, .wiki, __pycache__) for: - projects-meta-mcp v2.25.0 (TypeScript/Node) - wiki-graph v0.3.1 (TypeScript/Node) - interns-mcp v0.3.3 (Python/FastMCP) .gitignore: exclude lib build artefacts (node_modules, dist, .venv, __pycache__, *.pyc) bootstrap.ps1: add MCP build step — npm install+build for TS servers, venv+pip for Python Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
174
lib/projects-meta-mcp/tests/tools/tasks-park-question.test.ts
Normal file
174
lib/projects-meta-mcp/tests/tools/tasks-park-question.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
|
||||
import { makeTasksTools } from '../../src/tools/tasks.js';
|
||||
import type { Backend, CommitFileArgs, CommitResult, FileWithSha } from '../../src/lib/backend.js';
|
||||
|
||||
function recordingBackend(initial: Record<string, FileWithSha> = {}) {
|
||||
const files = { ...initial };
|
||||
const commits: CommitFileArgs[] = [];
|
||||
const backend: Backend = {
|
||||
async listUserRepos() {
|
||||
return [];
|
||||
},
|
||||
async getRawFile(_u, repo, path) {
|
||||
return files[`${repo}:${path}`]?.content ?? null;
|
||||
},
|
||||
async getFileWithSha(_u, repo, path) {
|
||||
return files[`${repo}:${path}`] ?? null;
|
||||
},
|
||||
async commitFile(args: CommitFileArgs): Promise<CommitResult> {
|
||||
commits.push({ ...args });
|
||||
const fileSha = `sha-${commits.length}`;
|
||||
files[`${args.repo}:${args.path}`] = { content: args.content, sha: fileSha };
|
||||
return { fileSha, commitSha: `commit-${commits.length}` };
|
||||
},
|
||||
};
|
||||
return { backend, commits, files };
|
||||
}
|
||||
|
||||
const fixture: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-06-08T12:00:00.000Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{
|
||||
name: 'OpeItcLoc03/alpha',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-06-08T12:00:01.000Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
const ACTIVE_BOARD =
|
||||
'# Task Board\n_Updated: 2026-06-08_\n\n## 🔴 [my-task] — A thing\n\n' +
|
||||
'**Status:** active\n**Where I stopped:** mid-flight\n**Next action:** keep going\n' +
|
||||
'**Branch:** master\n**Owner:** host:claude-opus:1\n**Claim token:** tok-123\n\n---\n';
|
||||
|
||||
let cacheFile: string;
|
||||
const fixedNow = () => new Date('2026-06-08T10:00:00.000Z');
|
||||
|
||||
beforeEach(async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'park-tool-'));
|
||||
cacheFile = join(dir, 'tasks.json');
|
||||
await writeCache(cacheFile, fixture);
|
||||
});
|
||||
|
||||
function makeTools(backend: Backend) {
|
||||
return makeTasksTools({
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
agendaTasksRepo: 'projects-tasks',
|
||||
machine: 'host-x',
|
||||
now: fixedNow,
|
||||
});
|
||||
}
|
||||
|
||||
function call(tools: ReturnType<typeof makeTasksTools>, name: string, args: Record<string, unknown>) {
|
||||
const t = tools.find((x) => x.name === name);
|
||||
if (!t) throw new Error(`tool ${name} not registered`);
|
||||
return t.handler(args);
|
||||
}
|
||||
|
||||
const base = {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'my-task',
|
||||
question: 'Should we drop the legacy column? (irreversible)',
|
||||
};
|
||||
|
||||
describe('tasks.park_question', () => {
|
||||
it('is registered', () => {
|
||||
const { backend } = recordingBackend();
|
||||
expect(makeTools(backend).find((t) => t.name === 'tasks.park_question')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('flips status to blocked and writes the pending question (atomic, single commit)', async () => {
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ACTIVE_BOARD, sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.park_question', base);
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
|
||||
expect(parsed.parked).toBe(true);
|
||||
expect(commits).toHaveLength(1);
|
||||
expect(commits[0].path).toBe('.tasks/STATUS.md');
|
||||
expect(commits[0].content).toContain('## 🔵 [my-task]');
|
||||
expect(commits[0].content).toContain('**Status:** blocked');
|
||||
expect(commits[0].content).toContain('Should we drop the legacy column?');
|
||||
});
|
||||
|
||||
it('commits straight (no confirm gate — infra mutation)', async () => {
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ACTIVE_BOARD, sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
await call(tools, 'tasks.park_question', base); // no confirm flag
|
||||
expect(commits).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('with a matching claim_token, parks (token-gated like heartbeat)', async () => {
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ACTIVE_BOARD, sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.park_question', { ...base, claim_token: 'tok-123' });
|
||||
expect(JSON.parse(r.content[0].text).parked).toBe(true);
|
||||
expect(commits).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects a claim_token mismatch without committing (someone else holds the claim)', async () => {
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ACTIVE_BOARD, sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.park_question', { ...base, claim_token: 'WRONG' });
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.reason).toBe('token-mismatch');
|
||||
expect(commits).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('errors when the task is not on the board', async () => {
|
||||
const { backend } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ACTIVE_BOARD, sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.park_question', { ...base, slug: 'ghost' });
|
||||
expect(r.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('is disabled without a backend', async () => {
|
||||
const tools = makeTasksTools({ cacheFile });
|
||||
const r = await call(tools, 'tasks.park_question', base);
|
||||
expect(r.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('calls inboxWriter with event:blocked when task has Notify field', async () => {
|
||||
const boardWithNotify =
|
||||
'# Task Board\n_Updated: 2026-06-08_\n\n## 🔴 [my-task] — A thing\n\n' +
|
||||
'**Status:** active\n**Where I stopped:** mid-flight\n**Next action:** keep going\n' +
|
||||
'**Branch:** master\n**Owner:** host:claude-opus:1\n**Claim token:** tok-123\n' +
|
||||
'**Notify:** OpeItcLoc03/workshop\n\n---\n';
|
||||
const { backend } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: boardWithNotify, sha: 'old' },
|
||||
});
|
||||
const inboxCalls: unknown[] = [];
|
||||
const inboxWriter = async (args: unknown) => { inboxCalls.push(args); };
|
||||
const tools = makeTasksTools({ cacheFile, backend, giteaUser: 'u', agendaTasksRepo: 'pt', now: fixedNow, inboxWriter });
|
||||
await call(tools, 'tasks.park_question', base);
|
||||
|
||||
expect(inboxCalls).toHaveLength(1);
|
||||
expect((inboxCalls[0] as Record<string, string>).event).toBe('blocked');
|
||||
expect((inboxCalls[0] as Record<string, string>).notify).toBe('OpeItcLoc03/workshop');
|
||||
expect((inboxCalls[0] as Record<string, string>).slug).toBe('my-task');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user