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:
Croissant Le Doux
2026-07-08 11:33:29 -04:00
parent 9920634e74
commit d6f531eb6d
4 changed files with 324 additions and 0 deletions

View File

@@ -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')
})
})