Surfaces the assign/milestone mutations end-to-end so they're usable in-app and by the agent — the rest of #24. Agent path: - propose_change tool + system prompt now accept `assignee` (login/null) and `milestone` (id/null). ProposeChangeArgs + proposalsFor extended: a new ProposalContext (current assignee/milestone + milestones list) lets a proposal skip no-ops and label the milestone. ChangeProposal gains an always-present `summary` (plan is now label-only) — chat-panel, use-chat, and the model executor render `summary`, so non-label proposals display correctly. Dialog path: - Client `listCollaborators()` (prepends the repo owner — /collaborators omits them, so a solo-owner repo still has an assignable person). New `gitea:collaborators` bridge. The Adjust dialog gains Assignee + Milestone pickers (current values from the reconciled backlog); pending assign/remilestone changes flow through the existing apply path. Tests: +4 core (assign/milestone proposals with no-op skip; collaborators owner-prepend + no-double-add). 138 core green; core + desktop typecheck clean; 14 fixture e2e green; live-backlog now drives the pickers on real data. Fixed stale P2 refs in live-backlog (P2 is shipped → correctly off the runway). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
279 lines
11 KiB
TypeScript
279 lines
11 KiB
TypeScript
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')
|
|
})
|
|
})
|