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 = {}) { 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, name: string, args: Record) { 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'); }); });