/** * Gitea read client. One method for now — `getIssue` — proving the vertical * slice end to end: config + injected fetch → typed, normalized `GiteaIssue` * with scheduler facts precomputed. Later reconcile work (P1-4) layers list * reads on the same `request` seam. */ import { extractLabelFacts } from '../labels/label-schema.js' import { GiteaApiError, type FetchLike, type GiteaConfig, type GiteaIssue, type GiteaMilestoneRef, } from './types.js' /** The subset of gitea's raw issue JSON we read. */ interface RawLabel { name: string } interface RawMilestone { id: number title: string due_on: string | null } interface RawUser { login: string } interface RawIssue { number: number title: string body: string | null state: string labels: RawLabel[] | null milestone: RawMilestone | null assignee: RawUser | null assignees: RawUser[] | null created_at: string updated_at: string closed_at: string | null html_url: string } export interface GiteaClient { /** Fetch one issue by its per-repo index, normalized. */ getIssue(index: number): Promise } /** Map raw gitea issue JSON to the normalized domain shape. Pure. */ export function normalizeIssue(raw: RawIssue): GiteaIssue { const labels = (raw.labels ?? []).map((l) => l.name) const milestone: GiteaMilestoneRef | null = raw.milestone ? { id: raw.milestone.id, title: raw.milestone.title, dueOn: raw.milestone.due_on } : null const assignees = (raw.assignees ?? []).map((u) => u.login) return { number: raw.number, title: raw.title, body: raw.body ?? '', state: raw.state === 'closed' ? 'closed' : 'open', labels, facts: extractLabelFacts(labels), milestone, assignee: raw.assignee?.login ?? null, assignees, createdAt: raw.created_at, updatedAt: raw.updated_at, closedAt: raw.closed_at, url: raw.html_url, } } export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): GiteaClient { const apiBase = `${config.baseUrl.replace(/\/+$/, '')}/api/v1` const repoBase = `${apiBase}/repos/${config.owner}/${config.repo}` async function request(path: string): Promise { const res = await fetchImpl(`${repoBase}${path}`, { headers: { Authorization: `token ${config.token}`, Accept: 'application/json', }, }) if (!res.ok) { const body = await res.text().catch(() => '') throw new GiteaApiError(res.status, `GET ${path} failed (${res.status})`, body) } return res.json() } return { async getIssue(index) { return normalizeIssue((await request(`/issues/${index}`)) as RawIssue) }, } }