import { describe, expect, it } from 'vitest' import { createGiteaClient, normalizeIssue, normalizeTimeline } 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('normalizeTimeline', () => { it('maps known gitea event types to lifecycle signals and drops the rest', () => { const events = normalizeTimeline([ { type: 'label', created_at: '2026-01-06T09:00:00Z' }, { type: 'milestone', created_at: '2026-01-06T09:05:00Z' }, { type: 'commit_ref', created_at: '2026-01-07T12:00:00Z' }, { type: 'pull_ref', created_at: '2026-01-08T12:00:00Z' }, { type: 'comment', created_at: '2026-01-08T13:00:00Z' }, // dropped { type: 'add_dependency', created_at: '2026-01-08T14:00:00Z' }, // dropped { type: 'close', created_at: '2026-01-12T09:00:00Z' }, ]) expect(events).toEqual([ { type: 'triage', at: '2026-01-06T09:00:00Z' }, { type: 'triage', at: '2026-01-06T09:05:00Z' }, { type: 'commit', at: '2026-01-07T12:00:00Z' }, { type: 'pull', at: '2026-01-08T12:00:00Z' }, { type: 'close', at: '2026-01-12T09:00:00Z' }, ]) }) it('skips events missing a timestamp', () => { expect(normalizeTimeline([{ type: 'commit_ref', created_at: '' }])).toEqual([]) }) }) 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('setIssueLabels PUTs the label ids to the issue labels endpoint', async () => { const { fetch, calls } = stubFetch(null, 204) await createGiteaClient(CONFIG, fetch).setIssueLabels(9, [3, 7]) expect(calls).toHaveLength(1) expect(calls[0].url).toBe('https://gitea.stephenmann.io/api/v1/repos/christian/commitea/issues/9/labels') expect(calls[0].init?.method).toBe('PUT') expect(calls[0].init?.headers?.['Content-Type']).toBe('application/json') expect(JSON.parse(calls[0].init?.body ?? '{}')).toEqual({ labels: [3, 7] }) }) it('listLabels returns id+name pairs', async () => { const { fetch } = stubFetch([ { id: 3, name: 'est/2d', color: 'fff' }, { id: 7, name: 'p/1', color: '000' }, ]) const labels = await createGiteaClient(CONFIG, fetch).listLabels() expect(labels).toEqual([ { id: 3, name: 'est/2d' }, { id: 7, name: 'p/1' }, ]) }) it('listCollaborators maps login+name and prepends the owner when absent', async () => { const { fetch } = stubFetch([{ login: 'stephen', full_name: 'Stephen M.' }]) const people = await createGiteaClient(CONFIG, fetch).listCollaborators() // owner (christian) prepended since /collaborators omits them; collaborator name preserved expect(people).toEqual([ { login: 'christian', name: 'christian' }, { login: 'stephen', name: 'Stephen M.' }, ]) }) it('listCollaborators does not double-add the owner when already listed', async () => { const { fetch } = stubFetch([{ login: 'christian', full_name: 'Christian L.' }]) const people = await createGiteaClient(CONFIG, fetch).listCollaborators() expect(people).toEqual([{ login: 'christian', name: 'Christian L.' }]) }) it('getFile returns null on 404 and content+sha on hit', async () => { const miss = stubFetch('nope', 404) expect(await createGiteaClient(CONFIG, miss.fetch).getFile('directives/log.jsonl')).toBeNull() const hit = stubFetch({ content: 'aGVsbG8=\n', sha: 'abc123' }) const file = await createGiteaClient(CONFIG, hit.fetch).getFile('directives/log.jsonl') expect(file).toEqual({ contentBase64: 'aGVsbG8=', sha: 'abc123' }) expect(hit.calls[0].url).toContain('/contents/directives/log.jsonl') }) it('putFile POSTs to create and PUTs to update (with sha)', async () => { const create = stubFetch({}, 201) await createGiteaClient(CONFIG, create.fetch).putFile('directives/log.jsonl', { contentBase64: 'eA==', message: 'seed' }) expect(create.calls[0].init?.method).toBe('POST') expect(JSON.parse(create.calls[0].init?.body ?? '{}')).toEqual({ content: 'eA==', message: 'seed' }) const update = stubFetch({}, 200) await createGiteaClient(CONFIG, update.fetch).putFile('directives/log.jsonl', { contentBase64: 'eQ==', message: 'append', sha: 's1' }) expect(update.calls[0].init?.method).toBe('PUT') expect(JSON.parse(update.calls[0].init?.body ?? '{}')).toEqual({ content: 'eQ==', message: 'append', sha: 's1' }) }) it('createIssue POSTs title/body/labels and returns a normalized issue', async () => { const created = { ...RAW_ISSUE, number: 44, title: 'Retry token refresh', labels: [{ name: 'est/2d' }, { name: 'p/2' }] } const { fetch, calls } = stubFetch(created, 201) const issue = await createGiteaClient(CONFIG, fetch).createIssue({ title: 'Retry token refresh', body: 'backoff', labelIds: [3, 7] }) expect(calls[0].url).toBe('https://gitea.stephenmann.io/api/v1/repos/christian/commitea/issues') expect(calls[0].init?.method).toBe('POST') expect(JSON.parse(calls[0].init?.body ?? '{}')).toEqual({ title: 'Retry token refresh', body: 'backoff', labels: [3, 7] }) expect(issue.number).toBe(44) expect(issue.labels).toEqual(['est/2d', 'p/2']) }) 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('createGiteaClient.listIssues', () => { /** Stub fetch that pages 50-at-a-time and records the paths it was asked for. */ function pagedFetch(pages: unknown[][]): { fetch: FetchLike; paths: string[] } { const paths: string[] = [] const fetch: FetchLike = (url) => { paths.push(url) const page = Number(/[?&]page=(\d+)/.exec(url)?.[1] ?? '1') const body = pages[page - 1] ?? [] return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body), text: () => Promise.resolve(''), }) } return { fetch, paths } } it('walks every page until a short page ends it', async () => { const full = Array.from({ length: 50 }, (_, i) => ({ ...RAW_ISSUE, number: i + 1 })) const tail = [{ ...RAW_ISSUE, number: 51 }] const { fetch, paths } = pagedFetch([full, tail]) const issues = await createGiteaClient(CONFIG, fetch).listIssues() expect(issues).toHaveLength(51) expect(paths).toHaveLength(2) // stopped after the short second page expect(paths[0]).toContain('/issues?type=issues&state=all&page=1&limit=50') }) it('excludes pull requests even if the API returns them', async () => { const { fetch } = pagedFetch([ [{ ...RAW_ISSUE, number: 1 }, { ...RAW_ISSUE, number: 2, pull_request: { url: 'x' } }], ]) const issues = await createGiteaClient(CONFIG, fetch).listIssues() expect(issues.map((i) => i.number)).toEqual([1]) }) it('passes the state filter through', async () => { const { fetch, paths } = pagedFetch([[]]) await createGiteaClient(CONFIG, fetch).listIssues({ state: 'open' }) expect(paths[0]).toContain('state=open') }) }) 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') }) })