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:
50
src/config.ts
Normal file
50
src/config.ts
Normal 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]! };
|
||||
});
|
||||
}
|
||||
58
src/gitea.ts
Normal file
58
src/gitea.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export interface GiteaClientOptions {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
fetch?: typeof fetch;
|
||||
defaultBranch?: string;
|
||||
}
|
||||
|
||||
export interface GiteaClient {
|
||||
getFile(owner: string, repo: string, path: string): Promise<string | null>;
|
||||
getLatestCommitIso(owner: string, repo: string, path: string): Promise<string | null>;
|
||||
rawUrl(owner: string, repo: string, path: string): string;
|
||||
}
|
||||
|
||||
interface ContentsResponse {
|
||||
content?: string;
|
||||
encoding?: string;
|
||||
}
|
||||
|
||||
interface CommitEntry {
|
||||
commit?: { author?: { date?: string } };
|
||||
}
|
||||
|
||||
export function createGiteaClient(opts: GiteaClientOptions): GiteaClient {
|
||||
const fetchImpl = opts.fetch ?? fetch;
|
||||
const base = opts.baseUrl.replace(/\/+$/, '');
|
||||
const branch = opts.defaultBranch ?? 'master';
|
||||
const headers = { Authorization: `token ${opts.token}` };
|
||||
|
||||
async function getFile(owner: string, repo: string, path: string): Promise<string | null> {
|
||||
const url = `${base}/api/v1/repos/${owner}/${repo}/contents/${path}`;
|
||||
const res = await fetchImpl(url, { headers });
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`Gitea getFile ${owner}/${repo}/${path}: HTTP ${res.status}`);
|
||||
const body = (await res.json()) as ContentsResponse;
|
||||
if (body.encoding !== 'base64' || typeof body.content !== 'string') {
|
||||
throw new Error(`Gitea getFile ${owner}/${repo}/${path}: unexpected encoding ${body.encoding}`);
|
||||
}
|
||||
return Buffer.from(body.content, 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
async function getLatestCommitIso(
|
||||
owner: string,
|
||||
repo: string,
|
||||
path: string,
|
||||
): Promise<string | null> {
|
||||
const url = `${base}/api/v1/repos/${owner}/${repo}/commits?path=${encodeURIComponent(path)}&limit=1`;
|
||||
const res = await fetchImpl(url, { headers });
|
||||
if (!res.ok) throw new Error(`Gitea getLatestCommitIso ${owner}/${repo}/${path}: HTTP ${res.status}`);
|
||||
const body = (await res.json()) as CommitEntry[];
|
||||
return body[0]?.commit?.author?.date ?? null;
|
||||
}
|
||||
|
||||
function rawUrl(owner: string, repo: string, path: string): string {
|
||||
return `${base}/${owner}/${repo}/raw/branch/${branch}/${path}`;
|
||||
}
|
||||
|
||||
return { getFile, getLatestCommitIso, rawUrl };
|
||||
}
|
||||
56
src/reader.ts
Normal file
56
src/reader.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { GiteaClient } from './gitea.ts';
|
||||
import { parseStatus, type Status, type StatusEmoji } from './parser.ts';
|
||||
|
||||
export interface RepoRef {
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
export interface TaskRecord {
|
||||
slug: string;
|
||||
project: string;
|
||||
project_owner: string;
|
||||
status: Status;
|
||||
status_emoji: StatusEmoji;
|
||||
title: string;
|
||||
where_stopped: string | null;
|
||||
next_action: string | null;
|
||||
blocker: string | null;
|
||||
branch: string | null;
|
||||
last_commit_iso: string | null;
|
||||
raw_url: string;
|
||||
}
|
||||
|
||||
export async function readBoard(
|
||||
client: GiteaClient,
|
||||
repos: ReadonlyArray<RepoRef>,
|
||||
): Promise<TaskRecord[]> {
|
||||
const records: TaskRecord[] = [];
|
||||
|
||||
for (const { owner, repo } of repos) {
|
||||
const status = await client.getFile(owner, repo, '.tasks/STATUS.md');
|
||||
if (status === null) continue;
|
||||
|
||||
const blocks = parseStatus(status);
|
||||
for (const block of blocks) {
|
||||
const taskPath = `.tasks/${block.slug}.md`;
|
||||
const last_commit_iso = await client.getLatestCommitIso(owner, repo, taskPath);
|
||||
records.push({
|
||||
slug: block.slug,
|
||||
project: repo,
|
||||
project_owner: owner,
|
||||
status: block.status,
|
||||
status_emoji: block.status_emoji,
|
||||
title: block.title,
|
||||
where_stopped: block.where_stopped,
|
||||
next_action: block.next_action,
|
||||
blocker: block.blocker,
|
||||
branch: block.branch,
|
||||
last_commit_iso,
|
||||
raw_url: client.rawUrl(owner, repo, taskPath),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
Reference in New Issue
Block a user