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:
128
lib/projects-meta-mcp/tests/lib/cache.test.ts
Normal file
128
lib/projects-meta-mcp/tests/lib/cache.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
writeCache,
|
||||
readCache,
|
||||
detectCacheInvalidation,
|
||||
CACHE_SCHEMA_VERSION,
|
||||
type CacheFile,
|
||||
} from '../../src/lib/cache.js';
|
||||
|
||||
const sample: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-04-29T12:00:00.000Z',
|
||||
synced_from: 'https://git.kzntsv.site',
|
||||
machine: 'test',
|
||||
projects: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
describe('cache', () => {
|
||||
it('writes JSON atomically and reads it back', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const file = join(dir, 'tasks.json');
|
||||
await writeCache(file, sample);
|
||||
const back = await readCache(file);
|
||||
expect(back).toEqual(sample);
|
||||
});
|
||||
|
||||
it('does not leave .tmp file on success', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const file = join(dir, 'tasks.json');
|
||||
await writeCache(file, sample);
|
||||
await expect(stat(file + '.tmp')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('returns null when cache file missing', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const back = await readCache(join(dir, 'missing.json'));
|
||||
expect(back).toBeNull();
|
||||
});
|
||||
|
||||
it('creates parent directory if missing', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const file = join(dir, 'nested', 'deep', 'tasks.json');
|
||||
await writeCache(file, sample);
|
||||
const txt = await readFile(file, 'utf8');
|
||||
expect(JSON.parse(txt)).toEqual(sample);
|
||||
});
|
||||
|
||||
it('exports CACHE_SCHEMA_VERSION = 3', () => {
|
||||
expect(CACHE_SCHEMA_VERSION).toBe(3);
|
||||
});
|
||||
|
||||
it('returns null on legacy cache without schemaVersion', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const file = join(dir, 'tasks.json');
|
||||
const legacy = {
|
||||
synced_at: '2026-04-29T12:00:00.000Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'test',
|
||||
projects: [{ name: 'bare-name', default_branch: 'main', fetched_at: '2026-04-29T12:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '' }],
|
||||
errors: [],
|
||||
};
|
||||
await writeFile(file, JSON.stringify(legacy), 'utf8');
|
||||
const back = await readCache(file);
|
||||
expect(back).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on schemaVersion mismatch', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const file = join(dir, 'tasks.json');
|
||||
const v1 = { ...sample, schemaVersion: 1 };
|
||||
await writeFile(file, JSON.stringify(v1), 'utf8');
|
||||
const back = await readCache(file);
|
||||
expect(back).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on v2 cache (pre-agenda-rename, name === "_meta" entries are stale)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const file = join(dir, 'tasks.json');
|
||||
const v2 = { ...sample, schemaVersion: 2 };
|
||||
await writeFile(file, JSON.stringify(v2), 'utf8');
|
||||
const back = await readCache(file);
|
||||
expect(back).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectCacheInvalidation', () => {
|
||||
it('returns invalidated:false when file missing', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const r = await detectCacheInvalidation(join(dir, 'missing.json'));
|
||||
expect(r).toEqual({ invalidated: false, oldVersion: null });
|
||||
});
|
||||
|
||||
it('returns invalidated:true, oldVersion:null on legacy (no field)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const file = join(dir, 'tasks.json');
|
||||
await writeFile(file, JSON.stringify({ synced_at: 't', projects: [] }), 'utf8');
|
||||
const r = await detectCacheInvalidation(file);
|
||||
expect(r).toEqual({ invalidated: true, oldVersion: null });
|
||||
});
|
||||
|
||||
it('returns invalidated:true, oldVersion:1 on v1 cache', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const file = join(dir, 'tasks.json');
|
||||
await writeFile(file, JSON.stringify({ schemaVersion: 1, projects: [] }), 'utf8');
|
||||
const r = await detectCacheInvalidation(file);
|
||||
expect(r).toEqual({ invalidated: true, oldVersion: 1 });
|
||||
});
|
||||
|
||||
it('returns invalidated:true, oldVersion:2 on v2 cache (pre-agenda-rename)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const file = join(dir, 'tasks.json');
|
||||
await writeFile(file, JSON.stringify({ schemaVersion: 2, projects: [] }), 'utf8');
|
||||
const r = await detectCacheInvalidation(file);
|
||||
expect(r).toEqual({ invalidated: true, oldVersion: 2 });
|
||||
});
|
||||
|
||||
it('returns invalidated:false, oldVersion:3 on current cache', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const file = join(dir, 'tasks.json');
|
||||
await writeCache(file, sample);
|
||||
const r = await detectCacheInvalidation(file);
|
||||
expect(r).toEqual({ invalidated: false, oldVersion: 3 });
|
||||
});
|
||||
});
|
||||
418
lib/projects-meta-mcp/tests/lib/claim.test.ts
Normal file
418
lib/projects-meta-mcp/tests/lib/claim.test.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isClaimable,
|
||||
isExpiredClaim,
|
||||
isReclaimable,
|
||||
selectClaimableTask,
|
||||
makeClaimStamp,
|
||||
type ClaimCandidate,
|
||||
} from '../../src/lib/claim.js';
|
||||
import type { ParsedTask } from '../../src/lib/status-md.js';
|
||||
|
||||
function task(slug: string, over: Partial<ParsedTask> = {}): ParsedTask {
|
||||
return { slug, status: 'ready', description: slug, next: null, ...over };
|
||||
}
|
||||
|
||||
function cand(project: string, t: ParsedTask): ClaimCandidate {
|
||||
return { project, task: t };
|
||||
}
|
||||
|
||||
describe('isClaimable', () => {
|
||||
it('true only for ⚪ ready + unowned', () => {
|
||||
expect(isClaimable(task('a'))).toBe(true);
|
||||
expect(isClaimable(task('a', { status: 'active' }))).toBe(false);
|
||||
expect(isClaimable(task('a', { owner: 'm:r:s' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectClaimableTask', () => {
|
||||
const owner = 'win11:claude-opus:s1';
|
||||
|
||||
it('happy: returns first claimable candidate in board order', () => {
|
||||
const out = selectClaimableTask(
|
||||
[cand('p', task('first')), cand('p', task('second'))],
|
||||
{ claimerOwner: owner },
|
||||
);
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.task.slug).toBe('first');
|
||||
});
|
||||
|
||||
it('skips non-claimable (active / owned) silently', () => {
|
||||
const out = selectClaimableTask(
|
||||
[cand('p', task('busy', { status: 'active' })), cand('p', task('free'))],
|
||||
{ claimerOwner: owner },
|
||||
);
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.task.slug).toBe('free');
|
||||
});
|
||||
|
||||
it('reject: runtime not in task.runtimeAllowed', () => {
|
||||
const out = selectClaimableTask(
|
||||
[cand('p', task('locked', { runtimeAllowed: ['claude-opus'] }))],
|
||||
{ runtime: 'hermes-glm51', claimerOwner: owner },
|
||||
);
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) {
|
||||
expect(out.skipped).toHaveLength(1);
|
||||
expect(out.skipped[0].reason).toBe('runtime-not-allowed');
|
||||
expect(out.skipped[0].detail).toBe('claude-opus');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts when runtime IS allowed; unrestricted task ignores runtime', () => {
|
||||
const allowed = selectClaimableTask(
|
||||
[cand('p', task('locked', { runtimeAllowed: ['claude-opus', 'claude-sonnet'] }))],
|
||||
{ runtime: 'claude-opus', claimerOwner: owner },
|
||||
);
|
||||
expect(allowed.ok).toBe(true);
|
||||
const open = selectClaimableTask([cand('p', task('open'))], { claimerOwner: owner });
|
||||
expect(open.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('reject: capabilities mismatch (task.requirements ⊄ self.capabilities)', () => {
|
||||
const out = selectClaimableTask(
|
||||
[cand('p', task('dbjob', { requirements: ['needs-db', 'needs-secrets'] }))],
|
||||
{ capabilities: ['needs-db'], claimerOwner: owner },
|
||||
);
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) {
|
||||
expect(out.skipped[0].reason).toBe('capabilities-missing');
|
||||
expect(out.skipped[0].detail).toBe('needs-secrets');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts when requirements are a subset of capabilities', () => {
|
||||
const out = selectClaimableTask(
|
||||
[cand('p', task('dbjob', { requirements: ['needs-db'] }))],
|
||||
{ capabilities: ['needs-db', 'needs-secrets'], claimerOwner: owner },
|
||||
);
|
||||
expect(out.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('no candidates → ok:false with empty skipped', () => {
|
||||
const out = selectClaimableTask([], { claimerOwner: owner });
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) expect(out.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it('reject: weight needs-human is excluded from autonomous claim', () => {
|
||||
const out = selectClaimableTask(
|
||||
[cand('p', task('touchy', { weight: 'needs-human' }))],
|
||||
{ claimerOwner: owner },
|
||||
);
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) {
|
||||
expect(out.skipped).toHaveLength(1);
|
||||
expect(out.skipped[0].reason).toBe('needs-human');
|
||||
}
|
||||
});
|
||||
|
||||
it('needs-human is skipped but a following claimable task is still picked', () => {
|
||||
const out = selectClaimableTask(
|
||||
[cand('p', task('touchy', { weight: 'needs-human' })), cand('p', task('ok'))],
|
||||
{ claimerOwner: owner },
|
||||
);
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.task.slug).toBe('ok');
|
||||
});
|
||||
|
||||
it('other weights (cheap-ok / needs-claude) remain claimable', () => {
|
||||
expect(
|
||||
selectClaimableTask([cand('p', task('c', { weight: 'cheap-ok' }))], { claimerOwner: owner }).ok,
|
||||
).toBe(true);
|
||||
expect(
|
||||
selectClaimableTask([cand('p', task('n', { weight: 'needs-claude' }))], { claimerOwner: owner }).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('lazy revoke: selects an expired-claim task when now is provided', () => {
|
||||
const stale = task('stale', {
|
||||
status: 'active',
|
||||
owner: 'old:claude-opus:s0',
|
||||
claimExpiresAt: '2026-06-07T11:00:00.000Z',
|
||||
});
|
||||
const out = selectClaimableTask([cand('p', stale)], {
|
||||
claimerOwner: owner,
|
||||
now: new Date('2026-06-07T12:00:00.000Z'),
|
||||
});
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.task.slug).toBe('stale');
|
||||
});
|
||||
|
||||
it('does NOT select a live (unexpired) claim even with now provided', () => {
|
||||
const live = task('live', {
|
||||
status: 'active',
|
||||
owner: 'other:claude-opus:s2',
|
||||
claimExpiresAt: '2026-06-07T12:30:00.000Z',
|
||||
});
|
||||
const out = selectClaimableTask([cand('p', live)], {
|
||||
claimerOwner: owner,
|
||||
now: new Date('2026-06-07T12:00:00.000Z'),
|
||||
});
|
||||
expect(out.ok).toBe(false);
|
||||
});
|
||||
|
||||
describe('one-agent-per-project claim-gate', () => {
|
||||
const now = new Date('2026-06-07T12:00:00.000Z');
|
||||
|
||||
// (a) a project with one live-claimed task → its OTHER ready tasks are skipped.
|
||||
it('skips a ready task whose project already has a LIVE-claimed task', () => {
|
||||
const liveClaimed = task('in-progress', {
|
||||
status: 'active',
|
||||
owner: 'other:claude-opus:s2',
|
||||
claimExpiresAt: '2026-06-07T12:30:00.000Z', // future → live
|
||||
});
|
||||
const ready = task('also-ready');
|
||||
const out = selectClaimableTask([cand('p', ready)], {
|
||||
claimerOwner: owner,
|
||||
boardTasks: { p: [liveClaimed, ready] },
|
||||
now,
|
||||
});
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) {
|
||||
expect(out.skipped).toHaveLength(1);
|
||||
expect(out.skipped[0].reason).toBe('project-busy');
|
||||
expect(out.skipped[0].slug).toBe('also-ready');
|
||||
}
|
||||
});
|
||||
|
||||
// a live claim with no explicit TTL (owned, in-flight) still marks the project busy.
|
||||
it('treats an owned task without an expiry as a live claim (project busy)', () => {
|
||||
const owned = task('owned-no-ttl', { status: 'active', owner: 'other:claude-opus:s2' });
|
||||
const ready = task('also-ready');
|
||||
const out = selectClaimableTask([cand('p', ready)], {
|
||||
claimerOwner: owner,
|
||||
boardTasks: { p: [owned, ready] },
|
||||
now,
|
||||
});
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) expect(out.skipped[0].reason).toBe('project-busy');
|
||||
});
|
||||
|
||||
// (b) a project whose only active task has an EXPIRED claim → its ready tasks ARE claimable.
|
||||
it('does NOT mark a project busy when its only active task has an EXPIRED claim', () => {
|
||||
const stale = task('stale', {
|
||||
status: 'active',
|
||||
owner: 'old:claude-opus:s0',
|
||||
claimExpiresAt: '2026-06-07T11:00:00.000Z', // past → expired, reclaimable
|
||||
});
|
||||
const ready = task('also-ready');
|
||||
const out = selectClaimableTask([cand('p', ready)], {
|
||||
claimerOwner: owner,
|
||||
boardTasks: { p: [stale, ready] },
|
||||
now,
|
||||
});
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.task.slug).toBe('also-ready');
|
||||
});
|
||||
|
||||
// (c) two DIFFERENT projects each with a ready task → no cross-project exclusion.
|
||||
it('does not exclude across projects — a busy project P does not block project Q', () => {
|
||||
const liveP = task('p-busy', {
|
||||
status: 'active',
|
||||
owner: 'other:claude-opus:s2',
|
||||
claimExpiresAt: '2026-06-07T12:30:00.000Z',
|
||||
});
|
||||
const readyP = task('p-ready');
|
||||
const readyQ = task('q-ready');
|
||||
const out = selectClaimableTask([cand('p', readyP), cand('q', readyQ)], {
|
||||
claimerOwner: owner,
|
||||
boardTasks: { p: [liveP, readyP], q: [readyQ] },
|
||||
now,
|
||||
});
|
||||
// P is busy → p-ready skipped, but q-ready claims.
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.task.slug).toBe('q-ready');
|
||||
});
|
||||
|
||||
// (d) regression: a project with no active task → its ready task claims normally.
|
||||
it('claims normally when the project has no active task', () => {
|
||||
const ready = task('clean-ready');
|
||||
const out = selectClaimableTask([cand('p', ready)], {
|
||||
claimerOwner: owner,
|
||||
boardTasks: { p: [ready] },
|
||||
now,
|
||||
});
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.task.slug).toBe('clean-ready');
|
||||
});
|
||||
|
||||
// the claimer's own live claim still marks the project busy (no second task in same repo).
|
||||
it('a project the claimer itself live-claimed is also busy', () => {
|
||||
const mine = task('mine', {
|
||||
status: 'active',
|
||||
owner,
|
||||
claimExpiresAt: '2026-06-07T12:30:00.000Z',
|
||||
});
|
||||
const ready = task('second');
|
||||
const out = selectClaimableTask([cand('p', ready)], {
|
||||
claimerOwner: owner,
|
||||
boardTasks: { p: [mine, ready] },
|
||||
now,
|
||||
});
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) expect(out.skipped[0].reason).toBe('project-busy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectAllowlist (rollout scope guard)', () => {
|
||||
const now = new Date('2026-06-07T12:00:00.000Z');
|
||||
|
||||
// (a) allowlist set → non-listed project skipped (out-of-scope); listed claimable.
|
||||
it('skips a candidate whose project is NOT in the allowlist, claims a listed one', () => {
|
||||
const out = selectClaimableTask(
|
||||
[cand('owner/off-scope', task('a')), cand('owner/in-scope', task('b'))],
|
||||
{ claimerOwner: owner, projectAllowlist: ['owner/in-scope'] },
|
||||
);
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.project).toBe('owner/in-scope');
|
||||
});
|
||||
|
||||
it('reports out-of-scope when the ONLY candidate is not in the allowlist', () => {
|
||||
const out = selectClaimableTask([cand('owner/off-scope', task('a'))], {
|
||||
claimerOwner: owner,
|
||||
projectAllowlist: ['owner/in-scope'],
|
||||
});
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) {
|
||||
expect(out.skipped).toHaveLength(1);
|
||||
expect(out.skipped[0].reason).toBe('out-of-scope');
|
||||
expect(out.skipped[0].project).toBe('owner/off-scope');
|
||||
}
|
||||
});
|
||||
|
||||
// (b) empty / undefined allowlist → no filtering (regression / backward-compat).
|
||||
it('does no filtering when the allowlist is undefined (federation-wide)', () => {
|
||||
const out = selectClaimableTask([cand('owner/anything', task('a'))], {
|
||||
claimerOwner: owner,
|
||||
});
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.project).toBe('owner/anything');
|
||||
});
|
||||
|
||||
it('does no filtering when the allowlist is empty (federation-wide)', () => {
|
||||
const out = selectClaimableTask([cand('owner/anything', task('a'))], {
|
||||
claimerOwner: owner,
|
||||
projectAllowlist: [],
|
||||
});
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.project).toBe('owner/anything');
|
||||
});
|
||||
|
||||
// (c) allowlist + project-busy compose: a listed-but-busy project is still skipped.
|
||||
it('composes with project-busy — listed but busy project → project-busy skip', () => {
|
||||
const liveClaimed = task('in-progress', {
|
||||
status: 'active',
|
||||
owner: 'other:claude-opus:s2',
|
||||
claimExpiresAt: '2026-06-07T12:30:00.000Z', // future → live
|
||||
});
|
||||
const ready = task('also-ready');
|
||||
const out = selectClaimableTask([cand('owner/in-scope', ready)], {
|
||||
claimerOwner: owner,
|
||||
projectAllowlist: ['owner/in-scope'],
|
||||
boardTasks: { 'owner/in-scope': [liveClaimed, ready] },
|
||||
now,
|
||||
});
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) {
|
||||
expect(out.skipped).toHaveLength(1);
|
||||
expect(out.skipped[0].reason).toBe('project-busy');
|
||||
}
|
||||
});
|
||||
|
||||
it('out-of-scope takes precedence over project-busy for a non-listed busy project', () => {
|
||||
const liveClaimed = task('in-progress', {
|
||||
status: 'active',
|
||||
owner: 'other:claude-opus:s2',
|
||||
claimExpiresAt: '2026-06-07T12:30:00.000Z',
|
||||
});
|
||||
const ready = task('also-ready');
|
||||
const out = selectClaimableTask([cand('owner/off-scope', ready)], {
|
||||
claimerOwner: owner,
|
||||
projectAllowlist: ['owner/in-scope'],
|
||||
boardTasks: { 'owner/off-scope': [liveClaimed, ready] },
|
||||
now,
|
||||
});
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) expect(out.skipped[0].reason).toBe('out-of-scope');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExpiredClaim / isReclaimable', () => {
|
||||
const now = new Date('2026-06-07T12:00:00.000Z');
|
||||
|
||||
it('expired claim true, live claim false', () => {
|
||||
const expired = task('x', { status: 'active', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:50:00.000Z' });
|
||||
const live = task('y', { status: 'active', owner: 'm:r:s', claimExpiresAt: '2026-06-07T12:10:00.000Z' });
|
||||
expect(isExpiredClaim(expired, now)).toBe(true);
|
||||
expect(isExpiredClaim(live, now)).toBe(false);
|
||||
});
|
||||
|
||||
it('owned-without-expiry and unowned are not expired claims', () => {
|
||||
expect(isExpiredClaim(task('a', { owner: 'm:r:s' }), now)).toBe(false);
|
||||
expect(isExpiredClaim(task('b'), now)).toBe(false);
|
||||
});
|
||||
|
||||
it('isReclaimable: ready OR expired claim', () => {
|
||||
expect(isReclaimable(task('r'), now)).toBe(true);
|
||||
expect(isReclaimable(task('e', { status: 'active', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' }), now)).toBe(true);
|
||||
expect(isReclaimable(task('busy', { status: 'active', owner: 'm:r:s', claimExpiresAt: '2026-06-07T13:00:00.000Z' }), now)).toBe(false);
|
||||
});
|
||||
|
||||
it('terminal/non-active task with stale stamp is NEVER reclaimable', () => {
|
||||
// root-cause-#3: a 🟢 done task whose claim-stamp lingered and TTL lapsed
|
||||
// must NOT become reclaimable (was re-claimed in a loop, burning tokens).
|
||||
const doneStale = task('d', { status: 'done', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' });
|
||||
expect(isExpiredClaim(doneStale, now)).toBe(false);
|
||||
expect(isReclaimable(doneStale, now)).toBe(false);
|
||||
// 🔵 blocked/parked task with a stale stamp is equally not reclaimable.
|
||||
const blockedStale = task('b', { status: 'blocked', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' });
|
||||
expect(isExpiredClaim(blockedStale, now)).toBe(false);
|
||||
expect(isReclaimable(blockedStale, now)).toBe(false);
|
||||
// crash-recovery preserved: an ACTIVE owned task with lapsed TTL stays reclaimable.
|
||||
const activeStale = task('a', { status: 'active', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' });
|
||||
expect(isExpiredClaim(activeStale, now)).toBe(true);
|
||||
expect(isReclaimable(activeStale, now)).toBe(true);
|
||||
});
|
||||
|
||||
it('selectClaimableTask skips a done/blocked task with stale expired stamp', () => {
|
||||
const owner = 'win11:claude-opus:s1';
|
||||
const doneStale = task('d', { status: 'done', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' });
|
||||
const blockedStale = task('b', { status: 'blocked', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' });
|
||||
const out = selectClaimableTask([cand('p', doneStale), cand('p', blockedStale)], {
|
||||
claimerOwner: owner,
|
||||
now,
|
||||
});
|
||||
expect(out.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('selectClaimableTask still selects an active task with lapsed TTL (crash recovery)', () => {
|
||||
const owner = 'win11:claude-opus:s1';
|
||||
const activeStale = task('a', { status: 'active', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' });
|
||||
const out = selectClaimableTask([cand('p', activeStale)], {
|
||||
claimerOwner: owner,
|
||||
now,
|
||||
boardTasks: { p: [activeStale] },
|
||||
});
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.task.slug).toBe('a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeClaimStamp', () => {
|
||||
it('stamps owner, a uuid token, and now+ttl expiry', () => {
|
||||
const now = new Date('2026-06-07T12:00:00.000Z');
|
||||
const s = makeClaimStamp('win11:claude-opus:s1', now, 10 * 60 * 1000);
|
||||
expect(s.owner).toBe('win11:claude-opus:s1');
|
||||
expect(s.claimToken).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(s.claimExpiresAt).toBe('2026-06-07T12:10:00.000Z');
|
||||
});
|
||||
|
||||
it('produces distinct tokens across calls', () => {
|
||||
const now = new Date('2026-06-07T12:00:00.000Z');
|
||||
const a = makeClaimStamp('o', now);
|
||||
const b = makeClaimStamp('o', now);
|
||||
expect(a.claimToken).not.toBe(b.claimToken);
|
||||
});
|
||||
});
|
||||
200
lib/projects-meta-mcp/tests/lib/config.test.ts
Normal file
200
lib/projects-meta-mcp/tests/lib/config.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path/posix';
|
||||
import { getPaths, loadAuth } from '../../src/lib/config.js';
|
||||
|
||||
describe('getPaths', () => {
|
||||
it('builds canonical paths from $HOME', () => {
|
||||
const p = getPaths('/home/u');
|
||||
expect(p.cacheDir.replaceAll('\\', '/')).toBe('/home/u/.cache/projects-mcp');
|
||||
expect(p.cacheFile.replaceAll('\\', '/')).toBe('/home/u/.cache/projects-mcp/tasks.json');
|
||||
expect(p.authFile.replaceAll('\\', '/')).toBe('/home/u/.config/projects-mcp/auth.toml');
|
||||
expect(p.sharedWikiClone.replaceAll('\\', '/')).toBe('/home/u/projects/.wiki');
|
||||
expect(p.agendaTasksDir.replaceAll('\\', '/')).toBe('/home/u/projects/.tasks');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadAuth', () => {
|
||||
it('parses well-formed auth.toml', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
[
|
||||
'gitea_url = "https://git.kzntsv.site"',
|
||||
'gitea_user = "OpeItcLoc03"',
|
||||
'gitea_token = "abc123"',
|
||||
].join('\n'),
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.giteaUrl).toBe('https://git.kzntsv.site');
|
||||
expect(cfg.giteaUser).toBe('OpeItcLoc03');
|
||||
expect(cfg.giteaToken).toBe('abc123');
|
||||
expect(cfg.agendaTasksRepo).toBe('projects-tasks');
|
||||
});
|
||||
|
||||
it('honors custom agenda_tasks_repo from auth.toml', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\nagenda_tasks_repo = "custom-meta"\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.agendaTasksRepo).toBe('custom-meta');
|
||||
});
|
||||
|
||||
it('falls back to default agenda_tasks_repo when value is empty string', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\nagenda_tasks_repo = ""\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.agendaTasksRepo).toBe('projects-tasks');
|
||||
});
|
||||
|
||||
it('strips trailing slash from gitea_url', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://git.kzntsv.site/"\ngitea_user = "x"\ngitea_token = "y"\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.giteaUrl).toBe('https://git.kzntsv.site');
|
||||
});
|
||||
|
||||
it('throws on missing field', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(file, 'gitea_url = "x"\ngitea_user = "y"\n');
|
||||
await expect(loadAuth(file)).rejects.toThrow(/gitea_token/);
|
||||
});
|
||||
|
||||
it('falls back to [gitea_user] when gitea_owners is absent (legacy single-owner)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "OpeItcLoc03"\ngitea_token = "t"\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.giteaOwners).toEqual(['OpeItcLoc03']);
|
||||
});
|
||||
|
||||
it('honors gitea_owners array when provided', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "OpeItcLoc03"\ngitea_token = "t"\ngitea_owners = ["victor", "cancel_music"]\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.giteaOwners).toEqual(['victor', 'cancel_music']);
|
||||
});
|
||||
|
||||
it('falls back to [gitea_user] when gitea_owners is empty array', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\ngitea_owners = []\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.giteaOwners).toEqual(['u']);
|
||||
});
|
||||
|
||||
it('trims whitespace from gitea_owners entries', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\ngitea_owners = [" victor ", "cancel_music"]\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.giteaOwners).toEqual(['victor', 'cancel_music']);
|
||||
});
|
||||
|
||||
it('splits qualified <owner>/<repo> in agenda_tasks_repo into owner + bare name', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\nagenda_tasks_repo = "OpeItcLoc03/agenda"\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.agendaTasksRepo).toBe('agenda');
|
||||
expect(cfg.agendaTasksOwner).toBe('OpeItcLoc03');
|
||||
});
|
||||
|
||||
it('leaves agendaTasksOwner undefined when agenda_tasks_repo is bare', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\nagenda_tasks_repo = "agenda"\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.agendaTasksRepo).toBe('agenda');
|
||||
expect(cfg.agendaTasksOwner).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves agendaTasksOwner undefined for default agenda_tasks_repo', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.agendaTasksRepo).toBe('projects-tasks');
|
||||
expect(cfg.agendaTasksOwner).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses gitea_aggregate_skip_owners as string[]', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\ngitea_aggregate_skip_owners = ["OpeItcLoc03"]\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.giteaAggregateSkipOwners).toEqual(['OpeItcLoc03']);
|
||||
});
|
||||
|
||||
it('defaults gitea_aggregate_skip_owners to [] when absent', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.giteaAggregateSkipOwners).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults gitea_aggregate_skip_owners to [] when empty array', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\ngitea_aggregate_skip_owners = []\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.giteaAggregateSkipOwners).toEqual([]);
|
||||
});
|
||||
|
||||
it('trims whitespace and drops empty strings from gitea_aggregate_skip_owners entries', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\ngitea_aggregate_skip_owners = [" OpeItcLoc03 ", "", " "]\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.giteaAggregateSkipOwners).toEqual(['OpeItcLoc03']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { appendDecisionTrail, type DecisionTrailEntry } from '../../src/lib/decision-trail-writer.js';
|
||||
|
||||
const entry: DecisionTrailEntry = {
|
||||
timestampIso: '2026-06-08T10:00:00.000Z',
|
||||
question: 'cache key includes locale?',
|
||||
blastRadius: 'reversible',
|
||||
decidedBy: 'arbiter',
|
||||
ruling: 'include locale',
|
||||
rationale: 'avoids cross-locale bleed',
|
||||
escalationChain: ['brief'],
|
||||
};
|
||||
|
||||
describe('appendDecisionTrail', () => {
|
||||
it('creates a "## Decision trail" section when none exists', () => {
|
||||
const md = '# my-task\n\n## Goal\nDo the thing.\n';
|
||||
const out = appendDecisionTrail(md, entry);
|
||||
expect(out).toContain('## Decision trail');
|
||||
expect(out).toContain('### consult 1 — 2026-06-08T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('keeps the original content intact', () => {
|
||||
const md = '# my-task\n\n## Goal\nDo the thing.\n';
|
||||
const out = appendDecisionTrail(md, entry);
|
||||
expect(out).toContain('# my-task');
|
||||
expect(out).toContain('## Goal');
|
||||
expect(out).toContain('Do the thing.');
|
||||
});
|
||||
|
||||
it('renders every contract field', () => {
|
||||
const out = appendDecisionTrail('# t\n', entry);
|
||||
expect(out).toContain('- question: cache key includes locale?');
|
||||
expect(out).toContain('- blast_radius: reversible');
|
||||
expect(out).toContain('- decided_by: arbiter');
|
||||
expect(out).toContain('- ruling: include locale');
|
||||
expect(out).toContain('- rationale: avoids cross-locale bleed');
|
||||
expect(out).toContain('- escalation_chain: brief');
|
||||
});
|
||||
|
||||
it('numbers consecutive consults 1, 2, 3 …', () => {
|
||||
let md = '# t\n';
|
||||
md = appendDecisionTrail(md, entry);
|
||||
md = appendDecisionTrail(md, { ...entry, question: 'second?' });
|
||||
md = appendDecisionTrail(md, { ...entry, question: 'third?' });
|
||||
expect(md).toContain('### consult 1 —');
|
||||
expect(md).toContain('### consult 2 —');
|
||||
expect(md).toContain('### consult 3 —');
|
||||
expect((md.match(/## Decision trail/g) ?? []).length).toBe(1); // section created once
|
||||
});
|
||||
|
||||
it('joins the escalation chain with arrows', () => {
|
||||
const out = appendDecisionTrail('# t\n', {
|
||||
...entry,
|
||||
escalationChain: ['brief', 'read-repo', 'arbiter-fork'],
|
||||
});
|
||||
expect(out).toContain('- escalation_chain: brief → read-repo → arbiter-fork');
|
||||
});
|
||||
|
||||
it('renders a halt entry (no ruling) with the escalation rationale', () => {
|
||||
const out = appendDecisionTrail('# t\n', {
|
||||
...entry,
|
||||
decidedBy: 'human-required',
|
||||
ruling: null,
|
||||
rationale: 'escalated: consult-cap exceeded',
|
||||
escalationChain: ['brief', 'arbiter-fork'],
|
||||
});
|
||||
expect(out).toContain('- decided_by: human-required');
|
||||
expect(out).toContain('- ruling: —');
|
||||
expect(out).toContain('- rationale: escalated: consult-cap exceeded');
|
||||
});
|
||||
});
|
||||
187
lib/projects-meta-mcp/tests/lib/domain-detector.test.ts
Normal file
187
lib/projects-meta-mcp/tests/lib/domain-detector.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
detectDomain,
|
||||
detectProjectIdentity,
|
||||
parseRemoteUrl,
|
||||
resolveSourceProject,
|
||||
} from '../../src/lib/domain-detector.js';
|
||||
|
||||
async function tmp(): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), 'dd-'));
|
||||
}
|
||||
|
||||
describe('detectDomain', () => {
|
||||
it('returns node when package.json present', async () => {
|
||||
const d = await tmp();
|
||||
await writeFile(join(d, 'package.json'), '{}');
|
||||
expect(await detectDomain(d)).toBe('node');
|
||||
});
|
||||
|
||||
it('returns embedded for platformio.ini', async () => {
|
||||
const d = await tmp();
|
||||
await writeFile(join(d, 'platformio.ini'), '[env]');
|
||||
expect(await detectDomain(d)).toBe('embedded');
|
||||
});
|
||||
|
||||
it('returns embedded for .ino', async () => {
|
||||
const d = await tmp();
|
||||
await writeFile(join(d, 'sketch.ino'), 'void setup() {}');
|
||||
expect(await detectDomain(d)).toBe('embedded');
|
||||
});
|
||||
|
||||
it('returns embedded for ARM CMakeLists', async () => {
|
||||
const d = await tmp();
|
||||
await writeFile(join(d, 'CMakeLists.txt'), 'set(CMAKE_C_COMPILER arm-none-eabi-gcc)');
|
||||
expect(await detectDomain(d)).toBe('embedded');
|
||||
});
|
||||
|
||||
it('returns web for vite config', async () => {
|
||||
const d = await tmp();
|
||||
await writeFile(join(d, 'vite.config.ts'), 'export default {}');
|
||||
expect(await detectDomain(d)).toBe('web');
|
||||
});
|
||||
|
||||
it('returns web for static index.html without package.json', async () => {
|
||||
const d = await tmp();
|
||||
await writeFile(join(d, 'index.html'), '<html></html>');
|
||||
expect(await detectDomain(d)).toBe('web');
|
||||
});
|
||||
|
||||
it('returns unknown for empty dir', async () => {
|
||||
const d = await tmp();
|
||||
expect(await detectDomain(d)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('package.json wins over index.html', async () => {
|
||||
const d = await tmp();
|
||||
await writeFile(join(d, 'package.json'), '{}');
|
||||
await writeFile(join(d, 'index.html'), '<html></html>');
|
||||
expect(await detectDomain(d)).toBe('node');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRemoteUrl', () => {
|
||||
it('parses https Gitea URL with .git suffix', () => {
|
||||
expect(parseRemoteUrl('https://git.kzntsv.site/OpeItcLoc03/common.git')).toEqual({
|
||||
owner: 'OpeItcLoc03',
|
||||
repo: 'common',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses https Gitea URL without .git suffix', () => {
|
||||
expect(parseRemoteUrl('https://git.kzntsv.site/victor/books')).toEqual({
|
||||
owner: 'victor',
|
||||
repo: 'books',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses https URL with port', () => {
|
||||
expect(parseRemoteUrl('https://git.kzntsv.site:443/victor/books.git')).toEqual({
|
||||
owner: 'victor',
|
||||
repo: 'books',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses ssh URL', () => {
|
||||
expect(parseRemoteUrl('git@git.kzntsv.site:victor/books.git')).toEqual({
|
||||
owner: 'victor',
|
||||
repo: 'books',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses ssh URL without .git suffix', () => {
|
||||
expect(parseRemoteUrl('git@github.com:owner/repo')).toEqual({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
});
|
||||
});
|
||||
|
||||
it('strips trailing slash before parsing', () => {
|
||||
expect(parseRemoteUrl('https://git.kzntsv.site/victor/books/')).toEqual({
|
||||
owner: 'victor',
|
||||
repo: 'books',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(parseRemoteUrl('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-URL local path', () => {
|
||||
expect(parseRemoteUrl('/path/to/local/repo')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for URL without owner/repo path', () => {
|
||||
expect(parseRemoteUrl('https://example.com/single')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectProjectIdentity', () => {
|
||||
it('returns parsed identity when git remote returns Gitea https URL', async () => {
|
||||
const fakeExec = async () => ({
|
||||
stdout: 'https://git.kzntsv.site/OpeItcLoc03/common.git\n',
|
||||
stderr: '',
|
||||
});
|
||||
expect(await detectProjectIdentity('/cwd', fakeExec)).toEqual({
|
||||
owner: 'OpeItcLoc03',
|
||||
repo: 'common',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns parsed identity when git remote returns ssh URL', async () => {
|
||||
const fakeExec = async () => ({
|
||||
stdout: 'git@git.kzntsv.site:victor/books.git\n',
|
||||
stderr: '',
|
||||
});
|
||||
expect(await detectProjectIdentity('/cwd', fakeExec)).toEqual({
|
||||
owner: 'victor',
|
||||
repo: 'books',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when git command fails (no remote / not a repo)', async () => {
|
||||
const fakeExec = async () => {
|
||||
throw new Error('fatal: not a git repository');
|
||||
};
|
||||
expect(await detectProjectIdentity('/cwd', fakeExec)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when remote URL is unparseable', async () => {
|
||||
const fakeExec = async () => ({ stdout: 'garbage-not-a-url\n', stderr: '' });
|
||||
expect(await detectProjectIdentity('/cwd', fakeExec)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSourceProject', () => {
|
||||
it('returns qualified <owner>/<repo> with fellBack=false when remote resolves', async () => {
|
||||
const fakeExec = async () => ({
|
||||
stdout: 'https://git.kzntsv.site/victor/books.git\n',
|
||||
stderr: '',
|
||||
});
|
||||
expect(await resolveSourceProject('/some/cwd/path/books', fakeExec)).toEqual({
|
||||
sourceProject: 'victor/books',
|
||||
fellBack: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to basename(cwd) with fellBack=true when remote command fails', async () => {
|
||||
const fakeExec = async () => {
|
||||
throw new Error('fatal: not a git repository');
|
||||
};
|
||||
expect(await resolveSourceProject('/home/user/projects/.workshop', fakeExec)).toEqual({
|
||||
sourceProject: '.workshop',
|
||||
fellBack: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to basename(cwd) with fellBack=true when remote URL is unparseable', async () => {
|
||||
const fakeExec = async () => ({ stdout: 'garbage-not-a-url\n', stderr: '' });
|
||||
expect(await resolveSourceProject('/tmp/loose-folder', fakeExec)).toEqual({
|
||||
sourceProject: 'loose-folder',
|
||||
fellBack: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
37
lib/projects-meta-mcp/tests/lib/git.test.ts
Normal file
37
lib/projects-meta-mcp/tests/lib/git.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { pullLocalClone } from '../../src/lib/git.js';
|
||||
|
||||
describe('pullLocalClone', () => {
|
||||
it('returns pulled:true on successful ff-only pull', async () => {
|
||||
const mockExec = async () => ({ stdout: '', stderr: 'Already up to date.\n' });
|
||||
const result = await pullLocalClone('/fake/wiki', mockExec);
|
||||
expect(result.pulled).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes correct git args', async () => {
|
||||
const calls: Array<{ file: string; args: string[] }> = [];
|
||||
const mockExec = async (file: string, args: string[]) => {
|
||||
calls.push({ file, args });
|
||||
return { stdout: '', stderr: '' };
|
||||
};
|
||||
await pullLocalClone('/some/wiki', mockExec);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].file).toBe('git');
|
||||
expect(calls[0].args).toEqual(['-C', '/some/wiki', 'pull', '--ff-only']);
|
||||
});
|
||||
|
||||
it('returns pulled:false with error message on non-ff failure', async () => {
|
||||
const mockExec = async () => { throw new Error('fatal: Not possible to fast-forward, aborting.'); };
|
||||
const result = await pullLocalClone('/fake/wiki', mockExec);
|
||||
expect(result.pulled).toBe(false);
|
||||
expect(result.error).toContain('fast-forward');
|
||||
});
|
||||
|
||||
it('returns pulled:false when git is not found', async () => {
|
||||
const mockExec = async () => { throw new Error('ENOENT: git not found'); };
|
||||
const result = await pullLocalClone('/fake/wiki', mockExec);
|
||||
expect(result.pulled).toBe(false);
|
||||
expect(result.error).toContain('ENOENT');
|
||||
});
|
||||
});
|
||||
214
lib/projects-meta-mcp/tests/lib/gitea.test.ts
Normal file
214
lib/projects-meta-mcp/tests/lib/gitea.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { makeGiteaBackend } from '../../src/lib/gitea.js';
|
||||
const makeGiteaClient = makeGiteaBackend;
|
||||
|
||||
function mockFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
|
||||
return async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
return handler(url, init);
|
||||
};
|
||||
}
|
||||
|
||||
describe('GiteaClient', () => {
|
||||
it('lists user repos with token in header', async () => {
|
||||
let receivedAuth = '';
|
||||
let callCount = 0;
|
||||
const fetchImpl = mockFetch((url, init) => {
|
||||
receivedAuth = (init?.headers as Record<string, string>)['Authorization'];
|
||||
if (callCount === 0) {
|
||||
expect(url).toBe('https://g/api/v1/users/u/repos?limit=50');
|
||||
callCount++;
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
{ name: 'r1', default_branch: 'main' },
|
||||
{ name: 'r2', default_branch: 'master' },
|
||||
]),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify([]), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
});
|
||||
const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
const repos = await c.listUserRepos('u');
|
||||
expect(repos).toEqual([
|
||||
{ name: 'r1', default_branch: 'main', archived: false },
|
||||
{ name: 'r2', default_branch: 'master', archived: false },
|
||||
]);
|
||||
expect(receivedAuth).toBe('token t');
|
||||
});
|
||||
|
||||
it('paginates listUserRepos', async () => {
|
||||
const pages = [
|
||||
[{ name: 'r1', default_branch: 'main' }, { name: 'r2', default_branch: 'main' }],
|
||||
[{ name: 'r3', default_branch: 'main' }],
|
||||
[],
|
||||
];
|
||||
let i = 0;
|
||||
const fetchImpl = mockFetch(() => {
|
||||
const body = JSON.stringify(pages[i++]);
|
||||
return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
});
|
||||
const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
const repos = await c.listUserRepos('u');
|
||||
expect(repos.map((r) => r.name)).toEqual(['r1', 'r2', 'r3']);
|
||||
});
|
||||
|
||||
it('returns null on 404 raw file', async () => {
|
||||
const fetchImpl = mockFetch(() => new Response('Not Found', { status: 404 }));
|
||||
const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
const r = await c.getRawFile('u', 'repo', '.tasks/STATUS.md', 'main');
|
||||
expect(r).toBeNull();
|
||||
});
|
||||
|
||||
it('returns body on 200 raw file', async () => {
|
||||
const fetchImpl = mockFetch((url) => {
|
||||
expect(url).toBe('https://g/api/v1/repos/u/repo/raw/.tasks/STATUS.md?ref=main');
|
||||
return new Response('# Task Board\n', { status: 200 });
|
||||
});
|
||||
const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
const r = await c.getRawFile('u', 'repo', '.tasks/STATUS.md', 'main');
|
||||
expect(r).toBe('# Task Board\n');
|
||||
});
|
||||
|
||||
it('throws on 500', async () => {
|
||||
const fetchImpl = mockFetch(() => new Response('boom', { status: 500 }));
|
||||
const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
await expect(c.getRawFile('u', 'r', 'p', 'main')).rejects.toThrow(/500/);
|
||||
});
|
||||
|
||||
it('throws on 401', async () => {
|
||||
const fetchImpl = mockFetch(() => new Response('nope', { status: 401 }));
|
||||
const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
await expect(c.listUserRepos('u')).rejects.toThrow(/401/);
|
||||
});
|
||||
|
||||
describe('getFileWithSha', () => {
|
||||
it('returns content + sha (base64-decoded)', async () => {
|
||||
const content = '# Task Board\n## 🔴 [t1] — first\n';
|
||||
const fetchImpl = mockFetch((url) => {
|
||||
expect(url).toBe('https://g/api/v1/repos/u/r/contents/.tasks/STATUS.md?ref=main');
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
content: Buffer.from(content, 'utf8').toString('base64'),
|
||||
encoding: 'base64',
|
||||
sha: 'abc123',
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
const c = makeGiteaBackend({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
const r = await c.getFileWithSha('u', 'r', '.tasks/STATUS.md', 'main');
|
||||
expect(r).toEqual({ content, sha: 'abc123' });
|
||||
});
|
||||
|
||||
it('returns null on 404', async () => {
|
||||
const fetchImpl = mockFetch(() => new Response('not found', { status: 404 }));
|
||||
const c = makeGiteaBackend({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
const r = await c.getFileWithSha('u', 'r', 'missing', 'main');
|
||||
expect(r).toBeNull();
|
||||
});
|
||||
|
||||
it('throws on 500', async () => {
|
||||
const fetchImpl = mockFetch(() => new Response('boom', { status: 500 }));
|
||||
const c = makeGiteaBackend({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
await expect(c.getFileWithSha('u', 'r', 'p', 'main')).rejects.toThrow(/500/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('commitFile', () => {
|
||||
it('POSTs to create new file (no sha)', async () => {
|
||||
let receivedMethod = '';
|
||||
let receivedBody: Record<string, unknown> = {};
|
||||
const fetchImpl = mockFetch((url, init) => {
|
||||
expect(url).toBe('https://g/api/v1/repos/u/r/contents/.tasks/STATUS.md');
|
||||
receivedMethod = init?.method ?? '';
|
||||
receivedBody = JSON.parse(init?.body as string);
|
||||
return new Response(
|
||||
JSON.stringify({ content: { sha: 'new-blob' }, commit: { sha: 'new-commit' } }),
|
||||
{ status: 201, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
const c = makeGiteaBackend({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
const r = await c.commitFile({
|
||||
user: 'u',
|
||||
repo: 'r',
|
||||
path: '.tasks/STATUS.md',
|
||||
branch: 'main',
|
||||
content: '# board\n',
|
||||
message: 'init',
|
||||
});
|
||||
expect(receivedMethod).toBe('POST');
|
||||
expect(receivedBody.branch).toBe('main');
|
||||
expect(Buffer.from(receivedBody.content as string, 'base64').toString('utf8')).toBe('# board\n');
|
||||
expect(receivedBody.message).toBe('init');
|
||||
expect(receivedBody).not.toHaveProperty('sha');
|
||||
expect(r).toEqual({ fileSha: 'new-blob', commitSha: 'new-commit' });
|
||||
});
|
||||
|
||||
it('PUTs to update existing file (with sha)', async () => {
|
||||
let receivedMethod = '';
|
||||
let receivedBody: Record<string, unknown> = {};
|
||||
const fetchImpl = mockFetch((_url, init) => {
|
||||
receivedMethod = init?.method ?? '';
|
||||
receivedBody = JSON.parse(init?.body as string);
|
||||
return new Response(
|
||||
JSON.stringify({ content: { sha: 'updated-blob' }, commit: { sha: 'updated-commit' } }),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
const c = makeGiteaBackend({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
const r = await c.commitFile({
|
||||
user: 'u',
|
||||
repo: 'r',
|
||||
path: '.tasks/STATUS.md',
|
||||
branch: 'main',
|
||||
content: 'updated',
|
||||
message: 'update',
|
||||
sha: 'old-blob',
|
||||
});
|
||||
expect(receivedMethod).toBe('PUT');
|
||||
expect(receivedBody.sha).toBe('old-blob');
|
||||
expect(r.fileSha).toBe('updated-blob');
|
||||
});
|
||||
|
||||
it('attaches author when both name and email provided', async () => {
|
||||
let receivedBody: Record<string, unknown> = {};
|
||||
const fetchImpl = mockFetch((_url, init) => {
|
||||
receivedBody = JSON.parse(init?.body as string);
|
||||
return new Response(
|
||||
JSON.stringify({ content: { sha: 'b' }, commit: { sha: 'c' } }),
|
||||
{ status: 201, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
const c = makeGiteaBackend({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
await c.commitFile({
|
||||
user: 'u',
|
||||
repo: 'r',
|
||||
path: 'x.md',
|
||||
branch: 'main',
|
||||
content: '',
|
||||
message: 'm',
|
||||
authorName: 'Vitya',
|
||||
authorEmail: 'v@x',
|
||||
});
|
||||
expect(receivedBody.author).toEqual({ name: 'Vitya', email: 'v@x' });
|
||||
});
|
||||
|
||||
it('throws on 422 (sha conflict)', async () => {
|
||||
const fetchImpl = mockFetch(() => new Response('sha mismatch', { status: 422 }));
|
||||
const c = makeGiteaBackend({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
await expect(
|
||||
c.commitFile({
|
||||
user: 'u',
|
||||
repo: 'r',
|
||||
path: 'x',
|
||||
branch: 'main',
|
||||
content: 'y',
|
||||
message: 'm',
|
||||
sha: 'stale',
|
||||
}),
|
||||
).rejects.toThrow(/422/);
|
||||
});
|
||||
});
|
||||
});
|
||||
127
lib/projects-meta-mcp/tests/lib/meta-cli.test.ts
Normal file
127
lib/projects-meta-mcp/tests/lib/meta-cli.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { runMetaPull, runMetaPush, type CliDeps, type HostResolution } from '../../src/lib/meta-cli.js';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
let scratch: string;
|
||||
|
||||
async function rawGit(cwd: string, ...args: string[]): Promise<string> {
|
||||
const { stdout } = await exec(
|
||||
'git',
|
||||
['-c', 'core.excludesFile=', '-c', 'core.autocrlf=false', '-c', 'user.email=t@t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args],
|
||||
{ cwd },
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function makeHost(files: Record<string, string>): Promise<string> {
|
||||
const host = join(scratch, 'host.git');
|
||||
await exec('git', ['init', '--bare', '--initial-branch=main', host]);
|
||||
const seed = join(scratch, 'seed');
|
||||
await exec('git', ['clone', host, seed]);
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const abs = join(seed, rel);
|
||||
await mkdir(join(abs, '..'), { recursive: true });
|
||||
await writeFile(abs, content);
|
||||
}
|
||||
await rawGit(seed, 'add', '-A');
|
||||
await rawGit(seed, 'commit', '-m', 'seed');
|
||||
await rawGit(seed, 'push', 'origin', 'main');
|
||||
return host;
|
||||
}
|
||||
|
||||
/** Deps whose resolver maps any dir to the given host, with a per-dir gitDir under scratch. */
|
||||
function depsForHost(host: string): CliDeps {
|
||||
const logs: string[] = [];
|
||||
const deps: CliDeps & { logs: string[] } = {
|
||||
logs,
|
||||
log: (m) => logs.push(m),
|
||||
resolveHost: async (dir: string): Promise<HostResolution> => ({
|
||||
remoteUrl: host,
|
||||
branch: 'main',
|
||||
gitDir: `${dir}.gitdir`,
|
||||
}),
|
||||
};
|
||||
return deps;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
scratch = await mkdtemp(join(tmpdir(), 'pmfs-cli-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(scratch, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('runMetaPull', () => {
|
||||
it('materializes meta into the work-tree and exits 0', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({ '.tasks/STATUS.md': '# board\n' });
|
||||
const dir = join(scratch, 'proj');
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const code = await runMetaPull(dir, depsForHost(host));
|
||||
expect(code).toBe(0);
|
||||
expect(await readFile(join(dir, '.tasks/STATUS.md'), 'utf8')).toBe('# board\n');
|
||||
});
|
||||
|
||||
it('is a fast no-op (no git, no git-dir) when the dir has no host', { timeout: 30000 }, async () => {
|
||||
const dir = join(scratch, 'noproj');
|
||||
await mkdir(dir, { recursive: true });
|
||||
const deps: CliDeps = { resolveHost: async () => null };
|
||||
|
||||
const code = await runMetaPull(dir, deps);
|
||||
expect(code).toBe(0);
|
||||
expect(existsSync(`${dir}.gitdir`)).toBe(false);
|
||||
});
|
||||
|
||||
it('exits 0 even when the host is unreachable (must not block session start)', { timeout: 30000 }, async () => {
|
||||
const dir = join(scratch, 'proj');
|
||||
await mkdir(dir, { recursive: true });
|
||||
const deps: CliDeps = {
|
||||
resolveHost: async () => ({ remoteUrl: join(scratch, 'does-not-exist.git'), branch: 'main', gitDir: `${dir}.gitdir` }),
|
||||
};
|
||||
const code = await runMetaPull(dir, deps);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMetaPush — round-trip', () => {
|
||||
it('pull -> edit -> push lands on the host; a second machine pulls it', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({ '.tasks/STATUS.md': 'v1\n' });
|
||||
const deps = depsForHost(host);
|
||||
|
||||
const a = join(scratch, 'a');
|
||||
await mkdir(a, { recursive: true });
|
||||
await runMetaPull(a, deps);
|
||||
await writeFile(join(a, '.tasks/STATUS.md'), 'v2\n');
|
||||
const pushCode = await runMetaPush(a, deps);
|
||||
expect(pushCode).toBe(0);
|
||||
|
||||
const b = join(scratch, 'b');
|
||||
await mkdir(b, { recursive: true });
|
||||
await runMetaPull(b, deps);
|
||||
expect(await readFile(join(b, '.tasks/STATUS.md'), 'utf8')).toBe('v2\n');
|
||||
});
|
||||
|
||||
it('is a no-op exit 0 when meta is clean', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({ '.tasks/STATUS.md': 'v1\n' });
|
||||
const deps = depsForHost(host);
|
||||
const a = join(scratch, 'a');
|
||||
await mkdir(a, { recursive: true });
|
||||
await runMetaPull(a, deps);
|
||||
|
||||
const code = await runMetaPush(a, deps);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
it('exits 0 when the dir has no host (fast no-op)', { timeout: 30000 }, async () => {
|
||||
const dir = join(scratch, 'noproj');
|
||||
await mkdir(dir, { recursive: true });
|
||||
const code = await runMetaPush(dir, { resolveHost: async () => null });
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
});
|
||||
70
lib/projects-meta-mcp/tests/lib/meta-host-resolver.test.ts
Normal file
70
lib/projects-meta-mcp/tests/lib/meta-host-resolver.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { join } from 'node:path';
|
||||
import { resolveMetaHostWith, type ResolveDeps } from '../../src/lib/meta-host-resolver.js';
|
||||
import type { CacheFile, ProjectStatus } from '../../src/lib/cache.js';
|
||||
|
||||
function project(name: string, defaultBranch = 'main'): ProjectStatus {
|
||||
return {
|
||||
name,
|
||||
default_branch: defaultBranch,
|
||||
fetched_at: '2026-05-27T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
};
|
||||
}
|
||||
|
||||
function cacheWith(...names: string[]): CacheFile {
|
||||
return {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-05-27T00:00:00Z',
|
||||
synced_from: 'gitea',
|
||||
machine: 'test',
|
||||
projects: names.map((n) => project(n)),
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
function deps(cache: CacheFile | null, log?: (m: string) => void): ResolveDeps {
|
||||
return {
|
||||
cache,
|
||||
auth: { giteaUrl: 'https://git.example.com', giteaToken: 'TOK123' },
|
||||
gitDirBase: '/cache/meta-git',
|
||||
log,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveMetaHostWith', () => {
|
||||
it('resolves a folder to its meta-<basename> host under the matching owner', async () => {
|
||||
const res = await resolveMetaHostWith('/home/u/projects/yt-tools', deps(cacheWith('OpeItcLoc03/meta-yt-tools', 'OpeItcLoc03/common')));
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.branch).toBe('main');
|
||||
expect(res!.remoteUrl).toBe('https://TOK123@git.example.com/OpeItcLoc03/meta-yt-tools.git');
|
||||
expect(res!.gitDir).toBe(join('/cache/meta-git', 'OpeItcLoc03__meta-yt-tools'));
|
||||
});
|
||||
|
||||
it('carries the host project default branch', async () => {
|
||||
const c = cacheWith('OpeItcLoc03/common');
|
||||
c.projects.push(project('OpeItcLoc03/meta-yt-tools', 'master'));
|
||||
const res = await resolveMetaHostWith('/x/yt-tools', deps(c));
|
||||
expect(res!.branch).toBe('master');
|
||||
});
|
||||
|
||||
it('returns null when no meta-<basename> host exists (instant no-op)', async () => {
|
||||
// own-Gitea project `books` is present as a bare repo, NOT `meta-books`
|
||||
const res = await resolveMetaHostWith('/home/u/projects/books', deps(cacheWith('victor/books')));
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the cache is absent', async () => {
|
||||
const res = await resolveMetaHostWith('/x/yt-tools', deps(null));
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses to guess when meta-<basename> exists under multiple owners', async () => {
|
||||
const logs: string[] = [];
|
||||
const res = await resolveMetaHostWith('/x/shared', deps(cacheWith('owner-a/meta-shared', 'owner-b/meta-shared'), (m) => logs.push(m)));
|
||||
expect(res).toBeNull();
|
||||
expect(logs.join('\n')).toMatch(/ambiguous|multiple/i);
|
||||
});
|
||||
});
|
||||
233
lib/projects-meta-mcp/tests/lib/meta-sync.test.ts
Normal file
233
lib/projects-meta-mcp/tests/lib/meta-sync.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { mkdtemp, rm, mkdir, writeFile, readFile, readdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { metaPull, metaPush, DEFAULT_META_PATHS } from '../../src/lib/meta-sync.js';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
// Real-git integration: mocking `git merge` would test nothing real.
|
||||
// We build a bare "host" repo + N work-trees ("machines") in temp dirs.
|
||||
|
||||
let scratch: string;
|
||||
|
||||
/** Run a plain git command (used only by the test harness, not the lib). */
|
||||
async function rawGit(cwd: string, ...args: string[]): Promise<string> {
|
||||
const { stdout } = await exec(
|
||||
'git',
|
||||
['-c', 'core.excludesFile=', '-c', 'core.autocrlf=false', '-c', 'user.email=t@t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args],
|
||||
{ cwd },
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
/** Create a bare host repo seeded with initial meta files on `main`. */
|
||||
async function makeHost(initialFiles: Record<string, string>): Promise<string> {
|
||||
const host = join(scratch, 'host.git');
|
||||
await exec('git', ['init', '--bare', '--initial-branch=main', host]);
|
||||
const seed = join(scratch, 'seed');
|
||||
await exec('git', ['clone', host, seed]);
|
||||
for (const [rel, content] of Object.entries(initialFiles)) {
|
||||
const abs = join(seed, rel);
|
||||
await mkdir(join(abs, '..'), { recursive: true });
|
||||
await writeFile(abs, content);
|
||||
}
|
||||
await rawGit(seed, 'add', '-A');
|
||||
await rawGit(seed, 'commit', '-m', 'seed');
|
||||
await rawGit(seed, 'push', 'origin', 'main');
|
||||
return host;
|
||||
}
|
||||
|
||||
/** Allocate a fresh (gitDir, workTree) pair for one "machine". */
|
||||
async function machine(name: string): Promise<{ gitDir: string; workTree: string }> {
|
||||
const gitDir = join(scratch, `${name}.gitdir`);
|
||||
const workTree = join(scratch, `${name}.wt`);
|
||||
await mkdir(workTree, { recursive: true });
|
||||
return { gitDir, workTree };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
scratch = await mkdtemp(join(tmpdir(), 'pmfs-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(scratch, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('metaPull — first materialization', () => {
|
||||
it('materializes meta files from host into an empty work-tree', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({
|
||||
'.tasks/STATUS.md': '# board\n',
|
||||
'CLAUDE.md': 'inst\n',
|
||||
});
|
||||
const m = await machine('a');
|
||||
|
||||
const res = await metaPull({ gitDir: m.gitDir, workTree: m.workTree, remoteUrl: host, branch: 'main' });
|
||||
|
||||
expect(res.status).toBe('pulled');
|
||||
expect(await readFile(join(m.workTree, '.tasks/STATUS.md'), 'utf8')).toBe('# board\n');
|
||||
expect(await readFile(join(m.workTree, 'CLAUDE.md'), 'utf8')).toBe('inst\n');
|
||||
expect(existsSync(m.gitDir)).toBe(true);
|
||||
// hidden git-dir is beside the work-tree, NOT nested
|
||||
expect(existsSync(join(m.workTree, '.git'))).toBe(false);
|
||||
});
|
||||
|
||||
it('creates the git-dir even when its parent directory does not exist', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({ 'CLAUDE.md': 'inst\n' });
|
||||
// git-dir nested under directories that do not exist yet (real case:
|
||||
// ~/.cache/projects-mcp/meta-git/<owner>__<repo> on first ever sync).
|
||||
const gitDir = join(scratch, 'nested', 'deep', 'a.gitdir');
|
||||
const workTree = join(scratch, 'a.wt');
|
||||
await mkdir(workTree, { recursive: true });
|
||||
|
||||
const res = await metaPull({ gitDir, workTree, remoteUrl: host, branch: 'main' });
|
||||
expect(res.status).toBe('pulled');
|
||||
expect(await readFile(join(workTree, 'CLAUDE.md'), 'utf8')).toBe('inst\n');
|
||||
});
|
||||
|
||||
it('reports up-to-date on a second pull with no remote change', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({ 'CLAUDE.md': 'inst\n' });
|
||||
const m = await machine('a');
|
||||
await metaPull({ gitDir: m.gitDir, workTree: m.workTree, remoteUrl: host, branch: 'main' });
|
||||
|
||||
const res = await metaPull({ gitDir: m.gitDir, workTree: m.workTree, remoteUrl: host, branch: 'main' });
|
||||
expect(res.status).toBe('up-to-date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('metaPush — clean meta', () => {
|
||||
it('is a no-op when nothing changed', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({ '.tasks/STATUS.md': '# board\n' });
|
||||
const m = await machine('a');
|
||||
await metaPull({ gitDir: m.gitDir, workTree: m.workTree, remoteUrl: host, branch: 'main' });
|
||||
|
||||
const res = await metaPush({ gitDir: m.gitDir, workTree: m.workTree, branch: 'main', message: 'noop' });
|
||||
expect(res.status).toBe('no-op');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip — two machines, clean block merge', () => {
|
||||
it('merges edits to different blocks without conflict', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({ '.tasks/STATUS.md': 'A: 1\n\nB: 1\n' });
|
||||
const a = await machine('a');
|
||||
const b = await machine('b');
|
||||
await metaPull({ gitDir: a.gitDir, workTree: a.workTree, remoteUrl: host, branch: 'main' });
|
||||
await metaPull({ gitDir: b.gitDir, workTree: b.workTree, remoteUrl: host, branch: 'main' });
|
||||
|
||||
// machine A edits the first block, pushes
|
||||
await writeFile(join(a.workTree, '.tasks/STATUS.md'), 'A: 2\n\nB: 1\n');
|
||||
const ap = await metaPush({ gitDir: a.gitDir, workTree: a.workTree, branch: 'main', message: 'edit A' });
|
||||
expect(ap.status).toBe('pushed');
|
||||
|
||||
// machine B edits the second block, pushes — must merge A's change cleanly
|
||||
await writeFile(join(b.workTree, '.tasks/STATUS.md'), 'A: 1\n\nB: 2\n');
|
||||
const bp = await metaPush({ gitDir: b.gitDir, workTree: b.workTree, branch: 'main', message: 'edit B' });
|
||||
expect(bp.status).toBe('pushed');
|
||||
|
||||
// host now carries BOTH edits
|
||||
const a2 = await machine('verify');
|
||||
await metaPull({ gitDir: a2.gitDir, workTree: a2.workTree, remoteUrl: host, branch: 'main' });
|
||||
expect(await readFile(join(a2.workTree, '.tasks/STATUS.md'), 'utf8')).toBe('A: 2\n\nB: 2\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('conflict — same line on two machines', () => {
|
||||
it('surfaces both versions and does NOT clobber the host', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({ '.tasks/STATUS.md': 'shared: 1\n' });
|
||||
const a = await machine('a');
|
||||
const b = await machine('b');
|
||||
await metaPull({ gitDir: a.gitDir, workTree: a.workTree, remoteUrl: host, branch: 'main' });
|
||||
await metaPull({ gitDir: b.gitDir, workTree: b.workTree, remoteUrl: host, branch: 'main' });
|
||||
|
||||
await writeFile(join(a.workTree, '.tasks/STATUS.md'), 'shared: A\n');
|
||||
await metaPush({ gitDir: a.gitDir, workTree: a.workTree, branch: 'main', message: 'A wins line' });
|
||||
|
||||
// B edits the same line — push must surface, not clobber
|
||||
await writeFile(join(b.workTree, '.tasks/STATUS.md'), 'shared: B\n');
|
||||
const bp = await metaPush({ gitDir: b.gitDir, workTree: b.workTree, branch: 'main', message: 'B same line' });
|
||||
|
||||
expect(bp.status).toBe('conflict');
|
||||
if (bp.status !== 'conflict') throw new Error('unreachable');
|
||||
const f = bp.conflicts.find((c) => c.path === '.tasks/STATUS.md');
|
||||
expect(f).toBeDefined();
|
||||
expect(f!.ours).toBe('shared: B\n');
|
||||
expect(f!.theirs).toBe('shared: A\n');
|
||||
|
||||
// host still holds A's version — B did not clobber it
|
||||
const v = await machine('verify');
|
||||
await metaPull({ gitDir: v.gitDir, workTree: v.workTree, remoteUrl: host, branch: 'main' });
|
||||
expect(await readFile(join(v.workTree, '.tasks/STATUS.md'), 'utf8')).toBe('shared: A\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('default meta paths — private .claude, not public root CLAUDE.md', () => {
|
||||
// Design contract (concepts/projects-meta-folder-sync.md, "Граничные / отложенное"):
|
||||
// root CLAUDE.md is PUBLIC (code-git → github); private instructions live in
|
||||
// `.claude/CLAUDE.md` (covered by the global ignore, on the meta rail). The default
|
||||
// meta set must therefore track `.claude/`, never the root CLAUDE.md — otherwise a
|
||||
// host carrying a root CLAUDE.md would materialize it where the code-git is NOT blind
|
||||
// to it (global ignore covers `.claude/` the dir, not the root file) → leak.
|
||||
it('tracks .claude and never the root CLAUDE.md', () => {
|
||||
expect(DEFAULT_META_PATHS).toEqual(['.wiki', '.tasks', '.claude']);
|
||||
expect(DEFAULT_META_PATHS).not.toContain('CLAUDE.md');
|
||||
});
|
||||
|
||||
it('stages private .claude/CLAUDE.md but leaves a root CLAUDE.md to the code-git', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({ '.claude/CLAUDE.md': 'private\n' });
|
||||
const m = await machine('a');
|
||||
await metaPull({ gitDir: m.gitDir, workTree: m.workTree, remoteUrl: host, branch: 'main' });
|
||||
|
||||
// a root CLAUDE.md sitting in the same folder is PUBLIC — code-git owns it
|
||||
await writeFile(join(m.workTree, 'CLAUDE.md'), 'public domain doc\n');
|
||||
// a real private-instructions edit on the meta rail
|
||||
await writeFile(join(m.workTree, '.claude/CLAUDE.md'), 'private edited\n');
|
||||
|
||||
const res = await metaPush({ gitDir: m.gitDir, workTree: m.workTree, branch: 'main', message: 'private only' });
|
||||
expect(res.status).toBe('pushed');
|
||||
|
||||
const tracked = (await rawGit(m.workTree, '--git-dir', m.gitDir, '--work-tree', m.workTree, 'ls-files')).trim().split('\n');
|
||||
expect(tracked).toContain('.claude/CLAUDE.md');
|
||||
expect(tracked).not.toContain('CLAUDE.md');
|
||||
|
||||
// the host clone never received the public root CLAUDE.md
|
||||
const v = await machine('verify');
|
||||
await metaPull({ gitDir: v.gitDir, workTree: v.workTree, remoteUrl: host, branch: 'main' });
|
||||
const names = await readdir(v.workTree);
|
||||
expect(names).not.toContain('CLAUDE.md');
|
||||
expect(existsSync(join(v.workTree, '.claude/CLAUDE.md'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no-leak — code files never enter the meta git', () => {
|
||||
it('commits only meta paths, ignoring untracked code in the work-tree', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({ '.tasks/STATUS.md': 'x\n' });
|
||||
const m = await machine('a');
|
||||
await metaPull({ gitDir: m.gitDir, workTree: m.workTree, remoteUrl: host, branch: 'main' });
|
||||
|
||||
// untracked code sitting in the same folder
|
||||
await writeFile(join(m.workTree, 'app.js'), 'console.log(1)\n');
|
||||
await mkdir(join(m.workTree, 'src'), { recursive: true });
|
||||
await writeFile(join(m.workTree, 'src/index.ts'), 'export {}\n');
|
||||
// a real meta edit
|
||||
await writeFile(join(m.workTree, '.tasks/STATUS.md'), 'y\n');
|
||||
|
||||
const res = await metaPush({ gitDir: m.gitDir, workTree: m.workTree, branch: 'main', message: 'meta only' });
|
||||
expect(res.status).toBe('pushed');
|
||||
|
||||
// the meta git index must not contain code paths
|
||||
const tracked = (await rawGit(m.workTree, '--git-dir', m.gitDir, '--work-tree', m.workTree, 'ls-files')).trim().split('\n');
|
||||
expect(tracked).toContain('.tasks/STATUS.md');
|
||||
expect(tracked).not.toContain('app.js');
|
||||
expect(tracked).not.toContain('src/index.ts');
|
||||
|
||||
// and the host clone never received code
|
||||
const v = await machine('verify');
|
||||
await metaPull({ gitDir: v.gitDir, workTree: v.workTree, remoteUrl: host, branch: 'main' });
|
||||
const names = await readdir(v.workTree);
|
||||
expect(names).not.toContain('app.js');
|
||||
expect(names).not.toContain('src');
|
||||
});
|
||||
});
|
||||
119
lib/projects-meta-mcp/tests/lib/policy.test.ts
Normal file
119
lib/projects-meta-mcp/tests/lib/policy.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parsePolicyToml, effectiveClaimFields } from '../../src/lib/policy.js';
|
||||
import type { ParsedTask } from '../../src/lib/status-md.js';
|
||||
|
||||
function task(over: Partial<ParsedTask> = {}): ParsedTask {
|
||||
return { slug: 'x', status: 'ready', description: 'x', next: null, ...over };
|
||||
}
|
||||
|
||||
describe('parsePolicyToml', () => {
|
||||
it('null content → null policy (unrestricted)', () => {
|
||||
const r = parsePolicyToml(null);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.policy).toBeNull();
|
||||
});
|
||||
|
||||
it('parses default_runtime_allowed', () => {
|
||||
const r = parsePolicyToml('default_runtime_allowed = ["claude-opus", "claude-sonnet"]\n');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.policy?.defaultRuntimeAllowed).toEqual(['claude-opus', 'claude-sonnet']);
|
||||
});
|
||||
|
||||
it('parses default_requirements + default_weight', () => {
|
||||
const r = parsePolicyToml('default_requirements = ["needs-secrets"]\ndefault_weight = "needs-claude"\n');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.policy?.defaultRequirements).toEqual(['needs-secrets']);
|
||||
expect(r.policy?.defaultWeight).toBe('needs-claude');
|
||||
}
|
||||
});
|
||||
|
||||
it('empty file → empty policy object', () => {
|
||||
const r = parsePolicyToml('\n');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.policy).toEqual({});
|
||||
});
|
||||
|
||||
it('malformed TOML → structured error', () => {
|
||||
const r = parsePolicyToml('default_runtime_allowed = [unterminated\n');
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain('malformed');
|
||||
});
|
||||
|
||||
it('wrong type (string not array) → structured error', () => {
|
||||
const r = parsePolicyToml('default_runtime_allowed = "claude-opus"\n');
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain('array of strings');
|
||||
});
|
||||
|
||||
it('wrong type for default_weight → structured error', () => {
|
||||
const r = parsePolicyToml('default_weight = ["a"]\n');
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain('default_weight must be a string');
|
||||
});
|
||||
|
||||
it('parses default_consult_policy (valid enum)', () => {
|
||||
const r = parsePolicyToml('default_consult_policy = "strict-human"\n');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.policy?.defaultConsultPolicy).toBe('strict-human');
|
||||
});
|
||||
|
||||
it('default_consult_policy must be a string', () => {
|
||||
const r = parsePolicyToml('default_consult_policy = ["auto"]\n');
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain('default_consult_policy must be a string');
|
||||
});
|
||||
|
||||
it('default_consult_policy invalid enum → structured error', () => {
|
||||
const r = parsePolicyToml('default_consult_policy = "yolo"\n');
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain('default_consult_policy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectiveClaimFields', () => {
|
||||
it('task field wins over policy (total override, not intersect)', () => {
|
||||
const eff = effectiveClaimFields(task({ runtimeAllowed: ['hermes-glm51'] }), {
|
||||
defaultRuntimeAllowed: ['claude-opus'],
|
||||
});
|
||||
expect(eff.runtimeAllowed).toEqual(['hermes-glm51']);
|
||||
});
|
||||
|
||||
it('policy default fills when the task omits the field', () => {
|
||||
const eff = effectiveClaimFields(task(), { defaultRuntimeAllowed: ['claude-opus'] });
|
||||
expect(eff.runtimeAllowed).toEqual(['claude-opus']);
|
||||
});
|
||||
|
||||
it('null policy → all undefined (unrestricted)', () => {
|
||||
const eff = effectiveClaimFields(task(), null);
|
||||
expect(eff.runtimeAllowed).toBeUndefined();
|
||||
expect(eff.requirements).toBeUndefined();
|
||||
expect(eff.weight).toBeUndefined();
|
||||
});
|
||||
|
||||
it('overlays requirements + weight from policy', () => {
|
||||
const eff = effectiveClaimFields(task(), {
|
||||
defaultRequirements: ['needs-db'],
|
||||
defaultWeight: 'needs-claude',
|
||||
});
|
||||
expect(eff.requirements).toEqual(['needs-db']);
|
||||
expect(eff.weight).toBe('needs-claude');
|
||||
});
|
||||
|
||||
it('consult_policy: task field wins over policy default', () => {
|
||||
const eff = effectiveClaimFields(task({ consultPolicy: 'auto' }), {
|
||||
defaultConsultPolicy: 'strict-human',
|
||||
});
|
||||
expect(eff.consultPolicy).toBe('auto');
|
||||
});
|
||||
|
||||
it('consult_policy: policy default fills when task omits it', () => {
|
||||
const eff = effectiveClaimFields(task(), { defaultConsultPolicy: 'strict-human' });
|
||||
expect(eff.consultPolicy).toBe('strict-human');
|
||||
});
|
||||
|
||||
it('consult_policy: built-in default is human-only when neither task nor policy sets it', () => {
|
||||
expect(effectiveClaimFields(task(), null).consultPolicy).toBe('human-only');
|
||||
expect(effectiveClaimFields(task(), {}).consultPolicy).toBe('human-only');
|
||||
});
|
||||
});
|
||||
93
lib/projects-meta-mcp/tests/lib/promotion.test.ts
Normal file
93
lib/projects-meta-mcp/tests/lib/promotion.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { findCandidates } from '../../src/lib/promotion.js';
|
||||
import type { WikiPage } from '../../src/lib/wiki-index.js';
|
||||
|
||||
let projectWiki: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'promo-'));
|
||||
projectWiki = join(root, '.wiki');
|
||||
await mkdir(join(projectWiki, 'concepts'), { recursive: true });
|
||||
});
|
||||
|
||||
const sharedPages: WikiPage[] = [
|
||||
{ slug: 'embedded/yarn-on-windows', title: 'Yarn on Windows', domain: 'embedded', tags: [], body: '', raw: '' },
|
||||
];
|
||||
|
||||
describe('findCandidates', () => {
|
||||
it('includes platform-flavored concept', async () => {
|
||||
await writeFile(
|
||||
join(projectWiki, 'concepts', 'windows-yarn-quirk.md'),
|
||||
'---\ntitle: Windows yarn quirk\n---\n\nWhen yarn on Windows, exec() is required.',
|
||||
);
|
||||
const r = await findCandidates({
|
||||
projectWikiDir: projectWiki,
|
||||
sharedPages,
|
||||
cwdDomain: 'node',
|
||||
blacklist: [],
|
||||
});
|
||||
expect(r).toHaveLength(1);
|
||||
expect(r[0].suggested_domain).toBe('node');
|
||||
expect(r[0].confidence).toBe('high'); // platform keyword + similar shared title
|
||||
});
|
||||
|
||||
it('excludes when project name in blacklist matched', async () => {
|
||||
await writeFile(
|
||||
join(projectWiki, 'concepts', 'snolla-edge-case.md'),
|
||||
'---\ntitle: Snolla edge case\n---\n\nWhen yarn meets the Snolla pipeline, special handling for npm-mcp.',
|
||||
);
|
||||
const r = await findCandidates({
|
||||
projectWikiDir: projectWiki,
|
||||
sharedPages,
|
||||
cwdDomain: 'node',
|
||||
blacklist: ['snolla', 'npm-mcp'],
|
||||
});
|
||||
expect(r).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns confidence=medium when keyword match but no similar shared title', async () => {
|
||||
await writeFile(
|
||||
join(projectWiki, 'concepts', 'docker-on-linux.md'),
|
||||
'---\ntitle: Docker on Linux\n---\n\nDocker daemon socket on Linux.',
|
||||
);
|
||||
const r = await findCandidates({
|
||||
projectWikiDir: projectWiki,
|
||||
sharedPages,
|
||||
cwdDomain: 'node',
|
||||
blacklist: [],
|
||||
});
|
||||
expect(r).toHaveLength(1);
|
||||
expect(r[0].confidence).toBe('medium');
|
||||
});
|
||||
|
||||
it('skips concept without platform keyword', async () => {
|
||||
await writeFile(
|
||||
join(projectWiki, 'concepts', 'business-rule.md'),
|
||||
'---\ntitle: Order discount logic\n---\n\nWhen total > 100 apply 5% off.',
|
||||
);
|
||||
const r = await findCandidates({
|
||||
projectWikiDir: projectWiki,
|
||||
sharedPages,
|
||||
cwdDomain: 'node',
|
||||
blacklist: [],
|
||||
});
|
||||
expect(r).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('cwdDomain unknown → suggested_domain=cross', async () => {
|
||||
await writeFile(
|
||||
join(projectWiki, 'concepts', 'git-trick.md'),
|
||||
'---\ntitle: Git tip\n---\n\ngit reflog tip.',
|
||||
);
|
||||
const r = await findCandidates({
|
||||
projectWikiDir: projectWiki,
|
||||
sharedPages,
|
||||
cwdDomain: 'unknown',
|
||||
blacklist: [],
|
||||
});
|
||||
expect(r[0].suggested_domain).toBe('cross');
|
||||
});
|
||||
});
|
||||
213
lib/projects-meta-mcp/tests/lib/resolve-target.test.ts
Normal file
213
lib/projects-meta-mcp/tests/lib/resolve-target.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
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 { parseTargetProject, resolveTarget } from '../../src/lib/resolve-target.js';
|
||||
|
||||
describe('parseTargetProject', () => {
|
||||
it('accepts the agenda literal', () => {
|
||||
const r = parseTargetProject('agenda');
|
||||
expect(r).toEqual({ agenda: true });
|
||||
});
|
||||
|
||||
it('accepts qualified <owner>/<repo>', () => {
|
||||
const r = parseTargetProject('victor/books');
|
||||
expect(r).toEqual({ agenda: false, owner: 'victor', repo: 'books' });
|
||||
});
|
||||
|
||||
it('rejects bare repo name with hint', () => {
|
||||
const r = parseTargetProject('books');
|
||||
expect(r).toEqual({ error: expect.stringContaining('must be qualified') });
|
||||
const err = (r as { error: string }).error;
|
||||
expect(err).toContain('use `<owner>/<repo>`');
|
||||
expect(err).toContain('victor/books');
|
||||
expect(err).toContain('Got: `books`');
|
||||
});
|
||||
|
||||
it('rejects the legacy _meta literal (replaced by `agenda` in 2.0)', () => {
|
||||
const r = parseTargetProject('_meta');
|
||||
expect(r).toEqual({ error: expect.stringContaining('must be qualified') });
|
||||
expect((r as { error: string }).error).toContain('Got: `_meta`');
|
||||
});
|
||||
|
||||
it('rejects empty string', () => {
|
||||
const r = parseTargetProject('');
|
||||
expect('error' in r).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects leading slash', () => {
|
||||
const r = parseTargetProject('/books');
|
||||
expect('error' in r).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects trailing slash', () => {
|
||||
const r = parseTargetProject('victor/');
|
||||
expect('error' in r).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects more than one slash', () => {
|
||||
const r = parseTargetProject('a/b/c');
|
||||
expect('error' in r).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects whitespace inside', () => {
|
||||
const r = parseTargetProject('victor /books');
|
||||
expect('error' in r).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty owner segment', () => {
|
||||
const r = parseTargetProject('//books');
|
||||
expect('error' in r).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTarget', () => {
|
||||
let cacheFile: string;
|
||||
const baseFixture: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-05-08T00:00:00Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{
|
||||
name: 'victor/books',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-05-08T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
{
|
||||
name: 'OpeItcLoc03/common',
|
||||
default_branch: 'master',
|
||||
fetched_at: '2026-05-08T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'resolve-target-'));
|
||||
cacheFile = join(dir, 'cache.json');
|
||||
await writeCache(cacheFile, baseFixture);
|
||||
});
|
||||
|
||||
it('returns owner+repo+branch for qualified target in cache', async () => {
|
||||
const r = await resolveTarget(cacheFile, 'victor/books', 'agenda', 'OpeItcLoc03');
|
||||
expect(r).toEqual({ owner: 'victor', repo: 'books', branch: 'main', isAgenda: false });
|
||||
});
|
||||
|
||||
it('routes agenda to fallbackOwner + agendaRepo', async () => {
|
||||
await writeCache(cacheFile, {
|
||||
...baseFixture,
|
||||
projects: [
|
||||
...baseFixture.projects,
|
||||
{
|
||||
name: 'agenda',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-05-08T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = await resolveTarget(cacheFile, 'agenda', 'agenda', 'OpeItcLoc03');
|
||||
expect(r).toEqual({ owner: 'OpeItcLoc03', repo: 'agenda', branch: 'main', isAgenda: true });
|
||||
});
|
||||
|
||||
it('rejects bare name with hint message', async () => {
|
||||
const r = await resolveTarget(cacheFile, 'books', 'agenda', 'OpeItcLoc03');
|
||||
expect('error' in r).toBe(true);
|
||||
expect((r as { error: string }).error).toContain('must be qualified');
|
||||
});
|
||||
|
||||
it('errors on cache miss for qualified target', async () => {
|
||||
const r = await resolveTarget(cacheFile, 'ghost/repo', 'agenda', 'OpeItcLoc03');
|
||||
expect('error' in r).toBe(true);
|
||||
expect((r as { error: string }).error).toContain('not in cache');
|
||||
});
|
||||
|
||||
it('qualified-unknown owner returns cache-miss error, not shape-error', async () => {
|
||||
const r = await resolveTarget(cacheFile, 'ekassir/books', 'agenda', 'OpeItcLoc03');
|
||||
expect('error' in r).toBe(true);
|
||||
const err = (r as { error: string }).error;
|
||||
expect(err).not.toContain('must be qualified');
|
||||
expect(err).toContain('not in cache');
|
||||
expect(err).toContain('ekassir/books');
|
||||
});
|
||||
|
||||
it('errors on missing agenda entry in cache', async () => {
|
||||
const r = await resolveTarget(cacheFile, 'agenda', 'agenda', 'OpeItcLoc03');
|
||||
expect('error' in r).toBe(true);
|
||||
expect((r as { error: string }).error).toContain('agenda not in cache');
|
||||
});
|
||||
|
||||
it('errors when agenda is local-only', async () => {
|
||||
await writeCache(cacheFile, {
|
||||
...baseFixture,
|
||||
projects: [
|
||||
...baseFixture.projects,
|
||||
{
|
||||
name: 'agenda',
|
||||
default_branch: 'local',
|
||||
fetched_at: '2026-05-08T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = await resolveTarget(cacheFile, 'agenda', 'agenda', 'OpeItcLoc03');
|
||||
expect('error' in r).toBe(true);
|
||||
expect((r as { error: string }).error).toContain('Gitea repo "agenda" not found');
|
||||
});
|
||||
|
||||
it('errors when cache missing entirely', async () => {
|
||||
const r = await resolveTarget('/nonexistent/cache.json', 'victor/books', 'agenda', 'OpeItcLoc03');
|
||||
expect('error' in r).toBe(true);
|
||||
expect((r as { error: string }).error).toContain('cache missing');
|
||||
});
|
||||
|
||||
it('routes agenda to agendaOwner override (qualified agenda_tasks_repo)', async () => {
|
||||
await writeCache(cacheFile, {
|
||||
...baseFixture,
|
||||
projects: [
|
||||
...baseFixture.projects,
|
||||
{
|
||||
name: 'agenda',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-05-08T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = await resolveTarget(cacheFile, 'agenda', 'agenda', 'OpeItcLoc03', 'victor');
|
||||
expect(r).toEqual({ owner: 'victor', repo: 'agenda', branch: 'main', isAgenda: true });
|
||||
});
|
||||
|
||||
it('falls back to fallbackOwner when agendaOwner is undefined (bare agenda_tasks_repo)', async () => {
|
||||
await writeCache(cacheFile, {
|
||||
...baseFixture,
|
||||
projects: [
|
||||
...baseFixture.projects,
|
||||
{
|
||||
name: 'agenda',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-05-08T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = await resolveTarget(cacheFile, 'agenda', 'agenda', 'OpeItcLoc03', undefined);
|
||||
expect(r).toEqual({ owner: 'OpeItcLoc03', repo: 'agenda', branch: 'main', isAgenda: true });
|
||||
});
|
||||
});
|
||||
383
lib/projects-meta-mcp/tests/lib/status-md-writer.test.ts
Normal file
383
lib/projects-meta-mcp/tests/lib/status-md-writer.test.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
formatTaskBlock,
|
||||
appendTaskBlock,
|
||||
freshBoardTemplate,
|
||||
closeTask,
|
||||
updateTaskFields,
|
||||
hasTaskSlug,
|
||||
} from '../../src/lib/status-md-writer.js';
|
||||
import { parseStatusMd } from '../../src/lib/status-md.js';
|
||||
|
||||
describe('formatTaskBlock', () => {
|
||||
it('formats minimal active block', () => {
|
||||
const out = formatTaskBlock({
|
||||
slug: 't1',
|
||||
status: 'active',
|
||||
description: 'first task',
|
||||
whereStopped: 'just started',
|
||||
nextAction: 'do x',
|
||||
});
|
||||
expect(out).toContain('## 🔴 [t1] — first task');
|
||||
expect(out).toContain('**Status:** active');
|
||||
expect(out).toContain('**Where I stopped:** just started');
|
||||
expect(out).toContain('**Next action:** do x');
|
||||
expect(out).toContain('**Branch:** n/a');
|
||||
expect(out).toContain('---');
|
||||
expect(out).not.toContain('Blocker');
|
||||
});
|
||||
|
||||
it('formats blocked block with blocker line', () => {
|
||||
const out = formatTaskBlock({
|
||||
slug: 't2',
|
||||
status: 'blocked',
|
||||
description: 'waiting',
|
||||
whereStopped: 'depends',
|
||||
nextAction: 'wait',
|
||||
blocker: 'upstream review',
|
||||
});
|
||||
expect(out).toContain('## 🔵 [t2] — waiting');
|
||||
expect(out).toContain('**Blocker:** upstream review');
|
||||
});
|
||||
|
||||
it('writes identity footer', () => {
|
||||
const out = formatTaskBlock({
|
||||
slug: 't3',
|
||||
status: 'ready',
|
||||
description: 'd',
|
||||
whereStopped: 'w',
|
||||
nextAction: 'n',
|
||||
createdBy: 'vitya@laptop',
|
||||
fromProject: 'modules-db',
|
||||
createdAtIso: '2026-04-29T12:00:00Z',
|
||||
});
|
||||
expect(out).toContain(
|
||||
'<!-- created-by: vitya@laptop / from: modules-db / 2026-04-29T12:00:00Z -->',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTaskBlock — notify field', () => {
|
||||
it('emits **Notify:** line when notify is set', () => {
|
||||
const out = formatTaskBlock({
|
||||
slug: 't-notify',
|
||||
status: 'ready',
|
||||
description: 'needs notify',
|
||||
whereStopped: 'w',
|
||||
nextAction: 'n',
|
||||
notify: 'OpeItcLoc03/workshop',
|
||||
});
|
||||
expect(out).toContain('**Notify:** OpeItcLoc03/workshop');
|
||||
});
|
||||
|
||||
it('omits Notify line when notify is absent', () => {
|
||||
const out = formatTaskBlock({
|
||||
slug: 't-no-notify',
|
||||
status: 'ready',
|
||||
description: 'no notify',
|
||||
whereStopped: 'w',
|
||||
nextAction: 'n',
|
||||
});
|
||||
expect(out).not.toContain('Notify');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendTaskBlock', () => {
|
||||
it('appends to non-empty markdown with one blank-line separator', () => {
|
||||
const before = '# Task Board\n\nsome stuff';
|
||||
const out = appendTaskBlock(before, {
|
||||
slug: 't1',
|
||||
status: 'ready',
|
||||
description: 'd',
|
||||
whereStopped: 'w',
|
||||
nextAction: 'n',
|
||||
});
|
||||
expect(out).toMatch(/some stuff\n\n## ⚪ \[t1\]/);
|
||||
expect(out.endsWith('\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('initializes from empty content', () => {
|
||||
const out = appendTaskBlock('', {
|
||||
slug: 't1',
|
||||
status: 'ready',
|
||||
description: 'd',
|
||||
whereStopped: 'w',
|
||||
nextAction: 'n',
|
||||
});
|
||||
expect(out).toMatch(/^## ⚪ \[t1\]/);
|
||||
});
|
||||
|
||||
it('round-trips through the parser', () => {
|
||||
const board = freshBoardTemplate('2026-04-29');
|
||||
const after = appendTaskBlock(board, {
|
||||
slug: 'rt',
|
||||
status: 'paused',
|
||||
description: 'roundtrip',
|
||||
whereStopped: 'mid-flow',
|
||||
nextAction: 'resume',
|
||||
});
|
||||
const parsed = parseStatusMd(after);
|
||||
expect(parsed.tasks.map((t) => t.slug)).toEqual(['rt']);
|
||||
expect(parsed.tasks[0].status).toBe('paused');
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeTask', () => {
|
||||
const board = `# Task Board\n_Updated: 2026-04-29_\n\n## 🔴 [t1] — first\n**Status:** active\n**Where I stopped:** mid\n**Next action:** ship\n**Branch:** main\n\n---\n\n## ⚪ [t2] — second\n**Status:** ready\n**Next action:** start\n**Branch:** n/a\n\n---\n`;
|
||||
|
||||
it('closes 🔴 active to 🟢 done', () => {
|
||||
const out = closeTask(board, 't1');
|
||||
expect(out).not.toBeNull();
|
||||
expect(out).toMatch(/^## 🟢 \[t1\] — first/m);
|
||||
expect(out).toContain('**Status:** done');
|
||||
expect(out).toMatch(/^## ⚪ \[t2\]/m);
|
||||
const parsed = parseStatusMd(out!);
|
||||
expect(parsed.tasks.find((t) => t.slug === 't1')?.status).toBe('done');
|
||||
expect(parsed.tasks.find((t) => t.slug === 't2')?.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('appends closing-by metadata', () => {
|
||||
const out = closeTask(board, 't1', {
|
||||
closedBy: 'vitya@laptop',
|
||||
closedAtIso: '2026-04-29T12:00:00Z',
|
||||
note: 'shipped',
|
||||
});
|
||||
expect(out).toContain('<!-- closed-by: vitya@laptop / 2026-04-29T12:00:00Z / note: shipped -->');
|
||||
});
|
||||
|
||||
it('normalizes Where I stopped and Next action on close', () => {
|
||||
const out = closeTask(board, 't1');
|
||||
expect(out).toContain('**Where I stopped:** closed via tasks_close');
|
||||
expect(out).toContain('**Next action:** (none — kept until merged)');
|
||||
});
|
||||
|
||||
it('uses close note as Where I stopped when provided', () => {
|
||||
const out = closeTask(board, 't1', { note: 'shipped v1.0' });
|
||||
expect(out).toContain('**Where I stopped:** shipped v1.0');
|
||||
expect(out).toContain('**Next action:** (none — kept until merged)');
|
||||
});
|
||||
|
||||
it('returns null when slug missing', () => {
|
||||
const out = closeTask(board, 'nope');
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it('clears the claim-stamp (Owner/Claim token/Claim expires at) on close', () => {
|
||||
// root-cause-#3 hygiene: a closed task must carry no lingering claim fields,
|
||||
// otherwise its stale stamp can be re-claimed once the TTL lapses.
|
||||
const claimed = `# Task Board\n_Updated: 2026-04-29_\n\n## 🔴 [t1] — first\n**Status:** active\n**Where I stopped:** mid\n**Next action:** ship\n**Branch:** main\n**Owner:** win11:claude-opus:s1\n**Claim token:** tok-1\n**Claim expires at:** 2026-04-29T12:10:00.000Z\n\n---\n`;
|
||||
const out = closeTask(claimed, 't1', { closedBy: 'vitya@laptop', closedAtIso: '2026-04-29T12:00:00Z' });
|
||||
expect(out).not.toBeNull();
|
||||
expect(out).toContain('**Status:** done');
|
||||
expect(out).not.toContain('**Owner:**');
|
||||
expect(out).not.toContain('**Claim token:**');
|
||||
expect(out).not.toContain('**Claim expires at:**');
|
||||
// closed-by footer is preserved
|
||||
expect(out).toContain('closed-by: vitya@laptop');
|
||||
const parsed = parseStatusMd(out!);
|
||||
const t1 = parsed.tasks.find((t) => t.slug === 't1');
|
||||
expect(t1?.status).toBe('done');
|
||||
expect(t1?.owner).toBeUndefined();
|
||||
expect(t1?.claimToken).toBeUndefined();
|
||||
expect(t1?.claimExpiresAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('appends bodyAppend section before closing ---', () => {
|
||||
const outcome = '## Review outcome\n\nstatus: approve\nsummary: all criteria met';
|
||||
const out = closeTask(board, 't1', { bodyAppend: outcome });
|
||||
expect(out).not.toBeNull();
|
||||
expect(out).toContain('## Review outcome');
|
||||
expect(out).toContain('status: approve');
|
||||
const bodyPos = out!.indexOf('## Review outcome');
|
||||
const sepPos = out!.indexOf('\n---\n');
|
||||
expect(bodyPos).toBeLessThan(sepPos);
|
||||
});
|
||||
|
||||
it('bodyAppend appears before closing comment when both present', () => {
|
||||
const outcome = '## Review outcome\n\nstatus: reject\nsummary: criteria failed';
|
||||
const out = closeTask(board, 't1', {
|
||||
bodyAppend: outcome,
|
||||
closedBy: 'bot@host',
|
||||
closedAtIso: '2026-06-09T00:00:00Z',
|
||||
});
|
||||
expect(out).not.toBeNull();
|
||||
const appendPos = out!.indexOf('## Review outcome');
|
||||
const commentPos = out!.indexOf('<!-- closed-by:');
|
||||
expect(appendPos).toBeGreaterThan(0);
|
||||
expect(appendPos).toBeLessThan(commentPos);
|
||||
});
|
||||
|
||||
it('no bodyAppend → output unchanged vs current behavior', () => {
|
||||
const out = closeTask(board, 't1', {});
|
||||
expect(out).not.toContain('## Review outcome');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTaskFields', () => {
|
||||
const board = `# Task Board\n_Updated: 2026-04-29_\n\n## 🔴 [t1] — first\n**Status:** active\n**Where I stopped:** old\n**Next action:** old next\n**Branch:** main\n\n---\n`;
|
||||
|
||||
it('updates whereStopped + nextAction without touching others', () => {
|
||||
const out = updateTaskFields(board, 't1', {
|
||||
whereStopped: 'NEW stopped',
|
||||
nextAction: 'NEW next',
|
||||
});
|
||||
expect(out).toContain('**Where I stopped:** NEW stopped');
|
||||
expect(out).toContain('**Next action:** NEW next');
|
||||
expect(out).toContain('## 🔴 [t1]');
|
||||
expect(out).toContain('**Status:** active');
|
||||
});
|
||||
|
||||
it('changes status updates both Status field and header emoji', () => {
|
||||
const out = updateTaskFields(board, 't1', { status: 'paused' });
|
||||
expect(out).toMatch(/^## 🟡 \[t1\]/m);
|
||||
expect(out).toContain('**Status:** paused');
|
||||
});
|
||||
|
||||
it('returns null when slug missing', () => {
|
||||
expect(updateTaskFields(board, 'nope', { whereStopped: 'x' })).toBeNull();
|
||||
});
|
||||
|
||||
it('changes description in header', () => {
|
||||
const out = updateTaskFields(board, 't1', { description: 'renamed task' });
|
||||
expect(out).toMatch(/^## 🔴 \[t1\] — renamed task$/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTaskBlock — agent-orchestration schema fields', () => {
|
||||
it('serializes provided new fields and round-trips through the parser', () => {
|
||||
const out = formatTaskBlock({
|
||||
slug: 'claimable',
|
||||
status: 'ready',
|
||||
description: 'd',
|
||||
whereStopped: 'w',
|
||||
nextAction: 'n',
|
||||
branch: 'master',
|
||||
owner: 'win11:claude-opus:s1',
|
||||
claimToken: 'tok-1',
|
||||
claimExpiresAt: '2026-06-07T12:00:00Z',
|
||||
requirements: ['needs-db'],
|
||||
weight: 'needs-claude',
|
||||
runtimeAllowed: ['claude-opus', 'claude-sonnet'],
|
||||
runtimePreferences: ['claude-opus'],
|
||||
resultArtifactUrl: 'https://x/pr/1',
|
||||
workerRegistry: { node: 'win11' },
|
||||
});
|
||||
expect(out).toContain('**Owner:** win11:claude-opus:s1');
|
||||
expect(out).toContain('**Runtime allowed:** claude-opus, claude-sonnet');
|
||||
const t = parseStatusMd(`${out}\n`).tasks[0];
|
||||
expect(t.owner).toBe('win11:claude-opus:s1');
|
||||
expect(t.claimToken).toBe('tok-1');
|
||||
expect(t.claimExpiresAt).toBe('2026-06-07T12:00:00Z');
|
||||
expect(t.requirements).toEqual(['needs-db']);
|
||||
expect(t.weight).toBe('needs-claude');
|
||||
expect(t.runtimeAllowed).toEqual(['claude-opus', 'claude-sonnet']);
|
||||
expect(t.runtimePreferences).toEqual(['claude-opus']);
|
||||
expect(t.resultArtifactUrl).toBe('https://x/pr/1');
|
||||
expect(t.workerRegistry).toEqual({ node: 'win11' });
|
||||
});
|
||||
|
||||
it('omits lines for absent new fields (no noise on legacy create)', () => {
|
||||
const out = formatTaskBlock({
|
||||
slug: 't',
|
||||
status: 'ready',
|
||||
description: 'd',
|
||||
whereStopped: 'w',
|
||||
nextAction: 'n',
|
||||
});
|
||||
expect(out).not.toContain('**Owner:**');
|
||||
expect(out).not.toContain('**Claim token:**');
|
||||
expect(out).not.toContain('**Requirements:**');
|
||||
expect(out).not.toContain('**Worker registry:**');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTaskFields — preserves agent-orchestration fields', () => {
|
||||
const board = `# Task Board\n_Updated: 2026-06-07_\n\n## 🔴 [t1] — first\n**Status:** active\n**Where I stopped:** old\n**Next action:** old next\n**Branch:** main\n**Owner:** win11:claude-opus:s1\n**Claim token:** tok-1\n\n---\n`;
|
||||
|
||||
it('keeps Owner + Claim token lines when editing an unrelated field', () => {
|
||||
const out = updateTaskFields(board, 't1', { whereStopped: 'NEW' })!;
|
||||
expect(out).toContain('**Where I stopped:** NEW');
|
||||
expect(out).toContain('**Owner:** win11:claude-opus:s1');
|
||||
expect(out).toContain('**Claim token:** tok-1');
|
||||
const t = parseStatusMd(out).tasks[0];
|
||||
expect(t.owner).toBe('win11:claude-opus:s1');
|
||||
expect(t.claimToken).toBe('tok-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTaskFields — weight + notify upsert', () => {
|
||||
const plainBoard =
|
||||
'# Task Board\n_Updated: 2026-06-11_\n\n## ⚪ [t1] — ready task\n**Status:** ready\n**Where I stopped:** none\n**Next action:** start\n**Branch:** master\n\n---\n';
|
||||
|
||||
it('inserts **Weight:** and **Notify:** lines when absent', () => {
|
||||
const out = updateTaskFields(plainBoard, 't1', {
|
||||
weight: 'needs-claude',
|
||||
notify: 'OpeItcLoc03/workshop',
|
||||
})!;
|
||||
expect(out).not.toBeNull();
|
||||
expect(out).toContain('**Weight:** needs-claude');
|
||||
expect(out).toContain('**Notify:** OpeItcLoc03/workshop');
|
||||
const t = parseStatusMd(out).tasks[0];
|
||||
expect(t.weight).toBe('needs-claude');
|
||||
expect(t.notify).toBe('OpeItcLoc03/workshop');
|
||||
});
|
||||
|
||||
it('replaces an existing **Weight:** line in place', () => {
|
||||
const weighted =
|
||||
'# Task Board\n\n## ⚪ [t1] — ready task\n**Status:** ready\n**Next action:** go\n**Branch:** master\n**Weight:** cheap-ok\n\n---\n';
|
||||
const out = updateTaskFields(weighted, 't1', { weight: 'needs-human' })!;
|
||||
expect(out).toContain('**Weight:** needs-human');
|
||||
expect(out).not.toContain('**Weight:** cheap-ok');
|
||||
// exactly one Weight line — replaced, not duplicated
|
||||
expect(out.match(/^\*\*Weight:\*\*/gm)!.length).toBe(1);
|
||||
});
|
||||
|
||||
it('replaces an existing **Notify:** line in place', () => {
|
||||
const withNotify =
|
||||
'# Task Board\n\n## ⚪ [t1] — ready task\n**Status:** ready\n**Next action:** go\n**Branch:** master\n**Notify:** OpeItcLoc03/old\n\n---\n';
|
||||
const out = updateTaskFields(withNotify, 't1', { notify: 'OpeItcLoc03/new' })!;
|
||||
expect(out).toContain('**Notify:** OpeItcLoc03/new');
|
||||
expect(out).not.toContain('**Notify:** OpeItcLoc03/old');
|
||||
expect(out.match(/^\*\*Notify:\*\*/gm)!.length).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves weight/notify untouched when not provided', () => {
|
||||
const weighted =
|
||||
'# Task Board\n\n## ⚪ [t1] — ready task\n**Status:** ready\n**Next action:** go\n**Branch:** master\n**Weight:** cheap-ok\n**Notify:** OpeItcLoc03/keep\n\n---\n';
|
||||
const out = updateTaskFields(weighted, 't1', { whereStopped: 'x' })!;
|
||||
expect(out).toContain('**Weight:** cheap-ok');
|
||||
expect(out).toContain('**Notify:** OpeItcLoc03/keep');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasTaskSlug', () => {
|
||||
const board = `## 🔴 [foo] — d\n**Next action:** n\n\n---\n## ⚪ [bar-baz] — d\n`;
|
||||
it('finds existing slug', () => {
|
||||
expect(hasTaskSlug(board, 'foo')).toBe(true);
|
||||
expect(hasTaskSlug(board, 'bar-baz')).toBe(true);
|
||||
});
|
||||
it('returns false for missing slug', () => {
|
||||
expect(hasTaskSlug(board, 'qux')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTaskFields — blocker clear (blocker: empty string removes the line)', () => {
|
||||
const blockedBoard =
|
||||
'# Task Board\n_Updated: 2026-06-09_\n\n## 🔵 [t1] — blocked task\n**Status:** blocked\n**Where I stopped:** dep pending\n**Next action:** wait\n**Blocker:** dep-a, dep-b\n**Branch:** master\n**Weight:** needs-claude\n\n---\n';
|
||||
|
||||
it('updateTaskFields({status: "ready", blocker: ""}) removes **Blocker:** line and flips emoji', () => {
|
||||
const out = updateTaskFields(blockedBoard, 't1', { status: 'ready', blocker: '' })!;
|
||||
expect(out).not.toBeNull();
|
||||
expect(out).toContain('## ⚪ [t1]');
|
||||
expect(out).toContain('**Status:** ready');
|
||||
expect(out).not.toContain('**Blocker:**');
|
||||
});
|
||||
|
||||
it('blocker: "" on a task without Blocker line is a no-op for that line', () => {
|
||||
const noBlocker =
|
||||
'# Task Board\n\n## ⚪ [t2] — ready task\n**Status:** ready\n**Next action:** go\n**Branch:** master\n\n---\n';
|
||||
const out = updateTaskFields(noBlocker, 't2', { blocker: '' })!;
|
||||
expect(out).not.toContain('**Blocker:**');
|
||||
expect(out).toContain('## ⚪ [t2]');
|
||||
});
|
||||
});
|
||||
170
lib/projects-meta-mcp/tests/lib/status-md.test.ts
Normal file
170
lib/projects-meta-mcp/tests/lib/status-md.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseStatusMd } from '../../src/lib/status-md.js';
|
||||
|
||||
const sample = `# Task Board
|
||||
_Updated: 2026-04-29_
|
||||
|
||||
## 🔴 [auth-rewrite] — Rebuild OAuth pipeline
|
||||
**Status:** active
|
||||
**Where I stopped:** Mid-refactor of token validator
|
||||
**Next action:** Add unit tests for refresh flow
|
||||
**Branch:** feat/auth-rewrite
|
||||
|
||||
---
|
||||
|
||||
## 🟡 [docs-sweep] — Refresh README
|
||||
**Status:** paused
|
||||
**Where I stopped:** Drafted intro, blocked on screenshots
|
||||
**Next action:** Capture login screencast
|
||||
**Branch:** docs/readme
|
||||
|
||||
---
|
||||
|
||||
## 🔵 [vendor-bug] — Upstream pagination broken
|
||||
**Status:** blocked
|
||||
**Blocker:** Waiting on vendor patch v2.4.1
|
||||
**Next action:** Re-run integration test once patched
|
||||
**Branch:** fix/vendor-bug
|
||||
|
||||
---
|
||||
`;
|
||||
|
||||
describe('parseStatusMd', () => {
|
||||
it('extracts updated timestamp', () => {
|
||||
const r = parseStatusMd(sample);
|
||||
expect(r.updated).toBe('2026-04-29');
|
||||
});
|
||||
|
||||
it('parses three tasks with correct statuses', () => {
|
||||
const r = parseStatusMd(sample);
|
||||
expect(r.tasks.map((t) => t.slug)).toEqual([
|
||||
'auth-rewrite',
|
||||
'docs-sweep',
|
||||
'vendor-bug',
|
||||
]);
|
||||
expect(r.tasks.map((t) => t.status)).toEqual(['active', 'paused', 'blocked']);
|
||||
});
|
||||
|
||||
it('captures description and next action', () => {
|
||||
const r = parseStatusMd(sample);
|
||||
expect(r.tasks[0].description).toBe('Rebuild OAuth pipeline');
|
||||
expect(r.tasks[0].next).toBe('Add unit tests for refresh flow');
|
||||
});
|
||||
|
||||
it('returns empty tasks when board empty', () => {
|
||||
const r = parseStatusMd('# Task Board\n_Updated: 2026-04-29_\n');
|
||||
expect(r.tasks).toEqual([]);
|
||||
expect(r.updated).toBe('2026-04-29');
|
||||
});
|
||||
|
||||
it('returns null updated when missing', () => {
|
||||
expect(parseStatusMd('# Task Board\n').updated).toBeNull();
|
||||
});
|
||||
|
||||
it('treats unknown emoji as ready (best effort)', () => {
|
||||
const txt = '## ⚫ [weird-task] — desc\n**Next action:** something\n';
|
||||
const r = parseStatusMd(txt);
|
||||
expect(r.tasks[0].status).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStatusMd — agent-orchestration schema fields', () => {
|
||||
const board = `# Task Board
|
||||
_Updated: 2026-06-07_
|
||||
|
||||
## ⚪ [claimable] — A claimable task
|
||||
**Status:** ready
|
||||
**Next action:** start
|
||||
**Branch:** master
|
||||
**Owner:** win11:claude-opus:sess-1
|
||||
**Claim token:** tok-abc123
|
||||
**Claim expires at:** 2026-06-07T12:00:00Z
|
||||
**Requirements:** needs-db, needs-secrets
|
||||
**Weight:** needs-claude
|
||||
**Runtime allowed:** claude-opus, claude-sonnet
|
||||
**Runtime preferences:** claude-opus
|
||||
**Result artifact URL:** https://git.example/pr/1
|
||||
**Worker registry:** {"node":"win11"}
|
||||
**Consult policy:** strict-human
|
||||
|
||||
---
|
||||
`;
|
||||
|
||||
it('parses all nine new fields', () => {
|
||||
const t = parseStatusMd(board).tasks[0];
|
||||
expect(t.owner).toBe('win11:claude-opus:sess-1');
|
||||
expect(t.claimToken).toBe('tok-abc123');
|
||||
expect(t.claimExpiresAt).toBe('2026-06-07T12:00:00Z');
|
||||
expect(t.requirements).toEqual(['needs-db', 'needs-secrets']);
|
||||
expect(t.weight).toBe('needs-claude');
|
||||
expect(t.runtimeAllowed).toEqual(['claude-opus', 'claude-sonnet']);
|
||||
expect(t.runtimePreferences).toEqual(['claude-opus']);
|
||||
expect(t.resultArtifactUrl).toBe('https://git.example/pr/1');
|
||||
expect(t.workerRegistry).toEqual({ node: 'win11' });
|
||||
});
|
||||
|
||||
it('parses the consult_policy task field', () => {
|
||||
expect(parseStatusMd(board).tasks[0].consultPolicy).toBe('strict-human');
|
||||
});
|
||||
|
||||
it('leaves new fields undefined for legacy blocks (backwards-compat)', () => {
|
||||
const legacy = '## 🔴 [old] — legacy\n**Status:** active\n**Next action:** go\n**Branch:** main\n';
|
||||
const t = parseStatusMd(legacy).tasks[0];
|
||||
expect(t.owner).toBeUndefined();
|
||||
expect(t.claimToken).toBeUndefined();
|
||||
expect(t.requirements).toBeUndefined();
|
||||
expect(t.weight).toBeUndefined();
|
||||
expect(t.runtimeAllowed).toBeUndefined();
|
||||
expect(t.workerRegistry).toBeUndefined();
|
||||
// legacy fields still parse correctly
|
||||
expect(t.status).toBe('active');
|
||||
expect(t.next).toBe('go');
|
||||
});
|
||||
|
||||
it('ignores malformed worker_registry JSON without throwing', () => {
|
||||
const bad = '## ⚪ [x] — d\n**Next action:** n\n**Worker registry:** {not json\n';
|
||||
const t = parseStatusMd(bad).tasks[0];
|
||||
expect(t.workerRegistry).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStatusMd — notify field', () => {
|
||||
it('parses **Notify:** OpeItcLoc03/workshop', () => {
|
||||
const md = '## ⚪ [t] — desc\n**Status:** ready\n**Next action:** go\n**Branch:** master\n**Notify:** OpeItcLoc03/workshop\n\n---\n';
|
||||
const t = parseStatusMd(md).tasks[0];
|
||||
expect(t.notify).toBe('OpeItcLoc03/workshop');
|
||||
});
|
||||
|
||||
it('leaves notify undefined when absent', () => {
|
||||
const md = '## ⚪ [t] — desc\n**Status:** ready\n**Next action:** go\n**Branch:** master\n\n---\n';
|
||||
const t = parseStatusMd(md).tasks[0];
|
||||
expect(t.notify).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStatusMd — blocker field', () => {
|
||||
it('parses **Blocker:** CSV into blocker string', () => {
|
||||
const md =
|
||||
'## 🔵 [t] — blocked task\n**Status:** blocked\n**Next action:** wait\n**Blocker:** dep-a, dep-b\n**Branch:** master\n\n---\n';
|
||||
const t = parseStatusMd(md).tasks[0];
|
||||
expect(t.blocker).toBe('dep-a, dep-b');
|
||||
});
|
||||
|
||||
it('parses single blocker slug', () => {
|
||||
const md =
|
||||
'## 🔵 [t] — blocked\n**Status:** blocked\n**Next action:** wait\n**Blocker:** only-dep\n**Branch:** master\n\n---\n';
|
||||
const t = parseStatusMd(md).tasks[0];
|
||||
expect(t.blocker).toBe('only-dep');
|
||||
});
|
||||
|
||||
it('leaves blocker undefined when field absent', () => {
|
||||
const md = '## ⚪ [t] — ready\n**Status:** ready\n**Next action:** go\n**Branch:** master\n\n---\n';
|
||||
const t = parseStatusMd(md).tasks[0];
|
||||
expect(t.blocker).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses blocker from the existing sample fixture', () => {
|
||||
const t = parseStatusMd(sample).tasks.find((x) => x.slug === 'vendor-bug')!;
|
||||
expect(t.blocker).toBe('Waiting on vendor patch v2.4.1');
|
||||
});
|
||||
});
|
||||
637
lib/projects-meta-mcp/tests/lib/sync-runner.test.ts
Normal file
637
lib/projects-meta-mcp/tests/lib/sync-runner.test.ts
Normal file
@@ -0,0 +1,637 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runSync } from '../../src/lib/sync-runner.js';
|
||||
import { parseStatusMd } from '../../src/lib/status-md.js';
|
||||
import type { Backend } from '../../src/lib/backend.js';
|
||||
|
||||
type FileMap = Record<string, string | null | Error>;
|
||||
type RepoFiles = string | null | Error | FileMap;
|
||||
|
||||
function fakeClient(
|
||||
repos: Array<{ name: string; default_branch: string; archived?: boolean }>,
|
||||
files: Record<string, RepoFiles>,
|
||||
): Backend {
|
||||
return {
|
||||
async listUserRepos() {
|
||||
return repos;
|
||||
},
|
||||
async getRawFile(_u, repo, path) {
|
||||
const v = files[repo];
|
||||
if (v === undefined) return null;
|
||||
if (typeof v === 'string' || v === null || v instanceof Error) {
|
||||
if (path === '.tasks/STATUS.md') {
|
||||
if (v instanceof Error) throw v;
|
||||
return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const inner = (v as FileMap)[path];
|
||||
if (inner === undefined) return null;
|
||||
if (inner instanceof Error) throw inner;
|
||||
return inner;
|
||||
},
|
||||
async getFileWithSha() {
|
||||
throw new Error('not used in sync tests');
|
||||
},
|
||||
async commitFile() {
|
||||
throw new Error('not used in sync tests');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('runSync', () => {
|
||||
const now = () => new Date('2026-04-29T12:00:00Z');
|
||||
const sample = `# Task Board\n_Updated: 2026-04-29_\n\n## 🔴 [t1] — first\n**Next action:** do x\n`;
|
||||
|
||||
it('aggregates STATUS.md across repos', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[{ name: 'a', default_branch: 'main' }, { name: 'b', default_branch: 'main' }],
|
||||
{ a: sample, b: sample },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
expect(cache.projects).toHaveLength(2);
|
||||
expect(cache.errors).toEqual([]);
|
||||
expect(cache.projects[0].active_tasks[0].slug).toBe('t1');
|
||||
});
|
||||
|
||||
// Regression [aggregate-blocked-no-slug-invisible]: a blocked task whose
|
||||
// **Blocker:** is free text (e.g. "design-hold") rather than another task's
|
||||
// slug must still be indexed into active_tasks — status is keyed off the 🔵
|
||||
// emoji, never off the shape of the blocker. The aggregate view reads from
|
||||
// active_tasks, so a missed index here makes such tasks invisible.
|
||||
it('indexes a blocked task with a non-slug (text) blocker', async () => {
|
||||
const md =
|
||||
`# Task Board\n_Updated: 2026-04-29_\n\n` +
|
||||
`## 🔵 [held] — held on a design decision\n` +
|
||||
`**Status:** blocked\n` +
|
||||
`**Where I stopped:** waiting on design\n` +
|
||||
`**Next action:** resume after design ruling\n` +
|
||||
`**Blocker:** design-hold\n` +
|
||||
`**Branch:** n/a\n`;
|
||||
const cache = await runSync({
|
||||
client: fakeClient([{ name: 'a', default_branch: 'main' }], { a: md }),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
const blocked = cache.projects[0].active_tasks.filter((t) => t.status === 'blocked');
|
||||
expect(blocked.map((t) => t.slug)).toEqual(['held']);
|
||||
});
|
||||
|
||||
it('skips 404 (no STATUS.md) without error', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[{ name: 'a', default_branch: 'main' }, { name: 'no-tasks', default_branch: 'main' }],
|
||||
{ a: sample, 'no-tasks': null },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
expect(cache.projects.map((p) => p.name)).toEqual(['u/a']);
|
||||
expect(cache.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('records error and continues on network failure', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[{ name: 'a', default_branch: 'main' }, { name: 'broken', default_branch: 'main' }],
|
||||
{ a: sample, broken: new Error('network down') },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
expect(cache.projects.map((p) => p.name)).toEqual(['u/a']);
|
||||
expect(cache.errors).toEqual([{ project: 'u/broken', reason: 'network down' }]);
|
||||
});
|
||||
|
||||
it('sets synced_at, machine, synced_from', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient([], {}),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'host-x',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
expect(cache.synced_at).toBe('2026-04-29T12:00:00.000Z');
|
||||
expect(cache.machine).toBe('host-x');
|
||||
expect(cache.synced_from).toBe('https://g');
|
||||
});
|
||||
|
||||
it('injects agenda pseudo-project when readAgendaTasksLocal returns content', async () => {
|
||||
const agendaSample = `# Task Board\n_Updated: 2026-04-29_\n\n## 🔴 [m1] — agenda task\n**Next action:** ship MVP-1\n\n## ⚪ [m2] — placeholder\n**Next action:** none\n`;
|
||||
const cache = await runSync({
|
||||
client: fakeClient([{ name: 'a', default_branch: 'main' }], { a: sample }),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
readAgendaTasksLocal: async () => agendaSample,
|
||||
});
|
||||
const names = cache.projects.map((p) => p.name);
|
||||
expect(names).toContain('agenda');
|
||||
expect(names).toContain('u/a');
|
||||
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
|
||||
expect(agenda.default_branch).toBe('local');
|
||||
expect(agenda.all_tasks_count).toBe(2);
|
||||
expect(agenda.active_tasks.map((t) => t.slug)).toEqual(['m1']);
|
||||
expect(cache.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips agenda when readAgendaTasksLocal returns null', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient([{ name: 'a', default_branch: 'main' }], { a: sample }),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
readAgendaTasksLocal: async () => null,
|
||||
});
|
||||
expect(cache.projects.map((p) => p.name)).toEqual(['u/a']);
|
||||
expect(cache.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('records error under "agenda" when readAgendaTasksLocal throws', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient([{ name: 'a', default_branch: 'main' }], { a: sample }),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
readAgendaTasksLocal: async () => {
|
||||
throw new Error('disk read failed');
|
||||
},
|
||||
});
|
||||
expect(cache.projects.map((p) => p.name)).toEqual(['u/a']);
|
||||
expect(cache.errors).toEqual([{ project: 'agenda', reason: 'disk read failed' }]);
|
||||
});
|
||||
|
||||
it('uses Gitea repo for agenda when agendaTasksRepo is found and excludes it from regular projects', async () => {
|
||||
const agendaFromGitea = `# Agenda\n_Updated: 2026-04-29_\n\n## 🔴 [g1] — gitea-side\n**Next action:** ship\n`;
|
||||
const agendaFromLocal = `# Agenda\n## 🔴 [l1] — local should not win\n`;
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[
|
||||
{ name: 'a', default_branch: 'main' },
|
||||
{ name: 'projects-tasks', default_branch: 'main' },
|
||||
],
|
||||
{ a: sample, 'projects-tasks': agendaFromGitea },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
agendaTasksRepo: 'projects-tasks',
|
||||
readAgendaTasksLocal: async () => agendaFromLocal,
|
||||
});
|
||||
const names = cache.projects.map((p) => p.name).sort();
|
||||
expect(names).toEqual(['agenda', 'u/a']);
|
||||
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
|
||||
expect(agenda.default_branch).toBe('main');
|
||||
expect(agenda.active_tasks.map((t) => t.slug)).toEqual(['g1']);
|
||||
});
|
||||
|
||||
it('falls back to local when agendaTasksRepo is configured but absent in Gitea', async () => {
|
||||
const localContent = `# Local agenda\n## 🔴 [l1] — fallback\n**Next action:** x\n`;
|
||||
const cache = await runSync({
|
||||
client: fakeClient([{ name: 'a', default_branch: 'main' }], { a: sample }),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
agendaTasksRepo: 'projects-tasks',
|
||||
readAgendaTasksLocal: async () => localContent,
|
||||
});
|
||||
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
|
||||
expect(agenda).toBeDefined();
|
||||
expect(agenda.default_branch).toBe('local');
|
||||
expect(agenda.active_tasks.map((t) => t.slug)).toEqual(['l1']);
|
||||
});
|
||||
|
||||
it('falls back to local when Gitea repo exists but has no STATUS.md', async () => {
|
||||
const localContent = `# Local\n## 🔴 [l2] — fallback-2\n`;
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[
|
||||
{ name: 'a', default_branch: 'main' },
|
||||
{ name: 'projects-tasks', default_branch: 'main' },
|
||||
],
|
||||
{ a: sample, 'projects-tasks': null },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
agendaTasksRepo: 'projects-tasks',
|
||||
readAgendaTasksLocal: async () => localContent,
|
||||
});
|
||||
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
|
||||
expect(agenda).toBeDefined();
|
||||
expect(agenda.default_branch).toBe('local');
|
||||
// projects-tasks excluded from regular project list even when its STATUS.md is absent.
|
||||
expect(cache.projects.map((p) => p.name)).not.toContain('u/projects-tasks');
|
||||
});
|
||||
|
||||
it('records gitea error in cache.errors when fetch throws and no local fallback', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[
|
||||
{ name: 'a', default_branch: 'main' },
|
||||
{ name: 'projects-tasks', default_branch: 'main' },
|
||||
],
|
||||
{ a: sample, 'projects-tasks': new Error('gitea 500') },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
agendaTasksRepo: 'projects-tasks',
|
||||
});
|
||||
expect(cache.projects.map((p) => p.name)).toEqual(['u/a']);
|
||||
expect(cache.errors).toEqual([{ project: 'agenda', reason: 'gitea 500' }]);
|
||||
});
|
||||
|
||||
it('captures wiki_index alongside STATUS.md when both exist', async () => {
|
||||
const wikiIndex = `# Wiki Index\n\n## Concepts\n\n- [foo](concepts/foo.md) — Foo\n`;
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[{ name: 'a', default_branch: 'main' }],
|
||||
{ a: { '.tasks/STATUS.md': sample, '.wiki/index.md': wikiIndex } },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
expect(cache.projects).toHaveLength(1);
|
||||
expect(cache.projects[0].wiki_index).toBe(wikiIndex);
|
||||
});
|
||||
|
||||
it('captures wiki-only repo (no STATUS.md) into projects list', async () => {
|
||||
const wikiIndex = `# Wiki Index\n\n## Concepts\n\n- [foo](concepts/foo.md) — Foo\n`;
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[{ name: 'wiki-only', default_branch: 'main' }],
|
||||
{ 'wiki-only': { '.tasks/STATUS.md': null, '.wiki/index.md': wikiIndex } },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
expect(cache.projects).toHaveLength(1);
|
||||
expect(cache.projects[0].name).toBe('u/wiki-only');
|
||||
expect(cache.projects[0].raw).toBe('');
|
||||
expect(cache.projects[0].active_tasks).toEqual([]);
|
||||
expect(cache.projects[0].all_tasks_count).toBe(0);
|
||||
expect(cache.projects[0].wiki_index).toBe(wikiIndex);
|
||||
});
|
||||
|
||||
it('omits wiki_index when .wiki/index.md is absent', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[{ name: 'a', default_branch: 'main' }],
|
||||
{ a: { '.tasks/STATUS.md': sample, '.wiki/index.md': null } },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
expect(cache.projects[0].wiki_index).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips repo when neither STATUS.md nor wiki_index exist', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[
|
||||
{ name: 'a', default_branch: 'main' },
|
||||
{ name: 'empty', default_branch: 'main' },
|
||||
],
|
||||
{
|
||||
a: { '.tasks/STATUS.md': sample, '.wiki/index.md': null },
|
||||
empty: { '.tasks/STATUS.md': null, '.wiki/index.md': null },
|
||||
},
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
expect(cache.projects.map((p) => p.name)).toEqual(['u/a']);
|
||||
expect(cache.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not record error when wiki_index fetch fails — degrades silently', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[{ name: 'a', default_branch: 'main' }],
|
||||
{ a: { '.tasks/STATUS.md': sample, '.wiki/index.md': new Error('wiki fetch 500') } },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
expect(cache.projects).toHaveLength(1);
|
||||
expect(cache.projects[0].wiki_index).toBeUndefined();
|
||||
expect(cache.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('local wins when agendaTasksRepo not configured at all', async () => {
|
||||
const localContent = `# Local\n## 🔴 [only] — local\n`;
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[
|
||||
{ name: 'a', default_branch: 'main' },
|
||||
{ name: 'projects-tasks', default_branch: 'main' },
|
||||
],
|
||||
{ a: sample, 'projects-tasks': 'should not be read' },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
// agendaTasksRepo intentionally omitted
|
||||
readAgendaTasksLocal: async () => localContent,
|
||||
});
|
||||
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
|
||||
expect(agenda.default_branch).toBe('local');
|
||||
expect(agenda.active_tasks.map((t) => t.slug)).toEqual(['only']);
|
||||
// Without agendaTasksRepo, projects-tasks shouldn't be filtered out.
|
||||
expect(cache.projects.map((p) => p.name).sort()).toEqual(['agenda', 'u/a', 'u/projects-tasks']);
|
||||
});
|
||||
|
||||
it('skips archived repos', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[
|
||||
{ name: 'live', default_branch: 'main', archived: false },
|
||||
{ name: 'archived-repo', default_branch: 'main', archived: true },
|
||||
],
|
||||
{ live: sample },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'test',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
expect(cache.projects.map((p) => p.name)).toEqual(['u/live']);
|
||||
expect(cache.archived_skipped).toBe(1);
|
||||
});
|
||||
|
||||
it('aggregates repos across multiple owners with qualified <owner>/<repo> names', async () => {
|
||||
const ownerARepos = [
|
||||
{ name: 'repo-a1', default_branch: 'main' },
|
||||
{ name: 'repo-a2', default_branch: 'main' },
|
||||
];
|
||||
const ownerBRepos = [{ name: 'repo-b1', default_branch: 'main' }];
|
||||
const calls: Array<{ owner: string; repo: string }> = [];
|
||||
|
||||
const client: Backend = {
|
||||
async listUserRepos(owner) {
|
||||
if (owner === 'owner-a') return ownerARepos;
|
||||
if (owner === 'owner-b') return ownerBRepos;
|
||||
return [];
|
||||
},
|
||||
async getRawFile(owner, repo, path) {
|
||||
calls.push({ owner, repo });
|
||||
if (path !== '.tasks/STATUS.md') return null;
|
||||
return sample;
|
||||
},
|
||||
async getFileWithSha() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async commitFile() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
|
||||
const cache = await runSync({
|
||||
client,
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['owner-a', 'owner-b'],
|
||||
});
|
||||
|
||||
expect(cache.projects.map((p) => p.name).sort()).toEqual([
|
||||
'owner-a/repo-a1',
|
||||
'owner-a/repo-a2',
|
||||
'owner-b/repo-b1',
|
||||
]);
|
||||
// getRawFile should have been called with each repo's actual owner — not
|
||||
// a uniform "acting user" — which is the whole point of multi-owner sync.
|
||||
const ownerARepoCalls = calls.filter((c) => c.repo === 'repo-a1' || c.repo === 'repo-a2');
|
||||
expect(ownerARepoCalls.every((c) => c.owner === 'owner-a')).toBe(true);
|
||||
const ownerBRepoCalls = calls.filter((c) => c.repo === 'repo-b1');
|
||||
expect(ownerBRepoCalls.every((c) => c.owner === 'owner-b')).toBe(true);
|
||||
expect(cache.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses agendaTasksOwner to pin agenda to a specific owner — non-acting owner', async () => {
|
||||
const agendaContent = `# Agenda\n_Updated: 2026-04-29_\n\n## 🔴 [v1] — victor agenda\n**Next action:** ship\n`;
|
||||
const calls: Array<{ owner: string; repo: string; path: string }> = [];
|
||||
|
||||
const client: Backend = {
|
||||
async listUserRepos(owner) {
|
||||
if (owner === 'OpeItcLoc03') return [{ name: 'agenda', default_branch: 'main' }];
|
||||
if (owner === 'victor') return [{ name: 'agenda', default_branch: 'main' }];
|
||||
return [];
|
||||
},
|
||||
async getRawFile(owner, repo, path) {
|
||||
calls.push({ owner, repo, path });
|
||||
if (path !== '.tasks/STATUS.md') return null;
|
||||
if (owner === 'victor' && repo === 'agenda') return agendaContent;
|
||||
if (owner === 'OpeItcLoc03' && repo === 'agenda') return sample;
|
||||
return null;
|
||||
},
|
||||
async getFileWithSha() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async commitFile() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
|
||||
const cache = await runSync({
|
||||
client,
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['OpeItcLoc03', 'victor'],
|
||||
agendaTasksRepo: 'agenda',
|
||||
agendaTasksOwner: 'victor',
|
||||
});
|
||||
|
||||
const names = cache.projects.map((p) => p.name).sort();
|
||||
// agenda pinned to victor; OpeItcLoc03/agenda stays in regular project list.
|
||||
expect(names).toEqual(['OpeItcLoc03/agenda', 'agenda']);
|
||||
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
|
||||
expect(agenda.active_tasks.map((t) => t.slug)).toEqual(['v1']);
|
||||
const agendaCalls = calls.filter(
|
||||
(c) => c.repo === 'agenda' && c.path === '.tasks/STATUS.md',
|
||||
);
|
||||
// Only victor was called for the agenda fetch — OpeItcLoc03 wasn't
|
||||
// misroute-targeted just because it shares the repo name.
|
||||
const victorAgendaCalls = agendaCalls.filter((c) => c.owner === 'victor');
|
||||
expect(victorAgendaCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('iterates union of owners + aggregateSkipOwners, populating cache with qualified keys for skip-owner repos', async () => {
|
||||
// Acceptance: OpeItcLoc03 infra repos must be present in cache (qualified)
|
||||
// so MCP-write tools can resolve them — even though they're hidden from
|
||||
// aggregation views (the aggregation filter is enforced separately at the
|
||||
// tasks.aggregate / search / get layer, not here).
|
||||
const ownerARepos = [{ name: 'biz1', default_branch: 'main' }];
|
||||
const skipOwnerRepos = [
|
||||
{ name: 'common', default_branch: 'master' },
|
||||
{ name: 'claude-skills', default_branch: 'master' },
|
||||
];
|
||||
|
||||
const client: Backend = {
|
||||
async listUserRepos(owner) {
|
||||
if (owner === 'victor') return ownerARepos;
|
||||
if (owner === 'OpeItcLoc03') return skipOwnerRepos;
|
||||
return [];
|
||||
},
|
||||
async getRawFile(_owner, _repo, path) {
|
||||
if (path !== '.tasks/STATUS.md') return null;
|
||||
return sample;
|
||||
},
|
||||
async getFileWithSha() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async commitFile() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
|
||||
const cache = await runSync({
|
||||
client,
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['victor'],
|
||||
aggregateSkipOwners: ['OpeItcLoc03'],
|
||||
});
|
||||
|
||||
expect(cache.projects.map((p) => p.name).sort()).toEqual([
|
||||
'OpeItcLoc03/claude-skills',
|
||||
'OpeItcLoc03/common',
|
||||
'victor/biz1',
|
||||
]);
|
||||
expect(cache.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('dedupes when an owner appears in both owners and aggregateSkipOwners', async () => {
|
||||
// If user fat-fingers and lists same owner in both — sync should not
|
||||
// double-fetch. Real-world cause: copy-paste error in auth.toml.
|
||||
let listUserReposCalls = 0;
|
||||
const client: Backend = {
|
||||
async listUserRepos() {
|
||||
listUserReposCalls++;
|
||||
return [{ name: 'r1', default_branch: 'main' }];
|
||||
},
|
||||
async getRawFile(_o, _r, path) {
|
||||
return path === '.tasks/STATUS.md' ? sample : null;
|
||||
},
|
||||
async getFileWithSha() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async commitFile() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
|
||||
const cache = await runSync({
|
||||
client,
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['victor'],
|
||||
aggregateSkipOwners: ['victor'],
|
||||
});
|
||||
|
||||
expect(listUserReposCalls).toBe(1);
|
||||
expect(cache.projects.map((p) => p.name)).toEqual(['victor/r1']);
|
||||
});
|
||||
|
||||
it('finds agenda repo under whichever owner contains it', async () => {
|
||||
const agendaContent = `# Agenda\n_Updated: 2026-04-29_\n\n## 🔴 [a1] — agenda task\n**Next action:** ship\n`;
|
||||
const calls: Array<{ owner: string; repo: string; path: string }> = [];
|
||||
|
||||
const client: Backend = {
|
||||
async listUserRepos(owner) {
|
||||
if (owner === 'owner-x') return [{ name: 'biz-repo', default_branch: 'main' }];
|
||||
if (owner === 'acting-user') return [{ name: 'agenda', default_branch: 'main' }];
|
||||
return [];
|
||||
},
|
||||
async getRawFile(owner, repo, path) {
|
||||
calls.push({ owner, repo, path });
|
||||
if (path !== '.tasks/STATUS.md') return null;
|
||||
if (owner === 'owner-x' && repo === 'biz-repo') return sample;
|
||||
if (owner === 'acting-user' && repo === 'agenda') return agendaContent;
|
||||
return null;
|
||||
},
|
||||
async getFileWithSha() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async commitFile() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
|
||||
const cache = await runSync({
|
||||
client,
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['owner-x', 'acting-user'],
|
||||
agendaTasksRepo: 'agenda',
|
||||
});
|
||||
|
||||
const names = cache.projects.map((p) => p.name).sort();
|
||||
expect(names).toEqual(['agenda', 'owner-x/biz-repo']);
|
||||
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
|
||||
expect(agenda.active_tasks.map((t) => t.slug)).toEqual(['a1']);
|
||||
// Confirm agenda was fetched from the right owner — not from owner-x.
|
||||
const agendaCalls = calls.filter(
|
||||
(c) => c.repo === 'agenda' && c.path === '.tasks/STATUS.md',
|
||||
);
|
||||
expect(agendaCalls).toHaveLength(1);
|
||||
expect(agendaCalls[0].owner).toBe('acting-user');
|
||||
});
|
||||
});
|
||||
80
lib/projects-meta-mcp/tests/lib/wiki-index-parser.test.ts
Normal file
80
lib/projects-meta-mcp/tests/lib/wiki-index-parser.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseWikiIndex } from '../../src/lib/wiki-index-parser.js';
|
||||
|
||||
describe('parseWikiIndex', () => {
|
||||
it('extracts entries from canonical index.md', () => {
|
||||
const md = `# Wiki Index
|
||||
|
||||
## Overview
|
||||
|
||||
- [overview.md](overview.md) — project overview
|
||||
|
||||
## Entities
|
||||
|
||||
- [some-entity](entities/some-entity.md) — Some Entity Title
|
||||
|
||||
## Concepts
|
||||
|
||||
- [foo](concepts/foo.md) — Foo Concept
|
||||
- [bar/baz](concepts/bar/baz.md) — Nested Bar Baz
|
||||
|
||||
## Packages
|
||||
|
||||
<!-- (none yet) -->
|
||||
|
||||
## Sources
|
||||
|
||||
<!-- (none yet) -->
|
||||
`;
|
||||
const entries = parseWikiIndex(md);
|
||||
expect(entries).toEqual([
|
||||
{ slug: 'entities/some-entity', type: 'entities', title: 'Some Entity Title' },
|
||||
{ slug: 'concepts/foo', type: 'concepts', title: 'Foo Concept' },
|
||||
{ slug: 'concepts/bar/baz', type: 'concepts', title: 'Nested Bar Baz' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when all sections are empty', () => {
|
||||
const md = `# Wiki Index
|
||||
|
||||
## Entities
|
||||
|
||||
<!-- (none yet) -->
|
||||
|
||||
## Concepts
|
||||
|
||||
<!-- (none yet) -->
|
||||
`;
|
||||
expect(parseWikiIndex(md)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array on completely malformed input', () => {
|
||||
expect(parseWikiIndex('not markdown at all')).toEqual([]);
|
||||
expect(parseWikiIndex('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips Overview section (special-cased: link is to overview.md, not type/slug.md)', () => {
|
||||
const md = `# Wiki Index\n\n## Overview\n\n- [overview.md](overview.md) — project overview\n`;
|
||||
expect(parseWikiIndex(md)).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips entries whose link does not match expected type/slug.md pattern', () => {
|
||||
const md = `# Wiki Index
|
||||
|
||||
## Concepts
|
||||
|
||||
- [external](https://example.com) — External Link
|
||||
- [proper](concepts/proper.md) — Proper Entry
|
||||
`;
|
||||
expect(parseWikiIndex(md)).toEqual([
|
||||
{ slug: 'concepts/proper', type: 'concepts', title: 'Proper Entry' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles Raw section', () => {
|
||||
const md = `# Wiki Index\n\n## Raw\n\n- [doc](raw/doc.md) — Some PDF\n`;
|
||||
expect(parseWikiIndex(md)).toEqual([
|
||||
{ slug: 'raw/doc', type: 'raw', title: 'Some PDF' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
70
lib/projects-meta-mcp/tests/lib/wiki-index.test.ts
Normal file
70
lib/projects-meta-mcp/tests/lib/wiki-index.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadWiki } from '../../src/lib/wiki-index.js';
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'wiki-'));
|
||||
await mkdir(join(root, 'node'), { recursive: true });
|
||||
await mkdir(join(root, 'cross'), { recursive: true });
|
||||
|
||||
await writeFile(join(root, 'CLAUDE.md'), 'should be skipped');
|
||||
await writeFile(join(root, 'README.md'), 'should be skipped');
|
||||
|
||||
await writeFile(
|
||||
join(root, 'node', 'windows-yarn-exec.md'),
|
||||
[
|
||||
'---',
|
||||
'title: Windows yarn requires exec()',
|
||||
'domain: node',
|
||||
'tags: [windows, yarn]',
|
||||
'---',
|
||||
'',
|
||||
'## Why',
|
||||
'Windows is special.',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
join(root, 'cross', 'git-tips.md'),
|
||||
['---', 'title: Git tips', 'domain: cross', 'tags: [git]', '---', '', 'body'].join('\n'),
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
join(root, 'cross', 'no-fm.md'),
|
||||
'just a body, no frontmatter',
|
||||
);
|
||||
});
|
||||
|
||||
describe('loadWiki', () => {
|
||||
it('skips top-level meta files', async () => {
|
||||
const pages = await loadWiki(root);
|
||||
expect(pages.find((p) => p.slug === 'CLAUDE')).toBeUndefined();
|
||||
expect(pages.find((p) => p.slug === 'README')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('loads pages with frontmatter', async () => {
|
||||
const pages = await loadWiki(root);
|
||||
const yarn = pages.find((p) => p.slug === 'node/windows-yarn-exec');
|
||||
expect(yarn).toBeDefined();
|
||||
expect(yarn!.title).toBe('Windows yarn requires exec()');
|
||||
expect(yarn!.domain).toBe('node');
|
||||
expect(yarn!.tags).toEqual(['windows', 'yarn']);
|
||||
expect(yarn!.body).toContain('Windows is special.');
|
||||
});
|
||||
|
||||
it('marks frontmatterless pages as unknown domain', async () => {
|
||||
const pages = await loadWiki(root);
|
||||
const fm = pages.find((p) => p.slug === 'cross/no-fm');
|
||||
expect(fm).toBeDefined();
|
||||
expect(fm!.domain).toBe('unknown');
|
||||
});
|
||||
|
||||
it('returns empty array if root missing', async () => {
|
||||
const pages = await loadWiki(join(root, 'does-not-exist'));
|
||||
expect(pages).toEqual([]);
|
||||
});
|
||||
});
|
||||
132
lib/projects-meta-mcp/tests/lib/wiki-writer.test.ts
Normal file
132
lib/projects-meta-mcp/tests/lib/wiki-writer.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
formatPage,
|
||||
pagePath,
|
||||
indexPath,
|
||||
logPath,
|
||||
rawPath,
|
||||
insertIndexEntry,
|
||||
appendLogEntry,
|
||||
freshIndexMd,
|
||||
freshLogMd,
|
||||
} from '../../src/lib/wiki-writer.js';
|
||||
|
||||
describe('formatPage', () => {
|
||||
it('produces frontmatter+body via gray-matter', () => {
|
||||
const out = formatPage({
|
||||
type: 'concepts',
|
||||
slug: 'tasks-routing-rule',
|
||||
body: 'Read local `.tasks/STATUS.md` for current project.',
|
||||
frontmatter: { tags: ['routing'], domain: 'projects-meta' },
|
||||
});
|
||||
expect(out.startsWith('---\n')).toBe(true);
|
||||
expect(out).toContain("title: tasks-routing-rule");
|
||||
expect(out).toContain('type: concept');
|
||||
expect(out).toContain('domain: projects-meta');
|
||||
expect(out).toContain('Read local `.tasks/STATUS.md`');
|
||||
});
|
||||
|
||||
it('auto-injects ingested_at / ingested_by / source_project', () => {
|
||||
const out = formatPage({
|
||||
type: 'sources',
|
||||
slug: 's',
|
||||
body: 'b',
|
||||
ingestedAtIso: '2026-04-29T12:00:00Z',
|
||||
ingestedBy: 'vitya@laptop',
|
||||
sourceProject: 'modules-db',
|
||||
});
|
||||
expect(out).toContain("ingested_at: '2026-04-29T12:00:00Z'");
|
||||
expect(out).toContain('ingested_by: vitya@laptop');
|
||||
expect(out).toContain('source_project: modules-db');
|
||||
});
|
||||
|
||||
it('respects user-supplied title and type override', () => {
|
||||
const out = formatPage({
|
||||
type: 'concepts',
|
||||
slug: 's',
|
||||
body: 'b',
|
||||
frontmatter: { title: 'Hand title', type: 'design-decision' },
|
||||
});
|
||||
expect(out).toContain('title: Hand title');
|
||||
expect(out).toContain('type: design-decision');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagePath', () => {
|
||||
it('builds .wiki/<type>/<slug>.md for project wikis (default / isAgenda=false)', () => {
|
||||
expect(pagePath('concepts', 'foo')).toBe('.wiki/concepts/foo.md');
|
||||
expect(pagePath('raw', 'pdf-note')).toBe('.wiki/raw/pdf-note.md');
|
||||
expect(pagePath('concepts', 'foo', false)).toBe('.wiki/concepts/foo.md');
|
||||
});
|
||||
|
||||
it('builds <type>/<slug>.md for the shared wiki (isAgenda=true, no .wiki/ prefix)', () => {
|
||||
expect(pagePath('concepts', 'foo', true)).toBe('concepts/foo.md');
|
||||
expect(pagePath('sources', 'bar', true)).toBe('sources/bar.md');
|
||||
expect(pagePath('raw', 'pdf', true)).toBe('raw/pdf.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('indexPath / logPath / rawPath', () => {
|
||||
it('project wiki layout — .wiki/ prefix', () => {
|
||||
expect(indexPath()).toBe('.wiki/index.md');
|
||||
expect(indexPath(false)).toBe('.wiki/index.md');
|
||||
expect(logPath()).toBe('.wiki/log.md');
|
||||
expect(logPath(false)).toBe('.wiki/log.md');
|
||||
expect(rawPath('foo')).toBe('.wiki/raw/foo.md');
|
||||
expect(rawPath('foo', false)).toBe('.wiki/raw/foo.md');
|
||||
});
|
||||
|
||||
it('shared wiki layout — repo root, no prefix', () => {
|
||||
expect(indexPath(true)).toBe('index.md');
|
||||
expect(logPath(true)).toBe('log.md');
|
||||
expect(rawPath('foo', true)).toBe('raw/foo.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertIndexEntry', () => {
|
||||
it('inserts entry into existing section, removes (none yet) placeholder', () => {
|
||||
const before = freshIndexMd();
|
||||
const after = insertIndexEntry(before, 'concepts', 'tasks-routing-rule', 'Tasks Routing Rule');
|
||||
expect(after).toContain('- [tasks-routing-rule](concepts/tasks-routing-rule.md) — Tasks Routing Rule');
|
||||
// Placeholder gone for Concepts section, but other sections still empty
|
||||
const conceptsSection = after.slice(after.indexOf('## Concepts'), after.indexOf('## Packages'));
|
||||
expect(conceptsSection).not.toContain('(none yet)');
|
||||
const packagesSection = after.slice(after.indexOf('## Packages'), after.indexOf('## Sources'));
|
||||
expect(packagesSection).toContain('(none yet)');
|
||||
});
|
||||
|
||||
it('appends to non-empty section preserving prior entries', () => {
|
||||
const before = `# Wiki Index\n\n## Concepts\n\n- [first](concepts/first.md) — first\n\n## Packages\n\n<!-- (none yet) -->\n`;
|
||||
const after = insertIndexEntry(before, 'concepts', 'second', 'Second');
|
||||
expect(after).toContain('- [first](concepts/first.md) — first');
|
||||
expect(after).toContain('- [second](concepts/second.md) — Second');
|
||||
});
|
||||
|
||||
it('skips if entry with the same slug already present', () => {
|
||||
const before = `# Wiki Index\n\n## Concepts\n\n- [foo](concepts/foo.md) — Foo\n\n## Packages\n\n`;
|
||||
const after = insertIndexEntry(before, 'concepts', 'foo', 'Foo Again');
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it('appends section if missing', () => {
|
||||
const before = `# Wiki Index\n\n## Overview\n\n- [overview.md](overview.md) — overview\n`;
|
||||
const after = insertIndexEntry(before, 'sources', 's1', 'Source One');
|
||||
expect(after).toContain('## Sources');
|
||||
expect(after).toContain('- [s1](sources/s1.md) — Source One');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendLogEntry', () => {
|
||||
it('appends to existing log', () => {
|
||||
const before = freshLogMd();
|
||||
const after = appendLogEntry(before, '2026-04-29', 'ingest', 'concepts/x');
|
||||
expect(after).toContain('## [2026-04-29] ingest | concepts/x');
|
||||
expect(after).toContain('# Wiki Log'); // header preserved
|
||||
});
|
||||
|
||||
it('initializes when log is empty', () => {
|
||||
const after = appendLogEntry('', '2026-04-29', 'init', 'bootstrap');
|
||||
expect(after).toMatch(/^# Wiki Log/);
|
||||
expect(after).toContain('## [2026-04-29] init | bootstrap');
|
||||
});
|
||||
});
|
||||
475
lib/projects-meta-mcp/tests/tools/knowledge-ask-projects.test.ts
Normal file
475
lib/projects-meta-mcp/tests/tools/knowledge-ask-projects.test.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp, mkdir } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { makeKnowledgeTools } from '../../src/tools/knowledge.js';
|
||||
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
|
||||
import type { Backend, CommitFileArgs, FileWithSha } from '../../src/lib/backend.js';
|
||||
|
||||
let wikiRoot: string;
|
||||
let projectCwd: string;
|
||||
let cacheFile: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
wikiRoot = await mkdtemp(join(tmpdir(), 'kw-shared-'));
|
||||
await mkdir(wikiRoot, { recursive: true });
|
||||
projectCwd = await mkdtemp(join(tmpdir(), 'kw-cwd-'));
|
||||
cacheFile = join(await mkdtemp(join(tmpdir(), 'kw-cache-')), 'cache.json');
|
||||
});
|
||||
|
||||
interface FetchedPage {
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages are keyed by qualified `<owner>/<repo>` so the mock backend can verify
|
||||
* that handlers route fetches to the correct owner — the whole point of the
|
||||
* cross-owner regression in [multi-owner-ask-projects-owner-fix].
|
||||
*/
|
||||
function makeBackend(pages: Record<string, Record<string, FetchedPage | Error>>): Backend {
|
||||
return {
|
||||
async listUserRepos() {
|
||||
return [];
|
||||
},
|
||||
async getRawFile(owner, repo, path) {
|
||||
const repoPages = pages[`${owner}/${repo}`];
|
||||
if (!repoPages) return null;
|
||||
const v = repoPages[path];
|
||||
if (v === undefined) return null;
|
||||
if (v instanceof Error) throw v;
|
||||
return v.body;
|
||||
},
|
||||
async getFileWithSha(): Promise<FileWithSha | null> {
|
||||
return null;
|
||||
},
|
||||
async commitFile(_a: CommitFileArgs) {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function seedCache(projects: CacheFile['projects']) {
|
||||
const file: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-04-30T00:00:00Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects,
|
||||
errors: [],
|
||||
};
|
||||
await writeCache(cacheFile, file);
|
||||
}
|
||||
|
||||
function call(
|
||||
tools: ReturnType<typeof makeKnowledgeTools>,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
) {
|
||||
const t = tools.find((x) => x.name === name);
|
||||
if (!t) throw new Error(`tool ${name} missing`);
|
||||
return t.handler(args);
|
||||
}
|
||||
|
||||
const sampleIndex = `# Wiki Index
|
||||
|
||||
## Concepts
|
||||
|
||||
- [yarn-on-windows](concepts/yarn-on-windows.md) — Yarn on Windows
|
||||
- [other](concepts/other.md) — Other Concept
|
||||
|
||||
## Raw
|
||||
|
||||
- [pdf-dump](raw/pdf-dump.md) — Heavy PDF
|
||||
`;
|
||||
|
||||
describe('knowledge.ask_projects', () => {
|
||||
it('returns matches across listed projects with snippets', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({
|
||||
'u/foo': {
|
||||
'.wiki/concepts/yarn-on-windows.md': {
|
||||
body: '---\ntitle: Yarn on Windows\n---\n\nyarn install on windows is fiddly',
|
||||
},
|
||||
},
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'yarn',
|
||||
projects: ['u/foo'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.asked_projects).toEqual(['u/foo']);
|
||||
expect(parsed.results).toHaveLength(1);
|
||||
expect(parsed.results[0].project).toBe('u/foo');
|
||||
expect(parsed.results[0].matches).toHaveLength(1);
|
||||
expect(parsed.results[0].matches[0].slug).toBe('concepts/yarn-on-windows');
|
||||
expect(parsed.results[0].matches[0].type).toBe('concepts');
|
||||
expect(parsed.results[0].matches[0].title).toBe('Yarn on Windows');
|
||||
expect(parsed.results[0].matches[0].snippet).toContain('yarn');
|
||||
});
|
||||
|
||||
it('routes fetches to the correct owner across multiple owners (cross-owner)', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'victor/books',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
{
|
||||
name: 'OpeItcLoc03/common',
|
||||
default_branch: 'master',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
]);
|
||||
const calls: Array<{ owner: string; repo: string }> = [];
|
||||
const backend: Backend = {
|
||||
async listUserRepos() {
|
||||
return [];
|
||||
},
|
||||
async getRawFile(owner, repo, path) {
|
||||
calls.push({ owner, repo });
|
||||
if (owner === 'victor' && repo === 'books') {
|
||||
return path === '.wiki/concepts/yarn-on-windows.md' ? 'victor body about yarn' : null;
|
||||
}
|
||||
if (owner === 'OpeItcLoc03' && repo === 'common') {
|
||||
return path === '.wiki/concepts/yarn-on-windows.md' ? 'common body about yarn' : null;
|
||||
}
|
||||
// Reject acting-identity routing — proves the bug is gone.
|
||||
throw new Error(`unexpected fetch ${owner}/${repo}`);
|
||||
},
|
||||
async getFileWithSha(): Promise<FileWithSha | null> {
|
||||
return null;
|
||||
},
|
||||
async commitFile() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'OpeItcLoc03',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'yarn',
|
||||
projects: ['victor/books', 'OpeItcLoc03/common'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results).toHaveLength(2);
|
||||
const victorResult = parsed.results.find((x: { project: string }) => x.project === 'victor/books');
|
||||
const commonResult = parsed.results.find((x: { project: string }) => x.project === 'OpeItcLoc03/common');
|
||||
expect(victorResult.matches[0].snippet).toContain('victor body');
|
||||
expect(commonResult.matches[0].snippet).toContain('common body');
|
||||
// Owner extracted from projectName, not from acting giteaUser.
|
||||
expect(calls.some((c) => c.owner === 'victor' && c.repo === 'books')).toBe(true);
|
||||
expect(calls.some((c) => c.owner === 'OpeItcLoc03' && c.repo === 'common')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects bare project name with parseTargetProject hint', async () => {
|
||||
await seedCache([]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'x',
|
||||
projects: ['books'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results).toHaveLength(1);
|
||||
expect(parsed.results[0].project).toBe('books');
|
||||
expect(parsed.results[0].error).toContain('must be qualified');
|
||||
});
|
||||
|
||||
it('continues with valid projects when one is bare', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({
|
||||
'u/foo': {
|
||||
'.wiki/concepts/yarn-on-windows.md': { body: 'yarn body' },
|
||||
},
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'yarn',
|
||||
projects: ['books', 'u/foo'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results).toHaveLength(2);
|
||||
expect(parsed.results[0].error).toContain('must be qualified');
|
||||
expect(parsed.results[1].matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects agenda literal with explanatory error', async () => {
|
||||
await seedCache([]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'x',
|
||||
projects: ['agenda'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results[0].error).toContain('agenda not supported');
|
||||
});
|
||||
|
||||
it('reports "no wiki" for projects without wiki_index', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/no-wiki',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'anything',
|
||||
projects: ['u/no-wiki'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results).toEqual([
|
||||
{ project: 'u/no-wiki', error: 'no wiki — repo has no .wiki/index.md' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports "unknown project" when project not in cache', async () => {
|
||||
await seedCache([]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'x',
|
||||
projects: ['u/ghost'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results).toEqual([
|
||||
{ project: 'u/ghost', error: 'unknown project — run sync first' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes raw type by default', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({
|
||||
'u/foo': {
|
||||
'.wiki/raw/pdf-dump.md': { body: 'pdf about pdf' },
|
||||
},
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'pdf',
|
||||
projects: ['u/foo'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results[0].matches).toEqual([]);
|
||||
});
|
||||
|
||||
it('includes raw when explicitly asked', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({
|
||||
'u/foo': { '.wiki/raw/pdf-dump.md': { body: 'pdf body' } },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'pdf',
|
||||
projects: ['u/foo'],
|
||||
types: ['raw'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results[0].matches).toHaveLength(1);
|
||||
expect(parsed.results[0].matches[0].slug).toBe('raw/pdf-dump');
|
||||
});
|
||||
|
||||
it('respects limit per project', async () => {
|
||||
const wideIndex = `# Wiki Index\n\n## Concepts\n\n- [a](concepts/a.md) — A\n- [b](concepts/b.md) — B\n- [c](concepts/c.md) — C\n`;
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: wideIndex,
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({
|
||||
'u/foo': {
|
||||
'.wiki/concepts/a.md': { body: '' },
|
||||
'.wiki/concepts/b.md': { body: '' },
|
||||
'.wiki/concepts/c.md': { body: '' },
|
||||
},
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: '',
|
||||
projects: ['u/foo'],
|
||||
limit: 2,
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results[0].matches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('marks snippet as "(fetch failed)" when live fetch throws', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({
|
||||
'u/foo': {
|
||||
'.wiki/concepts/yarn-on-windows.md': new Error('boom'),
|
||||
},
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'yarn',
|
||||
projects: ['u/foo'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results[0].matches[0].snippet).toBe('(fetch failed)');
|
||||
expect(parsed.results[0].matches[0].slug).toBe('concepts/yarn-on-windows');
|
||||
});
|
||||
|
||||
it('returns error when backend not configured', async () => {
|
||||
await seedCache([]);
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile });
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'x',
|
||||
projects: ['u/foo'],
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('disabled');
|
||||
});
|
||||
|
||||
it('next_action_hint mentions both knowledge.get_from and tasks.create', async () => {
|
||||
await seedCache([]);
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend: makeBackend({}),
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'x',
|
||||
projects: ['u/foo'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.next_action_hint).toContain('knowledge.get_from');
|
||||
expect(parsed.next_action_hint).toContain('tasks.create');
|
||||
});
|
||||
});
|
||||
217
lib/projects-meta-mcp/tests/tools/knowledge-get-from.test.ts
Normal file
217
lib/projects-meta-mcp/tests/tools/knowledge-get-from.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { makeKnowledgeTools } from '../../src/tools/knowledge.js';
|
||||
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
|
||||
import type { Backend, CommitFileArgs, FileWithSha } from '../../src/lib/backend.js';
|
||||
|
||||
let wikiRoot: string;
|
||||
let projectCwd: string;
|
||||
let cacheFile: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
wikiRoot = await mkdtemp(join(tmpdir(), 'kw-shared-'));
|
||||
projectCwd = await mkdtemp(join(tmpdir(), 'kw-cwd-'));
|
||||
cacheFile = join(await mkdtemp(join(tmpdir(), 'kw-cache-')), 'cache.json');
|
||||
});
|
||||
|
||||
function makeBackend(files: Record<string, Record<string, string | null | Error>>): Backend {
|
||||
return {
|
||||
async listUserRepos() {
|
||||
return [];
|
||||
},
|
||||
async getRawFile(_u, repo, path) {
|
||||
const v = files[repo]?.[path];
|
||||
if (v === undefined) return null;
|
||||
if (v instanceof Error) throw v;
|
||||
return v;
|
||||
},
|
||||
async getFileWithSha(): Promise<FileWithSha | null> {
|
||||
return null;
|
||||
},
|
||||
async commitFile(_a: CommitFileArgs) {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function seedCache(projects: CacheFile['projects']) {
|
||||
await writeCache(cacheFile, {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-04-30T00:00:00Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects,
|
||||
errors: [],
|
||||
});
|
||||
}
|
||||
|
||||
function call(
|
||||
tools: ReturnType<typeof makeKnowledgeTools>,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
) {
|
||||
const t = tools.find((x) => x.name === name);
|
||||
if (!t) throw new Error(`tool ${name} missing`);
|
||||
return t.handler(args);
|
||||
}
|
||||
|
||||
describe('knowledge.get_from', () => {
|
||||
it('returns full page body for a known qualified project + slug', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'victor/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: '',
|
||||
},
|
||||
]);
|
||||
const body = '---\ntitle: Foo\n---\n\nfoo body content';
|
||||
const backend = makeBackend({
|
||||
foo: { '.wiki/concepts/foo.md': body },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: 'victor/foo',
|
||||
slug: 'concepts/foo',
|
||||
});
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content[0].text).toBe(body);
|
||||
});
|
||||
|
||||
it('rejects bare project name with hint', async () => {
|
||||
await seedCache([]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: 'foo',
|
||||
slug: 'concepts/x',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('must be qualified');
|
||||
});
|
||||
|
||||
it('returns error when qualified project not in cache', async () => {
|
||||
await seedCache([]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: 'ghost/repo',
|
||||
slug: 'concepts/x',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('project not in cache');
|
||||
});
|
||||
|
||||
it('returns error when page does not exist', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'victor/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({ foo: {} });
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: 'victor/foo',
|
||||
slug: 'concepts/missing',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('page not found');
|
||||
});
|
||||
|
||||
it('resolves agenda alias to projects-wiki', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'agenda',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
]);
|
||||
const body = '---\ntitle: Agenda\n---\n\nagenda content';
|
||||
// Shared wiki (projects-wiki) stores content at repo root, NO .wiki/ prefix.
|
||||
// Asymmetric with project wikis where pages live under <repo>/.wiki/.
|
||||
const backend = makeBackend({
|
||||
'projects-wiki': { 'concepts/test.md': body },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: 'agenda',
|
||||
slug: 'concepts/test',
|
||||
});
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content[0].text).toBe(body);
|
||||
});
|
||||
|
||||
it('rejects legacy "_meta" literal with hint (renamed to "agenda" in 2.0)', async () => {
|
||||
await seedCache([]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: '_meta',
|
||||
slug: 'concepts/test',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('must be qualified');
|
||||
});
|
||||
|
||||
it('returns error when backend not configured', async () => {
|
||||
await seedCache([]);
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile });
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: 'victor/foo',
|
||||
slug: 'concepts/x',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('disabled');
|
||||
});
|
||||
});
|
||||
455
lib/projects-meta-mcp/tests/tools/knowledge.test.ts
Normal file
455
lib/projects-meta-mcp/tests/tools/knowledge.test.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { makeKnowledgeTools } from '../../src/tools/knowledge.js';
|
||||
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
|
||||
import type { Backend, CommitFileArgs, FileWithSha } from '../../src/lib/backend.js';
|
||||
|
||||
let wikiRoot: string;
|
||||
let nodeProject: string;
|
||||
let unknownProject: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
wikiRoot = await mkdtemp(join(tmpdir(), 'kw-wiki-'));
|
||||
await mkdir(join(wikiRoot, 'node'), { recursive: true });
|
||||
await mkdir(join(wikiRoot, 'embedded'), { recursive: true });
|
||||
await mkdir(join(wikiRoot, 'cross'), { recursive: true });
|
||||
|
||||
await writeFile(
|
||||
join(wikiRoot, 'node', 'yarn-windows.md'),
|
||||
'---\ntitle: Yarn on Windows\ndomain: node\n---\n\nyarn body',
|
||||
);
|
||||
await writeFile(
|
||||
join(wikiRoot, 'embedded', 'stm32-flash.md'),
|
||||
'---\ntitle: STM32 Flashing\ndomain: embedded\n---\n\nstm32 body',
|
||||
);
|
||||
await writeFile(
|
||||
join(wikiRoot, 'cross', 'git-trick.md'),
|
||||
'---\ntitle: Git Trick\ndomain: cross\n---\n\ngit yarn body',
|
||||
);
|
||||
|
||||
nodeProject = await mkdtemp(join(tmpdir(), 'kw-proj-'));
|
||||
await writeFile(join(nodeProject, 'package.json'), '{}');
|
||||
|
||||
unknownProject = await mkdtemp(join(tmpdir(), 'kw-proj-'));
|
||||
});
|
||||
|
||||
function call(tools: ReturnType<typeof makeKnowledgeTools>, name: string, args: Record<string, unknown>) {
|
||||
const t = tools.find((x) => x.name === name);
|
||||
if (!t) throw new Error(`tool ${name} missing`);
|
||||
return t.handler(args);
|
||||
}
|
||||
|
||||
describe('knowledge.search (node project)', () => {
|
||||
it('shows node + cross by default, hides embedded', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.search', { query: 'yarn' });
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
const slugs = parsed.results.map((x: { slug: string }) => x.slug).sort();
|
||||
expect(slugs).toEqual(['cross/git-trick', 'node/yarn-windows']);
|
||||
});
|
||||
|
||||
it('domain="all" shows everything', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.search', { query: '', domain: 'all' });
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results.length).toBe(3);
|
||||
});
|
||||
|
||||
it('explicit domain switches filter', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.search', { query: '', domain: 'embedded' });
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
const slugs = parsed.results.map((x: { slug: string }) => x.slug).sort();
|
||||
expect(slugs).toEqual(['cross/git-trick', 'embedded/stm32-flash']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledge.search (unknown project)', () => {
|
||||
it('returns all when cwd domain unknown', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: unknownProject });
|
||||
const r = await call(tools, 'knowledge.search', { query: '' });
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledge.get', () => {
|
||||
it('returns full page including frontmatter', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.get', { slug: 'node/yarn-windows' });
|
||||
expect(r.content[0].text).toContain('domain: node');
|
||||
expect(r.content[0].text).toContain('yarn body');
|
||||
});
|
||||
|
||||
it('errors on missing slug', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.get', { slug: 'ghost/page' });
|
||||
expect(r.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledge.suggest_promote', () => {
|
||||
it('returns candidates from project .wiki/concepts', async () => {
|
||||
await mkdir(join(nodeProject, '.wiki', 'concepts'), { recursive: true });
|
||||
await writeFile(
|
||||
join(nodeProject, '.wiki', 'concepts', 'docker-quirk.md'),
|
||||
'---\ntitle: Docker quirk\n---\n\nDocker daemon stuff.',
|
||||
);
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.suggest_promote', {});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.candidates).toHaveLength(1);
|
||||
expect(parsed.candidates[0].slug).toBe('docker-quirk');
|
||||
expect(parsed.candidates[0].suggested_domain).toBe('node');
|
||||
});
|
||||
});
|
||||
|
||||
interface RecordedCommit extends CommitFileArgs {}
|
||||
|
||||
function recordingBackend(initial: Record<string, FileWithSha> = {}): {
|
||||
backend: Backend;
|
||||
commits: RecordedCommit[];
|
||||
files: Record<string, FileWithSha>;
|
||||
} {
|
||||
const files = { ...initial };
|
||||
const commits: RecordedCommit[] = [];
|
||||
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) {
|
||||
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 fixedNow = () => new Date('2026-04-29T12:00:00Z');
|
||||
const fixture: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-04-29T12:00:00.000Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{ name: 'OpeItcLoc03/alpha', default_branch: 'main', fetched_at: '2026-04-29T12:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '' },
|
||||
{ name: 'victor/books', default_branch: 'main', fetched_at: '2026-04-29T12:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '' },
|
||||
{ name: 'projects-wiki', default_branch: 'main', fetched_at: '2026-04-29T12:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '' },
|
||||
],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
async function makeCacheFile(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'kw-cache-'));
|
||||
const f = join(dir, 'tasks.json');
|
||||
await writeCache(f, fixture);
|
||||
return f;
|
||||
}
|
||||
|
||||
describe('knowledge.ingest — preview', () => {
|
||||
it('returns preview without committing', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
machine: 'host-x',
|
||||
sourceProject: 'modules-db',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
type: 'concepts',
|
||||
slug: 'tasks-routing-rule',
|
||||
body: 'Read local STATUS.md for current project.',
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.preview).toBe(true);
|
||||
expect(parsed.target_owner).toBe('OpeItcLoc03');
|
||||
expect(parsed.target_repo).toBe('alpha');
|
||||
expect(parsed.target_path).toBe('.wiki/concepts/tasks-routing-rule.md');
|
||||
expect(parsed.content).toContain('title: tasks-routing-rule');
|
||||
expect(parsed.content).toContain('source_project: modules-db');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects bare target_project with hint', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'alpha',
|
||||
type: 'concepts',
|
||||
slug: 'x',
|
||||
body: 'b',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('must be qualified');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledge.ingest — confirm', () => {
|
||||
it('writes page + index + log (3 commits) and removes (none yet) placeholder', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits, files } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
machine: 'host-x',
|
||||
sourceProject: 'modules-db',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
type: 'concepts',
|
||||
slug: 'routing-rule',
|
||||
body: 'rule body',
|
||||
frontmatter: { title: 'Tasks Routing Rule', tags: ['routing'] },
|
||||
confirm: true,
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.committed).toBe(true);
|
||||
expect(parsed.target_owner).toBe('OpeItcLoc03');
|
||||
expect(parsed.target_repo).toBe('alpha');
|
||||
expect(parsed.commits).toHaveLength(3);
|
||||
const paths = parsed.commits.map((c: { path: string }) => c.path);
|
||||
expect(paths).toEqual(['.wiki/concepts/routing-rule.md', '.wiki/index.md', '.wiki/log.md']);
|
||||
expect(files['alpha:.wiki/concepts/routing-rule.md'].content).toContain('Tasks Routing Rule');
|
||||
expect(files['alpha:.wiki/index.md'].content).toContain('- [routing-rule](concepts/routing-rule.md) — Tasks Routing Rule');
|
||||
expect(files['alpha:.wiki/log.md'].content).toContain('## [2026-04-29] ingest | concepts/routing-rule');
|
||||
// Three commits = page + index + log
|
||||
expect(commits).toHaveLength(3);
|
||||
expect(commits[0].user).toBe('OpeItcLoc03');
|
||||
expect(commits[0].repo).toBe('alpha');
|
||||
});
|
||||
|
||||
it('routes victor/books to owner=victor', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
machine: 'host-x',
|
||||
sourceProject: 'modules-db',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'victor/books',
|
||||
type: 'concepts',
|
||||
slug: 'pagination',
|
||||
body: 'how to paginate',
|
||||
confirm: true,
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.committed).toBe(true);
|
||||
expect(parsed.target_owner).toBe('victor');
|
||||
expect(parsed.target_repo).toBe('books');
|
||||
expect(commits[0].user).toBe('victor');
|
||||
expect(commits[0].repo).toBe('books');
|
||||
});
|
||||
|
||||
it('rejects when page already exists', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.wiki/concepts/x.md': { content: '---\ntitle: x\n---\n', sha: 's' },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
type: 'concepts',
|
||||
slug: 'x',
|
||||
body: 'b',
|
||||
confirm: true,
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('already exists');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects unknown target_project (cache miss)', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'ghost/repo',
|
||||
type: 'concepts',
|
||||
slug: 's',
|
||||
body: 'b',
|
||||
confirm: true,
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('not in cache');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('write tools disabled when no backend/auth', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
type: 'concepts',
|
||||
slug: 's',
|
||||
body: 'b',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('Write tools disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledge.promote', () => {
|
||||
it('preview returns sources content with raw_path frontmatter', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.wiki/raw/x.md': { content: '# raw\n', sha: 'r' },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.promote', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'x',
|
||||
body: 'summary of raw',
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.preview).toBe(true);
|
||||
expect(parsed.target_owner).toBe('OpeItcLoc03');
|
||||
expect(parsed.target_repo).toBe('alpha');
|
||||
expect(parsed.target_path).toBe('.wiki/sources/x.md');
|
||||
expect(parsed.content).toContain('raw_path: raw/x.md');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects bare target_project', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.promote', {
|
||||
target_project: 'alpha',
|
||||
slug: 'x',
|
||||
body: 'b',
|
||||
confirm: true,
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('must be qualified');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects when raw page is missing', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.promote', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'missing',
|
||||
body: 'b',
|
||||
confirm: true,
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('raw page not found');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('confirm writes sources + updates index/log', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits, files } = recordingBackend({
|
||||
'alpha:.wiki/raw/notes.md': { content: '# notes\n', sha: 'r' },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.promote', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'notes',
|
||||
body: 'summary text',
|
||||
frontmatter: { title: 'Notes Summary' },
|
||||
confirm: true,
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.committed).toBe(true);
|
||||
expect(parsed.target_owner).toBe('OpeItcLoc03');
|
||||
expect(parsed.target_repo).toBe('alpha');
|
||||
expect(parsed.commits.map((c: { path: string }) => c.path)).toEqual([
|
||||
'.wiki/sources/notes.md',
|
||||
'.wiki/index.md',
|
||||
'.wiki/log.md',
|
||||
]);
|
||||
expect(files['alpha:.wiki/sources/notes.md'].content).toContain('raw_path: raw/notes.md');
|
||||
expect(files['alpha:.wiki/log.md'].content).toContain('promote | raw/notes → sources/notes');
|
||||
expect(commits).toHaveLength(3);
|
||||
expect(commits[0].user).toBe('OpeItcLoc03');
|
||||
expect(commits[0].repo).toBe('alpha');
|
||||
});
|
||||
});
|
||||
153
lib/projects-meta-mcp/tests/tools/meta.test.ts
Normal file
153
lib/projects-meta-mcp/tests/tools/meta.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { writeCache } from '../../src/lib/cache.js';
|
||||
import { makeMetaTools } from '../../src/tools/meta.js';
|
||||
import type { Paths } from '../../src/lib/config.js';
|
||||
|
||||
function mkPaths(home: string): Paths {
|
||||
const cacheDir = join(home, '.cache');
|
||||
return {
|
||||
cacheDir,
|
||||
cacheFile: join(cacheDir, 'tasks.json'),
|
||||
syncLog: join(cacheDir, 'sync.log'),
|
||||
authFile: join(home, '.config', 'auth.toml'),
|
||||
sharedWikiClone: join(home, 'wiki'),
|
||||
agendaTasksDir: join(home, 'tasks'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('meta.status', () => {
|
||||
let paths: Paths;
|
||||
|
||||
beforeEach(async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'meta-'));
|
||||
paths = mkPaths(home);
|
||||
await mkdir(paths.sharedWikiClone, { recursive: true });
|
||||
await mkdir(join(paths.sharedWikiClone, 'node'), { recursive: true });
|
||||
await writeFile(
|
||||
join(paths.sharedWikiClone, 'node', 'one.md'),
|
||||
'---\ntitle: t\ndomain: node\n---\n\nbody',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports cache_missing when no cache', async () => {
|
||||
const tools = makeMetaTools({ paths });
|
||||
const r = await (tools.find((t) => t.name === 'meta.status')!.handler({}));
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.cache_missing).toBe(true);
|
||||
expect(parsed.wiki_pages_count).toBe(1);
|
||||
expect(parsed.agenda_present).toBe(false);
|
||||
expect(parsed.agenda_tasks_dir).toBe(paths.agendaTasksDir);
|
||||
});
|
||||
|
||||
it('reports age and stale=false for fresh cache', async () => {
|
||||
await writeCache(paths.cacheFile, {
|
||||
schemaVersion: 3,
|
||||
synced_at: new Date().toISOString(),
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [],
|
||||
errors: [],
|
||||
});
|
||||
const tools = makeMetaTools({ paths });
|
||||
const r = await (tools.find((t) => t.name === 'meta.status')!.handler({}));
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.stale).toBe(false);
|
||||
expect(parsed.age_seconds).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('reports stale=true for >24h cache', async () => {
|
||||
const old = new Date(Date.now() - 25 * 3600 * 1000).toISOString();
|
||||
await writeCache(paths.cacheFile, {
|
||||
schemaVersion: 3,
|
||||
synced_at: old,
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [{ name: 'p', default_branch: 'main', fetched_at: old, active_tasks: [], all_tasks_count: 0, raw: '' }],
|
||||
errors: [{ project: 'q', reason: 'x' }],
|
||||
});
|
||||
const tools = makeMetaTools({ paths });
|
||||
const r = await (tools.find((t) => t.name === 'meta.status')!.handler({}));
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.stale).toBe(true);
|
||||
expect(parsed.projects_count).toBe(1);
|
||||
expect(parsed.errors_count).toBe(1);
|
||||
});
|
||||
|
||||
it('exposes agenda-project counts and source=local when agenda default_branch is "local"', async () => {
|
||||
await writeCache(paths.cacheFile, {
|
||||
schemaVersion: 3,
|
||||
synced_at: new Date().toISOString(),
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{ name: 'p', default_branch: 'main', fetched_at: new Date().toISOString(), active_tasks: [], all_tasks_count: 0, raw: '' },
|
||||
{
|
||||
name: 'agenda',
|
||||
default_branch: 'local',
|
||||
fetched_at: new Date().toISOString(),
|
||||
active_tasks: [
|
||||
{ slug: 'm1', status: 'active', next: 'do x' },
|
||||
{ slug: 'm2', status: 'paused', next: 'do y' },
|
||||
],
|
||||
all_tasks_count: 3,
|
||||
raw: '...',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
});
|
||||
const tools = makeMetaTools({ paths });
|
||||
const r = await (tools.find((t) => t.name === 'meta.status')!.handler({}));
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.agenda_present).toBe(true);
|
||||
expect(parsed.agenda_tasks_active).toBe(2);
|
||||
expect(parsed.agenda_tasks_total).toBe(3);
|
||||
expect(parsed.agenda_source).toBe('local');
|
||||
expect(parsed.agenda_branch).toBe('local');
|
||||
});
|
||||
|
||||
it('reports agenda_source=gitea when agenda default_branch is a real branch', async () => {
|
||||
await writeCache(paths.cacheFile, {
|
||||
schemaVersion: 3,
|
||||
synced_at: new Date().toISOString(),
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{
|
||||
name: 'agenda',
|
||||
default_branch: 'main',
|
||||
fetched_at: new Date().toISOString(),
|
||||
active_tasks: [{ slug: 'g1', status: 'active', next: 'x' }],
|
||||
all_tasks_count: 1,
|
||||
raw: '...',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
});
|
||||
const tools = makeMetaTools({ paths });
|
||||
const r = await (tools.find((t) => t.name === 'meta.status')!.handler({}));
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.agenda_present).toBe(true);
|
||||
expect(parsed.agenda_source).toBe('gitea');
|
||||
expect(parsed.agenda_branch).toBe('main');
|
||||
});
|
||||
|
||||
it('reports agenda_source=none when agenda absent', async () => {
|
||||
await writeCache(paths.cacheFile, {
|
||||
schemaVersion: 3,
|
||||
synced_at: new Date().toISOString(),
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [{ name: 'p', default_branch: 'main', fetched_at: new Date().toISOString(), active_tasks: [], all_tasks_count: 0, raw: '' }],
|
||||
errors: [],
|
||||
});
|
||||
const tools = makeMetaTools({ paths });
|
||||
const r = await (tools.find((t) => t.name === 'meta.status')!.handler({}));
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.agenda_present).toBe(false);
|
||||
expect(parsed.agenda_source).toBe('none');
|
||||
expect(parsed.agenda_branch).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
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: [],
|
||||
};
|
||||
|
||||
let cacheFile: string;
|
||||
const fixedNow = () => new Date('2026-06-08T10:00:00.000Z');
|
||||
|
||||
beforeEach(async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'trail-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: 'cache key includes locale?',
|
||||
decided_by: 'arbiter',
|
||||
ruling: 'include locale',
|
||||
rationale: 'avoids cross-locale bleed',
|
||||
blast_radius: 'reversible',
|
||||
escalation_chain: ['brief'],
|
||||
};
|
||||
|
||||
describe('tasks.append_decision_trail', () => {
|
||||
it('is registered', () => {
|
||||
const { backend } = recordingBackend();
|
||||
const tools = makeTools(backend);
|
||||
expect(tools.find((t) => t.name === 'tasks.append_decision_trail')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('commits straight (no confirm gate — infra append) and targets .tasks/<slug>.md', async () => {
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.tasks/my-task.md': { content: '# my-task\n\n## Goal\nx\n', sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.append_decision_trail', base);
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
|
||||
expect(parsed.committed).toBe(true);
|
||||
expect(commits).toHaveLength(1);
|
||||
expect(commits[0].path).toBe('.tasks/my-task.md');
|
||||
expect(commits[0].sha).toBe('old'); // updated existing file via sha lock
|
||||
expect(commits[0].content).toContain('## Decision trail');
|
||||
expect(commits[0].content).toContain('### consult 1 —');
|
||||
});
|
||||
|
||||
it('returns a trail_ref pointer the worker can be handed', async () => {
|
||||
const { backend } = recordingBackend({
|
||||
'alpha:.tasks/my-task.md': { content: '# my-task\n', sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.append_decision_trail', base);
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(typeof parsed.trail_ref).toBe('string');
|
||||
expect(parsed.trail_ref).toContain('my-task');
|
||||
});
|
||||
|
||||
it('creates the task file when it does not exist yet (no sha)', async () => {
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.append_decision_trail', base);
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.committed).toBe(true);
|
||||
expect(commits[0].sha).toBeUndefined(); // create, not update
|
||||
expect(commits[0].content).toContain('# my-task');
|
||||
expect(commits[0].content).toContain('### consult 1 —');
|
||||
});
|
||||
|
||||
it('writes every contract field', async () => {
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeTools(backend);
|
||||
await call(tools, 'tasks.append_decision_trail', base);
|
||||
const c = commits[0].content;
|
||||
expect(c).toContain('- question: cache key includes locale?');
|
||||
expect(c).toContain('- blast_radius: reversible');
|
||||
expect(c).toContain('- decided_by: arbiter');
|
||||
expect(c).toContain('- ruling: include locale');
|
||||
expect(c).toContain('- rationale: avoids cross-locale bleed');
|
||||
expect(c).toContain('- escalation_chain: brief');
|
||||
});
|
||||
|
||||
it('numbers a second consult as 2', async () => {
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeTools(backend);
|
||||
await call(tools, 'tasks.append_decision_trail', base);
|
||||
await call(tools, 'tasks.append_decision_trail', { ...base, question: 'second?' });
|
||||
expect(commits[1].content).toContain('### consult 2 —');
|
||||
});
|
||||
|
||||
it('logs a halt outcome (decided_by human-required, no ruling)', async () => {
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeTools(backend);
|
||||
await call(tools, 'tasks.append_decision_trail', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'my-task',
|
||||
question: 'priorities?',
|
||||
decided_by: 'human-required',
|
||||
rationale: 'escalated: arbiter declared a fork',
|
||||
escalation_chain: ['brief', 'arbiter-fork'],
|
||||
});
|
||||
expect(commits[0].content).toContain('- decided_by: human-required');
|
||||
expect(commits[0].content).toContain('- ruling: —');
|
||||
});
|
||||
|
||||
it('is disabled without a backend', async () => {
|
||||
const tools = makeTasksTools({ cacheFile });
|
||||
const r = await call(tools, 'tasks.append_decision_trail', base);
|
||||
expect(r.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
373
lib/projects-meta-mcp/tests/tools/tasks-cli.test.ts
Normal file
373
lib/projects-meta-mcp/tests/tools/tasks-cli.test.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runTasksCli } from '../../src/tasks-cli.js';
|
||||
import type { ToolDef, ToolResult } from '../../src/tools/types.js';
|
||||
|
||||
function fakeTool(
|
||||
name: string,
|
||||
impl: (args: Record<string, unknown>) => ToolResult,
|
||||
): ToolDef {
|
||||
return {
|
||||
name,
|
||||
description: '',
|
||||
inputSchema: { type: 'object' },
|
||||
handler: async (args) => impl(args),
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResult(value: unknown): ToolResult {
|
||||
return { content: [{ type: 'text', text: JSON.stringify(value) }] };
|
||||
}
|
||||
|
||||
describe('runTasksCli', () => {
|
||||
it('routes claim-next to tasks.claim_next with parsed flags and prints the handler JSON', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.claim_next', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ ok: true, claimed: true, slug: 'foo' });
|
||||
}),
|
||||
];
|
||||
const out: string[] = [];
|
||||
|
||||
const code = await runTasksCli(
|
||||
[
|
||||
'claim-next',
|
||||
'--claimer-identity',
|
||||
'host:claude-opus:123',
|
||||
'--runtime',
|
||||
'claude-opus',
|
||||
'--requirements',
|
||||
'needs-db,needs-internet',
|
||||
'--confirm',
|
||||
],
|
||||
{ tools, log: (s) => out.push(s) },
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
claimer_identity: 'host:claude-opus:123',
|
||||
confirm: true,
|
||||
filter: { runtime: 'claude-opus', requirements: ['needs-db', 'needs-internet'] },
|
||||
});
|
||||
expect(JSON.parse(out.join('\n'))).toEqual({ ok: true, claimed: true, slug: 'foo' });
|
||||
});
|
||||
|
||||
it('routes append-decision-trail to tasks.append_decision_trail, splitting the chain', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.append_decision_trail', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true, trail_ref: 'x' });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
[
|
||||
'append-decision-trail',
|
||||
'--target-project',
|
||||
'OpeItcLoc03/alpha',
|
||||
'--slug',
|
||||
'my-task',
|
||||
'--question',
|
||||
'cache key includes locale?',
|
||||
'--decided-by',
|
||||
'arbiter',
|
||||
'--blast-radius',
|
||||
'reversible',
|
||||
'--ruling',
|
||||
'include locale',
|
||||
'--rationale',
|
||||
'avoids bleed',
|
||||
'--escalation-chain',
|
||||
'brief,read-repo',
|
||||
],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'my-task',
|
||||
question: 'cache key includes locale?',
|
||||
decided_by: 'arbiter',
|
||||
blast_radius: 'reversible',
|
||||
ruling: 'include locale',
|
||||
rationale: 'avoids bleed',
|
||||
escalation_chain: ['brief', 'read-repo'],
|
||||
});
|
||||
});
|
||||
|
||||
it('omits ruling on a halt trail entry', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.append_decision_trail', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
await runTasksCli(
|
||||
[
|
||||
'append-decision-trail',
|
||||
'--target-project',
|
||||
'OpeItcLoc03/alpha',
|
||||
'--slug',
|
||||
'my-task',
|
||||
'--question',
|
||||
'priorities?',
|
||||
'--decided-by',
|
||||
'human-required',
|
||||
'--escalation-chain',
|
||||
'brief,arbiter-fork',
|
||||
],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(received).not.toHaveProperty('ruling');
|
||||
expect(received!.decided_by).toBe('human-required');
|
||||
});
|
||||
|
||||
it('routes park-question to tasks.park_question with question + optional claim_token', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.park_question', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ parked: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
[
|
||||
'park-question',
|
||||
'--target-project',
|
||||
'OpeItcLoc03/alpha',
|
||||
'--slug',
|
||||
'my-task',
|
||||
'--question',
|
||||
'drop the legacy column?',
|
||||
'--claim-token',
|
||||
'tok-123',
|
||||
],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'my-task',
|
||||
question: 'drop the legacy column?',
|
||||
claim_token: 'tok-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes get-status to tasks.get_status', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.get_status', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ found: true, status: 'blocked' });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['get-status', '--target-project', 'OpeItcLoc03/alpha', '--slug', 'my-task'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({ target_project: 'OpeItcLoc03/alpha', slug: 'my-task' });
|
||||
});
|
||||
|
||||
it('routes heartbeat to tasks.heartbeat with slug + claim_token (+ optional target_project)', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.heartbeat', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ ok: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['heartbeat', '--slug', 'foo', '--claim-token', 'tok-1', '--target-project', 'victor/books'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({ slug: 'foo', claim_token: 'tok-1', target_project: 'victor/books' });
|
||||
});
|
||||
|
||||
it('routes close to tasks.close with target_project + slug + note + confirm', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.close', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['close', '--target-project', 'victor/books', '--slug', 'foo', '--note', 'done via poller', '--confirm'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'victor/books',
|
||||
slug: 'foo',
|
||||
note: 'done via poller',
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('routes update to tasks.update with target_project + slug + status + where_stopped', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.update', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
[
|
||||
'update',
|
||||
'--target-project',
|
||||
'victor/books',
|
||||
'--slug',
|
||||
'foo',
|
||||
'--status',
|
||||
'blocked',
|
||||
'--where-stopped',
|
||||
'spawn exited 1: boom',
|
||||
'--confirm',
|
||||
],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'victor/books',
|
||||
slug: 'foo',
|
||||
status: 'blocked',
|
||||
where_stopped: 'spawn exited 1: boom',
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes an explicit empty --blocker= through as blocker:"" (clear the blocker)', async () => {
|
||||
// The ops watchdog resets a transient-blocked task with `--status=ready
|
||||
// --blocker=`; the empty value must reach the handler so updateTaskFields
|
||||
// strips the stale **Blocker:** line, not be dropped as a falsy flag.
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.update', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['update', '--target-project=victor/books', '--slug=foo', '--status=ready', '--blocker=', '--confirm'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'victor/books',
|
||||
slug: 'foo',
|
||||
status: 'ready',
|
||||
blocker: '',
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('omits blocker entirely when --blocker is absent', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.update', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['update', '--target-project=victor/books', '--slug=foo', '--status=ready', '--confirm'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).not.toHaveProperty('blocker');
|
||||
});
|
||||
|
||||
it('returns exit code 1 when the handler result isError', async () => {
|
||||
const tools = [
|
||||
fakeTool('tasks.claim_next', () => ({
|
||||
content: [{ type: 'text', text: 'cache missing — run sync first' }],
|
||||
isError: true,
|
||||
})),
|
||||
];
|
||||
const out: string[] = [];
|
||||
const code = await runTasksCli(['claim-next', '--claimer-identity', 'h:r:1'], {
|
||||
tools,
|
||||
log: (s) => out.push(s),
|
||||
});
|
||||
expect(code).toBe(1);
|
||||
expect(out.join('\n')).toContain('cache missing');
|
||||
});
|
||||
|
||||
it('returns exit code 2 and usage on an unknown command', async () => {
|
||||
const err: string[] = [];
|
||||
const code = await runTasksCli(['frobnicate'], { tools: [], errLog: (s) => err.push(s) });
|
||||
expect(code).toBe(2);
|
||||
expect(err.join('\n')).toContain('unknown command: frobnicate');
|
||||
});
|
||||
|
||||
it('a throwing handler (e.g. zod parse on a missing required flag) → JSON + non-zero, not an uncaught throw', async () => {
|
||||
const tools = [
|
||||
fakeTool('tasks.claim_next', () => {
|
||||
throw new Error('claimer_identity: Required');
|
||||
}),
|
||||
];
|
||||
const out: string[] = [];
|
||||
const code = await runTasksCli(['claim-next'], { tools, log: (s) => out.push(s) });
|
||||
expect(code).not.toBe(0);
|
||||
const parsed = JSON.parse(out.join('\n'));
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error).toContain('claimer_identity');
|
||||
});
|
||||
|
||||
it('--flag=value form: a value beginning with -- survives (blocked-path where_stopped)', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.update', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['update', '--target-project=victor/books', '--slug=foo', '--status=blocked', '--where-stopped=--boom: bad flag', '--confirm'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'victor/books',
|
||||
slug: 'foo',
|
||||
status: 'blocked',
|
||||
where_stopped: '--boom: bad flag',
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('routes list-blocked to tasks.list_blocked with optional project_allowlist', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.list_blocked', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ ok: true, tasks: [] });
|
||||
}),
|
||||
];
|
||||
const out: string[] = [];
|
||||
const code = await runTasksCli(
|
||||
['list-blocked', '--projects', 'OpeItcLoc03/alpha,OpeItcLoc03/beta'],
|
||||
{ tools, log: (s) => out.push(s) },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({ project_allowlist: ['OpeItcLoc03/alpha', 'OpeItcLoc03/beta'] });
|
||||
expect(JSON.parse(out.join('\n'))).toEqual({ ok: true, tasks: [] });
|
||||
});
|
||||
|
||||
it('routes list-blocked without --projects (no allowlist)', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.list_blocked', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ ok: true, tasks: [] });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(['list-blocked'], { tools, log: () => {} });
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({});
|
||||
});
|
||||
});
|
||||
102
lib/projects-meta-mcp/tests/tools/tasks-get-status.test.ts
Normal file
102
lib/projects-meta-mcp/tests/tools/tasks-get-status.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
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 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> {
|
||||
return { fileSha: 'x', commitSha: 'y' };
|
||||
},
|
||||
};
|
||||
return { backend, 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 BOARD =
|
||||
'# Task Board\n\n## 🔵 [parked-task] — x\n\n**Status:** blocked\n**Branch:** master\n\n---\n' +
|
||||
'## 🔴 [live-task] — y\n\n**Status:** active\n**Branch:** master\n\n---\n';
|
||||
|
||||
let cacheFile: string;
|
||||
beforeEach(async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'getstatus-'));
|
||||
cacheFile = join(dir, 'tasks.json');
|
||||
await writeCache(cacheFile, fixture);
|
||||
});
|
||||
|
||||
function makeTools(backend: Backend) {
|
||||
return makeTasksTools({ cacheFile, backend, giteaUser: 'u', agendaTasksRepo: 'projects-tasks' });
|
||||
}
|
||||
function call(tools: ReturnType<typeof makeTasksTools>, name: string, args: Record<string, unknown>) {
|
||||
const t = tools.find((x) => x.name === name)!;
|
||||
return t.handler(args);
|
||||
}
|
||||
|
||||
describe('tasks.get_status', () => {
|
||||
it('returns the LIVE status of a task (reads the board, not the cache)', async () => {
|
||||
const { backend } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: BOARD, sha: 's' } });
|
||||
const r = await call(makeTools(backend), 'tasks.get_status', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'parked-task',
|
||||
});
|
||||
expect(JSON.parse(r.content[0].text).status).toBe('blocked');
|
||||
});
|
||||
|
||||
it('distinguishes an active task', async () => {
|
||||
const { backend } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: BOARD, sha: 's' } });
|
||||
const r = await call(makeTools(backend), 'tasks.get_status', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'live-task',
|
||||
});
|
||||
expect(JSON.parse(r.content[0].text).status).toBe('active');
|
||||
});
|
||||
|
||||
it('reports not-found for an absent slug', async () => {
|
||||
const { backend } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: BOARD, sha: 's' } });
|
||||
const r = await call(makeTools(backend), 'tasks.get_status', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'ghost',
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.status).toBeNull();
|
||||
expect(parsed.found).toBe(false);
|
||||
});
|
||||
|
||||
it('is disabled without a backend', async () => {
|
||||
const r = await call(makeTasksTools({ cacheFile }), 'tasks.get_status', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'live-task',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
197
lib/projects-meta-mcp/tests/tools/tasks-list-blocked.test.ts
Normal file
197
lib/projects-meta-mcp/tests/tools/tasks-list-blocked.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
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, FileWithSha } from '../../src/lib/backend.js';
|
||||
|
||||
function recordingBackend(initial: Record<string, FileWithSha> = {}) {
|
||||
const files = { ...initial };
|
||||
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() { return { fileSha: 'x', commitSha: 'y' }; },
|
||||
};
|
||||
return { backend, files };
|
||||
}
|
||||
|
||||
const ALPHA_BOARD = `# Task Board
|
||||
_Updated: 2026-06-09_
|
||||
|
||||
## 🔵 [blocked-cheap] — blocked cheap task
|
||||
**Status:** blocked
|
||||
**Where I stopped:** dep pending
|
||||
**Next action:** wait
|
||||
**Blocker:** dep-a, dep-b
|
||||
**Branch:** master
|
||||
**Weight:** cheap-ok
|
||||
|
||||
---
|
||||
|
||||
## 🔵 [blocked-claude] — blocked claude task
|
||||
**Status:** blocked
|
||||
**Where I stopped:** dep pending
|
||||
**Next action:** wait
|
||||
**Blocker:** dep-c
|
||||
**Branch:** master
|
||||
**Weight:** needs-claude
|
||||
|
||||
---
|
||||
|
||||
## 🔵 [blocked-human] — blocked human task (should be skipped)
|
||||
**Status:** blocked
|
||||
**Where I stopped:** dep pending
|
||||
**Next action:** wait
|
||||
**Blocker:** dep-d
|
||||
**Branch:** master
|
||||
**Weight:** needs-human
|
||||
|
||||
---
|
||||
|
||||
## 🔵 [blocked-noweight] — blocked no-weight task (should be skipped)
|
||||
**Status:** blocked
|
||||
**Where I stopped:** dep pending
|
||||
**Next action:** wait
|
||||
**Blocker:** dep-e
|
||||
**Branch:** master
|
||||
|
||||
---
|
||||
|
||||
## ⚪ [ready-task] — not blocked, should not appear
|
||||
**Status:** ready
|
||||
**Next action:** go
|
||||
**Branch:** master
|
||||
|
||||
---
|
||||
`;
|
||||
|
||||
const BETA_BOARD = `# Task Board
|
||||
_Updated: 2026-06-09_
|
||||
|
||||
## 🔵 [blocked-beta] — blocked beta task
|
||||
**Status:** blocked
|
||||
**Where I stopped:** waiting
|
||||
**Next action:** wait
|
||||
**Blocker:** dep-x
|
||||
**Branch:** master
|
||||
**Weight:** needs-claude
|
||||
|
||||
---
|
||||
`;
|
||||
|
||||
const fixture: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-06-09T12:00:00.000Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{
|
||||
name: 'OpeItcLoc03/alpha',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-06-09T12:00:00.000Z',
|
||||
active_tasks: [
|
||||
{ slug: 'blocked-cheap', status: 'blocked', next: 'wait' },
|
||||
{ slug: 'blocked-claude', status: 'blocked', next: 'wait' },
|
||||
{ slug: 'blocked-human', status: 'blocked', next: 'wait' },
|
||||
{ slug: 'blocked-noweight', status: 'blocked', next: 'wait' },
|
||||
],
|
||||
all_tasks_count: 5,
|
||||
raw: '',
|
||||
},
|
||||
{
|
||||
name: 'OpeItcLoc03/beta',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-06-09T12:00:00.000Z',
|
||||
active_tasks: [
|
||||
{ slug: 'blocked-beta', status: 'blocked', next: 'wait' },
|
||||
],
|
||||
all_tasks_count: 1,
|
||||
raw: '',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
let cacheFile: string;
|
||||
beforeEach(async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'listblocked-'));
|
||||
cacheFile = join(dir, 'tasks.json');
|
||||
await writeCache(cacheFile, fixture);
|
||||
});
|
||||
|
||||
function makeTools(backend: Backend) {
|
||||
return makeTasksTools({ cacheFile, backend, giteaUser: 'u', agendaTasksRepo: 'projects-tasks' });
|
||||
}
|
||||
function call(tools: ReturnType<typeof makeTasksTools>, name: string, args: Record<string, unknown>) {
|
||||
const t = tools.find((x) => x.name === name)!;
|
||||
return t.handler(args);
|
||||
}
|
||||
|
||||
describe('tasks.list_blocked', () => {
|
||||
it('returns blocked tasks with weight needs-claude or cheap-ok, skips needs-human and no-weight', async () => {
|
||||
const { backend } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ALPHA_BOARD, sha: 's' },
|
||||
'beta:.tasks/STATUS.md': { content: BETA_BOARD, sha: 's' },
|
||||
});
|
||||
const r = await call(makeTools(backend), 'tasks.list_blocked', {});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.ok).toBe(true);
|
||||
const slugs = parsed.tasks.map((t: { slug: string }) => t.slug).sort();
|
||||
expect(slugs).toEqual(['blocked-beta', 'blocked-cheap', 'blocked-claude']);
|
||||
});
|
||||
|
||||
it('includes blocker and weight fields', async () => {
|
||||
const { backend } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ALPHA_BOARD, sha: 's' },
|
||||
'beta:.tasks/STATUS.md': { content: BETA_BOARD, sha: 's' },
|
||||
});
|
||||
const r = await call(makeTools(backend), 'tasks.list_blocked', {});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
const cheap = parsed.tasks.find((t: { slug: string }) => t.slug === 'blocked-cheap');
|
||||
expect(cheap.blocker).toBe('dep-a, dep-b');
|
||||
expect(cheap.weight).toBe('cheap-ok');
|
||||
expect(cheap.project).toBe('OpeItcLoc03/alpha');
|
||||
const claude = parsed.tasks.find((t: { slug: string }) => t.slug === 'blocked-claude');
|
||||
expect(claude.blocker).toBe('dep-c');
|
||||
expect(claude.weight).toBe('needs-claude');
|
||||
});
|
||||
|
||||
it('respects project_allowlist — only returns tasks from listed projects', async () => {
|
||||
const { backend } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ALPHA_BOARD, sha: 's' },
|
||||
'beta:.tasks/STATUS.md': { content: BETA_BOARD, sha: 's' },
|
||||
});
|
||||
const r = await call(makeTools(backend), 'tasks.list_blocked', {
|
||||
project_allowlist: ['OpeItcLoc03/beta'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.tasks.every((t: { project: string }) => t.project === 'OpeItcLoc03/beta')).toBe(true);
|
||||
expect(parsed.tasks.map((t: { slug: string }) => t.slug)).toEqual(['blocked-beta']);
|
||||
});
|
||||
|
||||
it('returns empty tasks when cache is missing', async () => {
|
||||
const { backend } = recordingBackend({});
|
||||
const tools = makeTasksTools({ cacheFile: '/nonexistent/tasks.json', backend, giteaUser: 'u', agendaTasksRepo: 'projects-tasks' });
|
||||
const t = tools.find((x) => x.name === 'tasks.list_blocked')!;
|
||||
const r = await t.handler({});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.tasks).toEqual([]);
|
||||
expect(parsed.cache_missing).toBe(true);
|
||||
});
|
||||
|
||||
it('skips project when STATUS.md not found in backend (graceful degradation)', async () => {
|
||||
const { backend } = recordingBackend({
|
||||
'beta:.tasks/STATUS.md': { content: BETA_BOARD, sha: 's' },
|
||||
// alpha STATUS.md is missing
|
||||
});
|
||||
const r = await call(makeTools(backend), 'tasks.list_blocked', {});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.ok).toBe(true);
|
||||
const slugs = parsed.tasks.map((t: { slug: string }) => t.slug);
|
||||
expect(slugs).toContain('blocked-beta');
|
||||
expect(slugs).not.toContain('blocked-cheap');
|
||||
});
|
||||
});
|
||||
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');
|
||||
});
|
||||
});
|
||||
1473
lib/projects-meta-mcp/tests/tools/tasks.test.ts
Normal file
1473
lib/projects-meta-mcp/tests/tools/tasks.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
49
lib/projects-meta-mcp/tests/tools/workers.test.ts
Normal file
49
lib/projects-meta-mcp/tests/tools/workers.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { makeWorkersTools } from '../../src/tools/workers.js';
|
||||
|
||||
describe('worker.register (stub)', () => {
|
||||
const tool = makeWorkersTools().find((t) => t.name === 'worker.register')!;
|
||||
|
||||
it('exists and documents itself as a stub', () => {
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool.description.toLowerCase()).toContain('stub');
|
||||
});
|
||||
|
||||
it('returns a 501 not-implemented verdict for valid input', async () => {
|
||||
const r = await tool.handler({
|
||||
machine: 'win11',
|
||||
runtime: 'claude-opus',
|
||||
capabilities: ['needs-db'],
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
const body = JSON.parse(r.content[0].text);
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.reason).toBe('not-implemented');
|
||||
expect(typeof body.hint).toBe('string');
|
||||
expect(body.hint.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('keeps the verdict for optional endpoint + confirm (no side-effects)', async () => {
|
||||
const r = await tool.handler({
|
||||
machine: 'win11',
|
||||
runtime: 'claude-opus',
|
||||
capabilities: [],
|
||||
endpoint: 'https://node/agent',
|
||||
confirm: true,
|
||||
});
|
||||
const body = JSON.parse(r.content[0].text);
|
||||
expect(body.reason).toBe('not-implemented');
|
||||
});
|
||||
|
||||
it('rejects malformed input — missing machine', async () => {
|
||||
await expect(
|
||||
tool.handler({ runtime: 'claude-opus', capabilities: [] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects malformed input — capabilities not an array', async () => {
|
||||
await expect(
|
||||
tool.handler({ machine: 'm', runtime: 'r', capabilities: 'nope' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user