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 { giteaAuthorization, 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 = {}) { 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, headers: Record = {}) { 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 = {}): Record => ({ 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): 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('giteaAuthorization', () => { it('sends a value with a colon as HTTP Basic and a token as token auth', () => { expect(giteaAuthorization('bot:secret:1')).toBe(`Basic ${Buffer.from('bot:secret:1', 'utf8').toString('base64')}`) expect(giteaAuthorization('0123456789abcdef0123456789abcdef01234567')).toBe('token 0123456789abcdef0123456789abcdef01234567') expect(giteaAuthorization('glpat-abc_123')).toBe('token glpat-abc_123') }) }) 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', 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' } }) }) })