Files
dsh-superagent/tests/gitea.spec.ts
T

302 lines
18 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import { Engine, key, type Assessor, type Executor, type OutboundMessage, type Trigger } from '../src/core/index.ts'
import { KV } from '../src/kv.ts'
import { GiteaConfigSchema, apply, giteaKind, giteaThreadContext, pollRound } from '../src/sources/gitea.ts'
import { giteaClient, GiteaApiError } from '../src/sources/gitea-api.ts'
import { SOURCE, issueTrigger } from '../src/sources/gitea-trigger.ts'
import { giteaLink, sendGitea } from '../src/sources/gitea-channel.ts'
import { SourceRegistry } from '../src/sources/registry.ts'
import { Store } from '../src/app/store.ts'
import { Commands } from '../src/infra/commands.ts'
import { HealthBoard } from '../src/infra/health.ts'
import { testDeployment } from './fixtures/deployment.ts'
import { SettingsSchema, settingsPolicy } from '../src/policy.ts'
import { sourceContextOf, type SourceContextDeps } from '../src/app/source-context.ts'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
// The Gitea API client reads its token from the environment; set a test token so the client builds.
process.env.GITEA_TOKEN = 'test-token'
afterEach(() => { vi.unstubAllGlobals() })
/** A Gitea host: one store, registry, engine and the `gitea` SourceContext, mirroring the feishu/github fixture. */
function host(overrides: Partial<SourceContextDeps> = {}) {
const store = new Store(join(mkdtempSync(join(tmpdir(), 'gitea-')), 'db.sqlite'))
const settings = SettingsSchema.parse({
policy: { sources: { gitea: { allowed: [{ id: 'alice' }], bots: [{ id: 'gitea-bot' }] } } },
eval: { mode: 'auto' },
sources: { gitea: { enabled: true, config: { url: 'https://gitea.example.com', repository: 'acme/widgets', tokenEnv: 'GITEA_TOKEN' } } },
})
store.saveSettings(settings)
const board = new HealthBoard()
const sources = new SourceRegistry(() => store.settings(), board)
const deployment = testDeployment({ gitea: { url: 'https://gitea.example.com', tokenEnv: 'GITEA_TOKEN', login: 'agent-bot' } })
sources.register(giteaKind)
const assessor: Assessor = { request: vi.fn(async () => {}) }
const executor: Executor = { start: vi.fn(async () => {}), deliver: vi.fn(async () => {}), stop: vi.fn(async () => {}) }
const engine = new Engine({ repo: store, scope: sources, policy: () => settingsPolicy(store.settings(), sources), assessor, executor, outbox: { send: () => {}, status: () => undefined, withdraw: () => {} } })
const commands = new Commands(() => deployment, {}, new AbortController().signal)
const receive = vi.fn(async (trigger: Trigger) => { const result = engine.receive(trigger); engine.evaluateDue(); return result })
const parts: SourceContextDeps = { store, engine, outbox: { send: message => { store.receipts.add(message) } }, commands, sources, deployment,
ready: Promise.resolve(), configured: () => true, receive, ...overrides }
const value = { ...parts, context: (kind: { kind: string; health: readonly string[] }) => sourceContextOf(parts, kind) }
const context = sourceContextOf(parts, { kind: 'gitea', health: ['gitea'] })
return { ...value, ...context, health: board.all, store, engine, commands: parts.commands, receive, executor }
}
/** Routes the Gitea API by path under `/api/v1/` and records every call; `fetch` is stubbed for the test. */
function gitea(routes: Record<string, unknown>, headers: Record<string, string> = {}) {
const calls: { path: string; query: string; method: string }[] = []
vi.stubGlobal('fetch', async (url: string, init?: { method?: string }) => {
const u = new URL(url)
const path = u.pathname.replace(/^\/api\/v1\//, '')
calls.push({ path, query: u.search, method: init?.method ?? 'GET' })
const route = routes[path]
if (route === undefined) return { status: 404, ok: false, headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => 'Not Found' }
const value: unknown = typeof route === 'function' ? route(u.search) : route
if (value instanceof Error) throw value
return { status: 200, ok: true, headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => JSON.stringify(value) }
})
return calls
}
/** A valid Gitea Issue body; `over` overrides fields. */
const issue = (number: number, over: Record<string, unknown> = {}): Record<string, unknown> => ({
number, title: `Issue ${number}`, body: '', state: 'open', comments: 0,
created_at: '2026-09-08T00:00:00Z', updated_at: '2026-09-08T00:00:00Z',
html_url: `https://gitea.example.com/acme/widgets/issues/${number}`, user: { login: 'alice', id: 1 },
...over,
})
const triggerOf = (meta: Record<string, unknown>): Trigger => ({ id: 't', source: SOURCE, threads: [{ source: SOURCE, id: '12' }],
actor: { source: SOURCE, id: 'alice' }, human: true, at: '2026-09-08T00:00:00Z', text: '', meta })
describe('GiteaConfigSchema', () => {
it('defaults a bare config to a safe baseline', () => {
expect(GiteaConfigSchema.parse({})).toEqual({
repository: '', url: '', tokenEnv: 'GITEA_TOKEN',
pollSeconds: 60, failuresBeforeError: 3,
observeIssues: false, observePulls: false, observeComments: false,
})
})
it('accepts a full config and keeps the values', () => {
expect(GiteaConfigSchema.parse({
repository: 'acme/widgets', url: 'https://gitea.example.com', tokenEnv: 'GITEA_TOKEN',
pollSeconds: 300, failuresBeforeError: 5,
observeIssues: true, observePulls: true, observeComments: false,
})).toEqual({
repository: 'acme/widgets', url: 'https://gitea.example.com', tokenEnv: 'GITEA_TOKEN',
pollSeconds: 300, failuresBeforeError: 5,
observeIssues: true, observePulls: true, observeComments: false,
})
})
it('rejects unknown keys (strict) and out-of-range values', () => {
expect(() => GiteaConfigSchema.parse({ unknown: 1 })).toThrow()
expect(() => GiteaConfigSchema.parse({ pollSeconds: 59 })).toThrow()
expect(() => GiteaConfigSchema.parse({ pollSeconds: 3601 })).toThrow()
expect(() => GiteaConfigSchema.parse({ failuresBeforeError: 0 })).toThrow()
expect(() => GiteaConfigSchema.parse({ failuresBeforeError: 21 })).toThrow()
})
})
describe('giteaKind', () => {
it('exposes the eight setup fields in order', () => {
expect(giteaKind.fields.map(field => field.key)).toEqual([
'url', 'repository', 'tokenEnv', 'pollSeconds',
'failuresBeforeError', 'observeIssues', 'observePulls', 'observeComments',
])
})
it('scope reports what the Source observes for each meta kind', () => {
const config = GiteaConfigSchema.parse({ observeIssues: true, observePulls: false, observeComments: true })
expect(giteaKind.scope!({ meta: { kind: 'issue' } }, config)).toEqual({ listened: true, observed: true, row: 'issues' })
expect(giteaKind.scope!({ meta: { kind: 'pull' } }, config)).toEqual({ listened: true, observed: false, row: 'pulls' })
expect(giteaKind.scope!({ meta: { kind: 'conversation' } }, config)).toEqual({ listened: true, observed: true, row: 'comments' })
expect(giteaKind.scope!({ meta: { kind: 'unknown' } }, config)).toEqual({ listened: true, observed: false })
})
it('find names the thread of a #number, a bare number, or an Issue/PR page', () => {
expect(giteaKind.find!('#12').threads).toEqual([`${SOURCE}:12`])
expect(giteaKind.find!('12').threads).toEqual([`${SOURCE}:12`])
expect(giteaKind.find!('https://gitea.example.com/acme/widgets/issues/12').threads).toEqual([`${SOURCE}:12`])
expect(giteaKind.find!('https://gitea.example.com/acme/widgets/pulls/12').threads).toEqual([`${SOURCE}:12`])
expect(giteaKind.find!('nothing')).toEqual({ threads: [], terms: [] })
})
it('facts name the current Gitea account', () => {
const deployment = testDeployment({ gitea: { url: 'https://gitea.example.com', tokenEnv: 'GITEA_TOKEN', login: 'agent-bot' } })
expect(giteaKind.facts!(deployment)).toEqual({ tokenEnv: { zh: '当前账号 @agent-bot', en: 'Current account @agent-bot' } })
expect(giteaKind.facts!(testDeployment({ gitea: undefined }))).toEqual({})
})
it('policy.schema validates the allowed and bots lists', () => {
expect(giteaKind.policy!.schema.parse({ allowed: [{ id: 'alice' }], bots: [{ id: 'gitea-bot' }] })).toEqual({ allowed: [{ id: 'alice' }], bots: [{ id: 'gitea-bot' }] })
expect(giteaKind.policy!.schema.parse({})).toEqual({ allowed: [] })
expect(() => giteaKind.policy!.schema.parse({ allowed: [{ id: 'bad id' }] })).toThrow()
expect(() => giteaKind.policy!.schema.parse({ allowed: [{ id: 'ok', extra: 1 }] })).toThrow()
})
})
describe('apply', () => {
it('registers the Gitea kind on a fresh host', () => {
const store = new Store(join(mkdtempSync(join(tmpdir(), 'gitea-apply-')), 'db.sqlite'))
const board = new HealthBoard()
const sources = new SourceRegistry(() => store.settings(), board)
// The watcher context reports setupPending (not configured) so the polling loop returns immediately.
const ctx = { orchestrator: { sources, context: () => ({ ready: Promise.resolve(), configured: () => false, config: () => undefined, report: () => {} }) },
effect: vi.fn((fn: () => unknown) => fn()) }
apply(ctx as never)
expect(sources.kind('gitea')).toBe(giteaKind)
store.close()
})
})
describe('pollRound', () => {
it('reads notifications since the cursor, clamps the poll interval, and takes in a mentioned Issue', async () => {
const h = host()
h.store.set(KV.gitea.since, '2026-09-07T00:00:00Z')
const notification = { repository: { full_name: 'acme/widgets' }, subject: { url: 'https://gitea.example.com/api/v1/repos/acme/widgets/issues/12', type: 'Issue' } }
const calls = gitea({
notifications: [notification],
'repos/acme/widgets/issues/12': issue(12, { body: 'Fix it @agent-bot' }),
'repos/acme/widgets/issues/12/comments': [],
}, { 'x-poll-interval': '120' })
const interval = await pollRound(h)
expect(interval).toBe(120)
expect(h.receive).toHaveBeenCalledTimes(1)
expect(h.receive.mock.calls[0]![0]).toMatchObject({ id: key('gitea-issue', '12'), addressed: 'mention', meta: { number: 12, kind: 'issue' } })
expect(calls.map(c => c.path)).toContain('notifications')
expect(h.store.get(KV.gitea.since)).toBeTypeOf('string')
h.store.close()
})
it('reads the observed repository streams and takes in new Issues, PRs and comments', async () => {
const h = host({ configured: () => true })
h.store.set(KV.gitea.since, '2026-09-07T00:00:00Z')
// Turn on every stream so the round reads all three.
const settings = h.store.settings()
h.store.saveSettings({ ...settings, sources: { ...settings.sources, gitea: { enabled: true, config: { url: 'https://gitea.example.com', repository: 'acme/widgets', tokenEnv: 'GITEA_TOKEN', observeIssues: true, observePulls: true, observeComments: true } } } })
// The stream's `since` is this round's start, so the items must be dated after it.
const now = new Date(Date.now() + 60_000).toISOString()
const newIssue = issue(20, { created_at: now })
const newPr = issue(30, { created_at: now, pull_request: { url: 'https://gitea.example.com/api/v1/repos/acme/widgets/pulls/30' } })
const newComment = { id: 1, body: 'hi @agent-bot', user: { login: 'alice', id: 1 }, created_at: now, issue_url: 'https://gitea.example.com/api/v1/repos/acme/widgets/issues/20' }
gitea({
notifications: [],
'repos/acme/widgets/issues': [newIssue],
'repos/acme/widgets/pulls': [newPr],
'repos/acme/widgets/issues/comments': [newComment],
})
await pollRound(h)
expect(h.receive).toHaveBeenCalledTimes(3)
const received = h.receive.mock.calls.map(call => call[0]!)
expect(received.map(t => t.meta.kind).sort()).toEqual(['conversation', 'issue', 'pull'])
h.store.close()
})
it('takes in a new comment and review of a subscribed thread', async () => {
const h = host()
h.store.threads.save({ key: `${SOURCE}:12`, ref: { source: SOURCE, id: '12' }, createdAt: new Date().toISOString(), subscriptions: ['ticket'], correlated: [], pending: [], meta: { isPr: true } })
h.store.set(KV.gitea.since, '2026-09-07T00:00:00Z')
h.store.set(KV.gitea.cursor(12), '2026-09-08T00:00:00Z')
const comment = { id: 1, body: 'hi @agent-bot', user: { login: 'alice', id: 1 }, created_at: '2026-09-09T00:00:00Z' }
const review = { id: 10, body: 'looks good', user: { login: 'carol', id: 3 }, state: 'APPROVED', commit_id: 'abc', submitted_at: '2026-09-09T00:00:00Z' }
gitea({
notifications: [],
'repos/acme/widgets/issues/12': issue(12, { pull_request: { url: 'https://gitea.example.com/api/v1/repos/acme/widgets/pulls/12' } }),
'repos/acme/widgets/issues/12/comments': [comment],
'repos/acme/widgets/pulls/12/reviews': [review],
})
// pollRound reads subscribed threads through pollThreads; the thread is subscribed and its cursor is set.
const interval = await pollRound(h)
expect(interval).toBe(60)
expect(h.receive).toHaveBeenCalledTimes(2)
const received = h.receive.mock.calls.map(call => call[0]!)
expect(received.map(t => t.meta.kind).sort()).toEqual(['conversation', 'review'])
h.store.close()
})
})
describe('giteaThreadContext', () => {
it('messageId keys each Gitea message by kind', () => {
expect(giteaThreadContext.messageId(triggerOf({ number: 12, kind: 'issue' }))).toBe('12-body')
expect(giteaThreadContext.messageId(triggerOf({ number: 12, kind: 'pull' }))).toBe('12-body')
expect(giteaThreadContext.messageId(triggerOf({ number: 12, kind: 'review', reviewId: 5 }))).toBe('r5')
expect(giteaThreadContext.messageId(triggerOf({ number: 12, kind: 'conversation', commentId: 7 }))).toBe('c7')
})
it('read returns the body for root and parent parts', async () => {
const h = host()
gitea({ 'repos/acme/widgets/issues/12': issue(12) })
const part = await giteaThreadContext.read(h.commands, triggerOf({ number: 12, kind: 'issue' }), 'root', undefined, h.config('gitea')!)
expect(part.messages).toHaveLength(1)
expect(part.messages[0]!).toMatchObject({ id: '12-body', from: 'alice', text: 'Issue 12\n\n' })
h.store.close()
})
it('read returns body, comments and (for a PR) reviews in time order', async () => {
const h = host()
gitea({
'repos/acme/widgets/issues/12': issue(12, { pull_request: { url: 'https://gitea.example.com/api/v1/repos/acme/widgets/pulls/12' } }),
'repos/acme/widgets/issues/12/comments': [
{ id: 1, body: 'First', user: { login: 'alice', id: 1 }, created_at: '2026-09-08T01:00:00Z' },
{ id: 2, body: 'Second', user: { login: 'bob', id: 2 }, created_at: '2026-09-08T00:30:00Z' },
],
'repos/acme/widgets/pulls/12/reviews': [
{ id: 10, body: 'Review', user: { login: 'carol', id: 3 }, state: 'APPROVED', commit_id: 'abc', submitted_at: '2026-09-08T00:45:00Z' },
],
})
const part = await giteaThreadContext.read(h.commands, triggerOf({ number: 12, kind: 'pull' }), 'thread', undefined, h.config('gitea')!)
expect(part.messages.map(m => m.id)).toEqual(['12-body', 'c2', 'r10', 'c1'])
h.store.close()
})
it('read paginates 100 messages per page and offers the next cursor', async () => {
const h = host()
const comments = Array.from({ length: 150 }, (_, i) => ({ id: i + 1, body: `C${i + 1}`, user: { login: 'alice', id: 1 }, created_at: `2026-09-08T00:${String(i % 60).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}Z` }))
gitea({
'repos/acme/widgets/issues/12': issue(12),
// The client pages 100 at a time; serve a slice per page so it stops after the last page.
'repos/acme/widgets/issues/12/comments': (query: string) => {
const page = Number(new URLSearchParams(query).get('page') ?? 1)
return comments.slice((page - 1) * 100, page * 100)
},
})
const config = GiteaConfigSchema.parse(h.config('gitea'))
const page1 = await giteaThreadContext.read(h.commands, triggerOf({ number: 12, kind: 'issue' }), 'thread', undefined, config)
expect(page1.messages).toHaveLength(100)
expect(page1.next).toBe('p2')
const page2 = await giteaThreadContext.read(h.commands, triggerOf({ number: 12, kind: 'issue' }), 'thread', 'p2', config)
expect(page2.messages).toHaveLength(51)
h.store.close()
})
})
describe('sendGitea', () => {
const message: OutboundMessage = { id: 'm1', channel: { source: SOURCE, thread: `${SOURCE}:12`, address: { number: '12', url: 'https://gitea.example.com/acme/widgets/issues/12' } }, text: 'Hello', kind: 'reply' }
it('posts a comment and is idempotent via the marker', async () => {
const h = host()
const calls = gitea({
'repos/acme/widgets/issues/12': issue(12),
'repos/acme/widgets/issues/12/comments': [],
})
expect(await sendGitea(h, message)).toBe('sent')
expect(calls.filter(c => c.method === 'POST')).toHaveLength(1)
// Second send: the marker is already stored, so no new POST.
gitea({
'repos/acme/widgets/issues/12': issue(12),
'repos/acme/widgets/issues/12/comments': [{ id: 1, body: 'Hello\n\n<!-- dsh-superagent:m1 -->', user: { login: 'agent-bot', id: 1 } }],
})
expect(await sendGitea(h, message)).toBe('sent')
h.store.close()
})
it('returns gone when the Issue is deleted', async () => {
const h = host()
gitea({})
expect(await sendGitea(h, message)).toBe('gone')
h.store.close()
})
it('returns gone when the Issue is locked (403)', async () => {
const h = host()
vi.stubGlobal('fetch', async () => { throw new GiteaApiError(403, 'Issue is locked', 'locked') })
expect(await sendGitea(h, message)).toBe('gone')
h.store.close()
})
})
describe('giteaLink', () => {
it('builds the panel link from the address', () => {
expect(giteaLink({ number: '12', url: 'https://gitea.example.com/acme/widgets/issues/12' })).toEqual({ url: 'https://gitea.example.com/acme/widgets/issues/12', label: { zh: 'Gitea #12', en: 'Gitea #12' } })
})
})