feat(core): gitea read client behind an injected fetch (#1)
createGiteaClient(config, fetchImpl).getIssue(index) fetches one issue and normalizes raw gitea JSON to a typed GiteaIssue with scheduler facts precomputed via extractLabelFacts. Network is an injected FetchLike (core has no DOM lib; global fetch is structurally assignable), so it unit-tests against a stub — no live calls in the suite. Non-2xx responses throw GiteaApiError carrying status + body. Closes P1-1. Verified end-to-end against the live repo's issue #9. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
97
packages/core/src/gitea/client.ts
Normal file
97
packages/core/src/gitea/client.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 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<GiteaIssue>
|
||||
}
|
||||
|
||||
/** 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<unknown> {
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user