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:
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user