/** * Gitea read client. Config + injected fetch → typed, normalized domain * objects with scheduler facts precomputed. `getIssue` proved the vertical * slice (P1-1); `listIssues`/`listMilestones` are the reconcile reads (P1-4) * layered on the same `request` seam. Pagination is handled here so callers * get the full set. */ import { type LifecycleEvent, type LifecycleEventType } from '../lifecycle/lifecycle-v0.js' import { extractLabelFacts } from '../labels/label-schema.js' import { GiteaApiError, type FetchLike, type GiteaConfig, type GiteaIssue, type GiteaLabel, type GiteaMilestone, 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 /** Present (non-null) when the row is actually a pull request. */ pull_request?: unknown } interface RawMilestoneFull { id: number title: string description: string | null due_on: string | null state: string open_issues: number closed_issues: number } /** The subset of a gitea timeline comment we read. */ interface RawTimelineComment { type: string created_at: string } /** gitea timeline `type` → our lifecycle signal. Unmapped types are dropped. */ const TIMELINE_TYPE_MAP: Record = { label: 'triage', milestone: 'triage', assignees: 'triage', commit_ref: 'commit', pull_ref: 'pull', close: 'close', reopen: 'reopen', } /** Map a raw gitea timeline to normalized lifecycle events. Pure. */ export function normalizeTimeline(raw: RawTimelineComment[]): LifecycleEvent[] { const out: LifecycleEvent[] = [] for (const c of raw) { const type = TIMELINE_TYPE_MAP[c.type] if (type && c.created_at) out.push({ type, at: c.created_at }) } return out } export interface ListIssuesOptions { /** @default 'all' */ state?: 'open' | 'closed' | 'all' } export interface GiteaClient { /** Fetch one issue by its per-repo index, normalized. */ getIssue(index: number): Promise /** Fetch every issue (all pages), pull requests excluded. */ listIssues(opts?: ListIssuesOptions): Promise /** Fetch every milestone (all pages). */ listMilestones(): Promise /** The issue indices this issue depends on (its blockers). */ getIssueDependencies(index: number): Promise /** Normalized lifecycle events for one issue (all pages of its timeline). */ getIssueTimeline(index: number): Promise /** Every label defined on the repo (id + name), for name→id resolution. */ listLabels(): Promise /** Replace an issue's entire label set with the given label ids. Write. */ setIssueLabels(index: number, labelIds: number[]): Promise /** Open a new issue with a title, optional body, and label ids. Write. */ createIssue(input: { title: string; body?: string; labelIds?: number[] }): Promise /** Read a repo file's base64 content + blob sha; null if it (or the repo) is absent. */ getFile(path: string): Promise<{ contentBase64: string; sha: string } | null> /** Create or update a repo file with base64 content (pass `sha` to update). Write. */ putFile(path: string, input: { contentBase64: string; message: string; sha?: string }): 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, } } /** Map a raw milestone resource to the normalized shape. Pure. */ export function normalizeMilestone(raw: RawMilestoneFull): GiteaMilestone { return { id: raw.id, title: raw.title, description: raw.description ?? '', dueOn: raw.due_on, state: raw.state === 'closed' ? 'closed' : 'open', openIssues: raw.open_issues, closedIssues: raw.closed_issues, } } const PAGE_LIMIT = 50 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, init?: { method?: string; body?: unknown }): Promise { const method = init?.method ?? 'GET' const hasBody = init?.body !== undefined const res = await fetchImpl(`${repoBase}${path}`, { method, headers: { Authorization: `token ${config.token}`, Accept: 'application/json', ...(hasBody ? { 'Content-Type': 'application/json' } : {}), }, body: hasBody ? JSON.stringify(init!.body) : undefined, }) if (!res.ok) { const body = await res.text().catch(() => '') throw new GiteaApiError(res.status, `${method} ${path} failed (${res.status})`, body) } // writes may reply 204 No Content return res.status === 204 ? null : res.json() } /** Follow gitea's page-limit pagination until a short page is returned. */ async function requestAll(build: (page: number) => string): Promise { const out: T[] = [] for (let page = 1; ; page++) { const batch = (await request(build(page))) as T[] out.push(...batch) if (batch.length < PAGE_LIMIT) return out } } return { async getIssue(index) { return normalizeIssue((await request(`/issues/${index}`)) as RawIssue) }, async listIssues(opts) { const state = opts?.state ?? 'all' const raw = await requestAll( (page) => `/issues?type=issues&state=${state}&page=${page}&limit=${PAGE_LIMIT}`, ) // `type=issues` should exclude PRs, but guard anyway. return raw.filter((r) => r.pull_request == null).map(normalizeIssue) }, async listMilestones() { const raw = await requestAll( (page) => `/milestones?state=all&page=${page}&limit=${PAGE_LIMIT}`, ) return raw.map(normalizeMilestone) }, async getIssueDependencies(index) { const raw = (await request(`/issues/${index}/dependencies`)) as { number: number }[] return raw.map((d) => d.number) }, async getIssueTimeline(index) { const raw = await requestAll( (page) => `/issues/${index}/timeline?page=${page}&limit=${PAGE_LIMIT}`, ) return normalizeTimeline(raw) }, async listLabels() { const raw = await requestAll<{ id: number; name: string }>( (page) => `/labels?page=${page}&limit=${PAGE_LIMIT}`, ) return raw.map((l) => ({ id: l.id, name: l.name })) }, async setIssueLabels(index, labelIds) { await request(`/issues/${index}/labels`, { method: 'PUT', body: { labels: labelIds } }) }, async createIssue(input) { const raw = (await request('/issues', { method: 'POST', body: { title: input.title, body: input.body ?? '', labels: input.labelIds ?? [] }, })) as RawIssue return normalizeIssue(raw) }, async getFile(path) { const res = await fetchImpl(`${repoBase}/contents/${path}`, { headers: { Authorization: `token ${config.token}`, Accept: 'application/json' }, }) if (res.status === 404) return null if (!res.ok) { const body = await res.text().catch(() => '') throw new GiteaApiError(res.status, `GET contents/${path} failed (${res.status})`, body) } const json = (await res.json()) as { content?: string; sha: string } return { contentBase64: (json.content ?? '').replace(/\n/g, ''), sha: json.sha } }, async putFile(path, input) { await request(`/contents/${path}`, { method: input.sha ? 'PUT' : 'POST', body: { content: input.contentBase64, message: input.message, ...(input.sha ? { sha: input.sha } : {}) }, }) }, } }