diff --git a/packages/core/src/gitea/client.test.ts b/packages/core/src/gitea/client.test.ts new file mode 100644 index 0000000..5e02528 --- /dev/null +++ b/packages/core/src/gitea/client.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' + +import { createGiteaClient, normalizeIssue } from './client.js' +import { GiteaApiError, type FetchLike, type GiteaConfig, type GiteaRequestInit } from './types.js' + +const CONFIG: GiteaConfig = { + baseUrl: 'https://gitea.stephenmann.io', + token: 'tok_secret', + owner: 'christian', + repo: 'commitea', +} + +/** Raw gitea issue JSON as the API returns it. */ +const RAW_ISSUE = { + number: 9, + title: 'Deterministic scheduler', + body: 'topo order → dated plan', + state: 'open', + labels: [{ name: 'est/5d' }, { name: 'p/1' }, { name: 'deadline/hard' }], + milestone: { id: 7, title: 'P2 — Scheduler + Monte Carlo', due_on: '2026-09-01T00:00:00Z' }, + assignee: { login: 'christian' }, + assignees: [{ login: 'christian' }, { login: 'stephen' }], + created_at: '2026-07-08T00:00:00Z', + updated_at: '2026-07-08T01:00:00Z', + closed_at: null, + html_url: 'https://gitea.stephenmann.io/christian/commitea/issues/9', +} + +/** A stub fetch that records calls and returns a canned 200 JSON body. */ +function stubFetch(body: unknown, status = 200): { + fetch: FetchLike + calls: { url: string; init?: GiteaRequestInit }[] +} { + const calls: { url: string; init?: GiteaRequestInit }[] = [] + const fetch: FetchLike = (url, init) => { + calls.push({ url, init }) + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)), + }) + } + return { fetch, calls } +} + +describe('createGiteaClient.getIssue', () => { + it('returns a normalized, typed issue from canned JSON', async () => { + const { fetch } = stubFetch(RAW_ISSUE) + const issue = await createGiteaClient(CONFIG, fetch).getIssue(9) + + expect(issue.number).toBe(9) + expect(issue.title).toBe('Deterministic scheduler') + expect(issue.state).toBe('open') + expect(issue.labels).toEqual(['est/5d', 'p/1', 'deadline/hard']) + expect(issue.url).toBe('https://gitea.stephenmann.io/christian/commitea/issues/9') + }) + + it('precomputes scheduler facts via extractLabelFacts', async () => { + const { fetch } = stubFetch(RAW_ISSUE) + const issue = await createGiteaClient(CONFIG, fetch).getIssue(9) + + expect(issue.facts.estimateDays).toBe(5) + expect(issue.facts.priority).toBe(1) + expect(issue.facts.hardDeadline).toBe(true) + }) + + it('maps the milestone ref and assignees', async () => { + const { fetch } = stubFetch(RAW_ISSUE) + const issue = await createGiteaClient(CONFIG, fetch).getIssue(9) + + expect(issue.milestone).toEqual({ + id: 7, + title: 'P2 — Scheduler + Monte Carlo', + dueOn: '2026-09-01T00:00:00Z', + }) + expect(issue.assignee).toBe('christian') + expect(issue.assignees).toEqual(['christian', 'stephen']) + }) + + it('hits the configured repo path with the token auth header', async () => { + const { fetch, calls } = stubFetch(RAW_ISSUE) + await createGiteaClient(CONFIG, fetch).getIssue(9) + + expect(calls).toHaveLength(1) + expect(calls[0].url).toBe( + 'https://gitea.stephenmann.io/api/v1/repos/christian/commitea/issues/9', + ) + expect(calls[0].init?.headers?.Authorization).toBe('token tok_secret') + }) + + it('tolerates a trailing slash on baseUrl', async () => { + const { fetch, calls } = stubFetch(RAW_ISSUE) + await createGiteaClient({ ...CONFIG, baseUrl: 'https://gitea.stephenmann.io/' }, fetch).getIssue(9) + + expect(calls[0].url).toBe( + 'https://gitea.stephenmann.io/api/v1/repos/christian/commitea/issues/9', + ) + }) + + it('throws GiteaApiError carrying status + body on a non-2xx response', async () => { + const { fetch } = stubFetch('not found', 404) + const client = createGiteaClient(CONFIG, fetch) + + await expect(client.getIssue(999)).rejects.toBeInstanceOf(GiteaApiError) + await expect(client.getIssue(999)).rejects.toMatchObject({ status: 404, body: 'not found' }) + }) +}) + +describe('normalizeIssue', () => { + it('defaults missing labels/assignees/body to empty and maps a closed state', () => { + const issue = normalizeIssue({ + number: 1, + title: 'bare issue', + body: null, + state: 'closed', + labels: null, + milestone: null, + assignee: null, + assignees: null, + created_at: '2026-07-01T00:00:00Z', + updated_at: '2026-07-02T00:00:00Z', + closed_at: '2026-07-02T00:00:00Z', + html_url: 'https://gitea.stephenmann.io/christian/commitea/issues/1', + }) + + expect(issue.state).toBe('closed') + expect(issue.body).toBe('') + expect(issue.labels).toEqual([]) + expect(issue.assignee).toBeNull() + expect(issue.assignees).toEqual([]) + expect(issue.milestone).toBeNull() + expect(issue.facts.estimateDays).toBeNull() + expect(issue.closedAt).toBe('2026-07-02T00:00:00Z') + }) +}) diff --git a/packages/core/src/gitea/client.ts b/packages/core/src/gitea/client.ts new file mode 100644 index 0000000..02fe22c --- /dev/null +++ b/packages/core/src/gitea/client.ts @@ -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 +} + +/** 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) + }, + } +} diff --git a/packages/core/src/gitea/types.ts b/packages/core/src/gitea/types.ts new file mode 100644 index 0000000..aaa323d --- /dev/null +++ b/packages/core/src/gitea/types.ts @@ -0,0 +1,79 @@ +/** + * Types for the gitea read client. Deliberately minimal: `@commitea/core` is + * pure (no DOM lib, no Electron, no network), so we don't reach for the global + * `fetch`/`Response`/`RequestInit` DOM types — we define the small structural + * surface we actually use. The real `globalThis.fetch` is structurally + * assignable to `FetchLike`, so the desktop main process passes it verbatim + * while tests pass a stub. + */ + +import type { LabelFacts } from '../labels/label-schema.js' + +/** Connection config. `baseUrl` is the instance root (no `/api/v1`). */ +export interface GiteaConfig { + /** e.g. `https://gitea.stephenmann.io` — trailing slash tolerated. */ + baseUrl: string + /** Personal access token (scopes: issue/repository/user). */ + token: string + owner: string + repo: string +} + +export interface GiteaRequestInit { + method?: string + headers?: Record + body?: string +} + +/** The slice of a `fetch` Response we consume. */ +export interface GiteaHttpResponse { + ok: boolean + status: number + json(): Promise + text(): Promise +} + +export type FetchLike = (url: string, init?: GiteaRequestInit) => Promise + +/** Milestone as referenced from an issue (not the full milestone resource). */ +export interface GiteaMilestoneRef { + id: number + title: string + /** ISO date the milestone is due, or null. */ + dueOn: string | null +} + +/** + * Normalized issue — camelCase, label names flattened, scheduler-facing + * `facts` precomputed via `extractLabelFacts`. This is the domain shape the + * rest of CommiTea works with; raw gitea JSON never escapes this module. + */ +export interface GiteaIssue { + number: number + title: string + body: string + state: 'open' | 'closed' + labels: string[] + facts: LabelFacts + milestone: GiteaMilestoneRef | null + /** Primary assignee username, or null. */ + assignee: string | null + /** All assignee usernames (includes the primary). */ + assignees: string[] + createdAt: string + updatedAt: string + closedAt: string | null + url: string +} + +/** Thrown on a non-2xx gitea response; carries the status + raw body. */ +export class GiteaApiError extends Error { + constructor( + readonly status: number, + message: string, + readonly body?: string, + ) { + super(message) + this.name = 'GiteaApiError' + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 82f0f7a..388ae80 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,3 +8,15 @@ export { parsePriorityLabel, } from './labels/label-schema.js' export type { EstimateLabel, LabelFacts, PriorityLabel } from './labels/label-schema.js' + +export { createGiteaClient, normalizeIssue } from './gitea/client.js' +export type { GiteaClient } from './gitea/client.js' +export { GiteaApiError } from './gitea/types.js' +export type { + FetchLike, + GiteaConfig, + GiteaHttpResponse, + GiteaIssue, + GiteaMilestoneRef, + GiteaRequestInit, +} from './gitea/types.js'