feat(reader): Gitea client + config + board orchestrator

closes board-viewer-gitea-reader

- src/gitea.ts: HTTP client (getFile, getLatestCommitIso, rawUrl); DI'd fetch
- src/reader.ts: readBoard(client, repos) → TaskRecord[]
- src/config.ts: TOML config loader with board_viewer_repos whitelist
- 25 tests total (9 parser incl. 3 real-repo fixtures, 7 gitea, 5 reader, 4 config)
- emoji contract 🔴🟡🔵🟢 (open/in_progress/paused/blocked/done)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
vitya
2026-05-22 12:48:37 +03:00
parent 1c7e372abf
commit d093693795
14 changed files with 2543 additions and 15 deletions

50
src/config.ts Normal file
View File

@@ -0,0 +1,50 @@
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { parse as parseToml } from 'smol-toml';
import type { RepoRef } from './reader.ts';
export interface BoardConfig {
baseUrl: string;
token: string;
repos: RepoRef[];
}
export function defaultConfigPath(): string {
return join(homedir(), '.config', 'projects-mcp', 'auth.toml');
}
export function loadConfig(path: string = defaultConfigPath()): BoardConfig {
const raw = readFileSync(path, 'utf8');
const data = parseToml(raw) as Record<string, unknown>;
const baseUrl = stringField(data, 'gitea_url');
const token = stringField(data, 'gitea_token');
const repos = parseRepos(data['board_viewer_repos']);
return { baseUrl, token, repos };
}
function stringField(data: Record<string, unknown>, key: string): string {
const value = data[key];
if (typeof value !== 'string' || value.length === 0) {
throw new Error(`config: missing or empty string field "${key}"`);
}
return value;
}
function parseRepos(value: unknown): RepoRef[] {
if (!Array.isArray(value)) {
throw new Error('config: missing or invalid "board_viewer_repos" (expected array of "owner/repo")');
}
return value.map((entry) => {
if (typeof entry !== 'string') {
throw new Error(`config: board_viewer_repos entry must be string, got ${typeof entry}`);
}
const parts = entry.split('/');
if (parts.length !== 2 || parts[0] === '' || parts[1] === '') {
throw new Error(`config: board_viewer_repos entry "${entry}" is not "owner/repo"`);
}
return { owner: parts[0]!, repo: parts[1]! };
});
}