import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Engine, MemoryRepositories, key, type Assessor, type Executor, type Thread, type Ticket, type Trigger } from '../src/core/index.ts' import { backfillChat, consumerRetry, feishuKind, feishuTriggerId, feishuRef, feishuTime, feishuTrigger, historyEvent } from '../src/sources/feishu.ts' import { KV } from '../src/kv.ts' import { apply as applyFeishu, pollThreads as pollFeishuThreads } from '../src/sources/feishu.ts' import { runFeishu } from '../src/sources/feishu-loops.ts' import { commentTrigger, githubAddress, githubChannel, issueTrigger, type GithubComment, type GithubIssue } from '../src/sources/github-trigger.ts' import { claimPull, observePull, pullEndNotices, pullOf, ticketBranch, type Pull } from '../src/sources/github-pull.ts' import { ciCounts, ciFact, ciSummary } from '../src/sources/github-read.ts' import { apply as applyGithub, githubKind, pollFailure, pollRound } from '../src/sources/github.ts' import { pollThreads as pollGithubThreads } from '../src/sources/github.ts' import { SourceRegistry } from '../src/sources/registry.ts' import { cursorAt, POLL_HORIZON_MS } from '../src/sources/poll.ts' import { Store } from '../src/app/store.ts' import { Commands, commandError } 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 { SetupError, type SetupProblem } from '../src/setup.ts' import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' const MEMBER = 'ou_alice' function host(overrides: Partial = {}) { const store = new Store(join(mkdtempSync(join(tmpdir(), 'sources-')), 'db.sqlite')) const settings = SettingsSchema.parse({ policy: { sources: { feishu: { allowed: [{ id: 'ou_alice', name: 'Alice' }], syncChat: 'oc_team' }, github: { allowed: [{ id: 'alice' }] } } }, eval: { mode: 'auto' }, sources: { feishu: { enabled: true, config: { chats: [{ chatId: 'oc_team', listen: 'mentions' }, { chatId: 'oc_observed', listen: 'all' }], directMessages: true } } } }) store.saveSettings(settings) const board = new HealthBoard() const sources = new SourceRegistry(() => store.settings(), board) const deployment = testDeployment() sources.register(feishuKind); sources.register(githubKind) const assessor: Assessor = { request: vi.fn(async () => {}) } const executor: Executor = { start: vi.fn(async () => {}), deliver: vi.fn(async () => {}), stop: vi.fn(async () => {}) } const outbox = { send: () => {}, status: () => undefined, withdraw: () => {} } const engine = new Engine({ repo: store, scope: sources, policy: () => settingsPolicy(store.settings(), sources), assessor, executor, outbox }) const commands = new Commands(() => deployment, {}, new AbortController().signal) const receive = vi.fn(async (trigger: Trigger) => { const result = engine.receive(trigger); engine.evaluateDue(); return result }) // Record outbound messages in the store without sending them. const parts: SourceContextDeps = { store, engine, outbox: { send: message => { store.receipts.add(message) } }, commands, sources, deployment, ready: Promise.resolve(), configured: () => true, receive, ...overrides } // The Sources' functions take the context; plugin entries and kind callbacks take the host, which builds each kind's context. const value = { ...parts, context: (kind: { kind: string; health: readonly string[] }) => sourceContextOf(parts, kind) } const context = sourceContextOf(parts, { kind: 'feishu', health: ['github', 'feishu', 'history'] }) const health = overrides.sources ? healthOf.get(overrides.sources)!.all : board.all return { ...value, ...context, health, store, engine, commands: parts.commands, receive, executor } } const healthOf = new WeakMap() function emptyRegistry(): SourceRegistry { const board = new HealthBoard() const sources = new SourceRegistry(() => SettingsSchema.parse({}), board) healthOf.set(sources, board) return sources } const subscribed = (h: ReturnType, ref: { source: string; id: string }, meta: Record = {}) => h.store.threads.save({ key: `${ref.source}:${ref.id}`, ref, createdAt: new Date().toISOString(), subscriptions: ['ticket'], correlated: [], pending: [], meta }) const openTicket = (id: string): Ticket => ({ id, title: id, decisionId: `d-${id}`, mandate: { strength: 'explicit', policyVersion: '', basis: { via: 'listed' }, actor: { source: 'github', id: 'alice' }, scope: '' }, channels: [], assignments: [], inputs: [], undelivered: [], createdAt: '', updatedAt: '', origin: { triggerId: `source-${id}`, triggerIds: [] } }) const execution = (name: string) => ({ name, directory: `/w/${name}`, assignment: '' }) const pulled = (number: number) => ({ follows: [{ thread: `github:${number}`, own: true }] }) const endedPull = (number: number, at: string, merged = true): Pull => ({ ...openPull(number), state: 'closed', merged, ...merged ? { merged_at: at } : { closed_at: at } }) const agentsPull = (number: number, ref: string): Pull => ({ ...openPull(number), user: { login: 'agent-bot' }, head: { sha: 'abc', ref, repo: { full_name: 'acme/widgets' } } }) const openPull = (number: number) => ({ number, title: 'Fix the margin', state: 'open', html_url: `https://github.com/acme/widgets/pull/${number}`, user: { login: 'carol' }, head: { sha: 'abc', ref: 'fix', repo: { full_name: 'acme/widgets' } } }) const notification = (number: number, kind: 'issues' | 'pulls' = 'issues') => ({ id: `n${number}`, updated_at: '2026-09-09T00:00:00Z', reason: 'mention', repository: { full_name: 'acme/widgets' }, subject: { url: `https://api.github.com/repos/acme/widgets/${kind}/${number}`, type: kind === 'pulls' ? 'PullRequest' : 'Issue', title: '' } }) const refused = (status: number, text: string) => commandError({ stderr: `gh: ${text} (HTTP ${status})`, code: 1 }) /** Stubs `gh api` calls against `acme/widgets` and returns the requested paths in order. */ function github(h: ReturnType, routes: Record): string[] { const reads: string[] = [] vi.spyOn(h.commands, 'run').mockImplementation(async (_program, args) => { reads.push(args[1]!.replace(/^repos\/acme\/widgets\//, '')) const path = reads.at(-1)!.split('?')[0]! const list = /\/(comments|reviews|statuses)$/.test(path) ? [] : refused(404, 'Not Found') const route = routes[path] ?? (path.endsWith('/check-runs') ? { check_runs: [] } : list) const value: unknown = typeof route === 'function' ? (route as () => unknown)() : route if (value instanceof Error) throw value if (args.includes('--include')) return `HTTP/2.0 200 OK\r\nX-Poll-Interval: 60\r\n\r\n${JSON.stringify(value)}` return JSON.stringify(args.includes('--slurp') ? [value] : value) }) return reads } const bare = (reads: string[]) => reads.map(path => path.split('?')[0]) describe('feishu source', () => { it('registers its kind without a bot, and names on its health what setup still needs', async () => { const run = async (overrides: Partial) => { // The fixture's registry already holds the kind; this one starts empty so the plugin's registration shows. const value = host({ sources: emptyRegistry(), ...overrides }) const disposers: (() => unknown)[] = [] applyFeishu({ orchestrator: value, effect: (effect: () => () => unknown) => { disposers.push(effect()) } } as never) await vi.waitFor(() => expect(value.health.history).toBeDefined()) const registered = value.sources.kind('feishu') // Snapshot health before disposal clears the registered entries. const health = { ...value.health } for (const dispose of disposers) await dispose() return { ...value, registered, health } } const unset = await run({ deployment: testDeployment({ feishu: undefined }) }) expect(unset.registered).toBe(feishuKind) expect(unset.health).toMatchObject({ feishu: { state: 'paused', code: 'feishuCliUnset' }, history: { state: 'paused', code: 'feishuBotMissing' } }) const problem = new SetupError('feishuBotNotReady', { app: 'cli_bot', status: 'missing' }).problem const refused = await run({ deployment: testDeployment({ feishu: undefined }), feishuProblem: problem }) // A failed bot check waits for the operator's fix and a check; it does not retry by itself like a polling error. expect(refused.health.feishu).toEqual({ state: 'paused', detail: problem.detail }) expect((await run({ configured: () => false })).health.feishu).toEqual({ state: 'paused', detail: 'Waiting for the deployment check', code: 'setupPending' }) }) it('translates live events into triggers keyed by root message, and drops the bot\'s own messages', () => { const trigger = feishuTrigger({ message_id: 'om_2', chat_id: 'oc_1', chat_type: 'group', sender_id: MEMBER, sender_type: 'user', content: 'hi', create_time: '1757376000000', mentions: [{ id: 'cli_bot' }], root_id: 'om_1', parent_id: 'om_1', thread_id: 'omt_1', message_app_link: 'https://applink/x' }, 'ou_bot', 'cli_bot')! expect(trigger).toMatchObject({ id: key('feishu', 'om_2'), source: 'feishu', threads: [{ source: 'feishu', id: 'oc_1:om_1' }], actor: { source: 'feishu', id: MEMBER }, human: true, at: '2025-09-09T00:00:00.000Z', url: 'https://applink/x', channel: { source: 'feishu', address: { chatId: 'oc_1', messageId: 'om_2' } }, meta: { chatId: 'oc_1', direct: false, mentioned: true, rootId: 'om_1', parentId: 'om_1', threadId: 'omt_1', messageId: 'om_2' } }) expect(trigger.addressed).toBe('mention') expect(feishuTrigger({ message_id: 'om_5', chat_id: 'oc_2', chat_type: 'p2p', sender_id: MEMBER, sender_type: 'user', content: 'hi', create_time: '1' }, 'ou_bot', 'cli_bot')!.addressed).toBe('direct') expect(feishuTrigger({ message_id: 'om_6', chat_id: 'oc_1', chat_type: 'group', sender_id: MEMBER, sender_type: 'user', content: 'hi', create_time: '1' }, 'ou_bot', 'cli_bot')).not.toHaveProperty('addressed') expect(feishuRef(trigger)).toEqual({ triggerId: trigger.id, chatId: 'oc_1', messageId: 'om_2', at: trigger.at }) expect(feishuTrigger({ message_id: 'om_3', chat_id: 'oc_1', chat_type: 'p2p', sender_id: 'ou_bot', sender_type: 'bot', content: 'me', create_time: '1' }, 'ou_bot', 'cli_bot')).toBeUndefined() expect(feishuTrigger(historyEvent({ message_id: 'om_4', chat_id: 'oc_1', create_time: '2', sender: { id: 'ou_x', sender_type: 'app' }, body: { content: '{"text":"from app"}' } }, 'oc_1', false), 'ou_bot', 'cli_bot')).toMatchObject({ human: false, text: 'from app', threads: [{ id: 'oc_1:om_4' }] }) }) it('polls subscribed threads and stores unseen replies as triggers on that thread', async () => { const h = host() const source = feishuTrigger({ message_id: 'om_root', chat_id: 'oc_1', chat_type: 'group', sender_id: MEMBER, sender_type: 'user', content: '@bot fix', create_time: '1', mentions: [{ id: 'ou_bot' }] }, 'ou_bot', 'cli_bot')! h.store.triggers.save(source) subscribed(h, source.threads[0]!, { chatId: 'oc_1', messageId: 'om_root', sourceTriggerId: source.id }) h.store.threads.save({ ...h.store.threads.get('feishu:oc_1:om_root')!, createdAt: '2025-09-01T00:00:00Z', lastAt: new Date().toISOString() }) const reply = { message_id: 'om_reply', chat_id: 'oc_1', msg_type: 'text', content: 'more details', sender: { id: 'ou_guest', sender_type: 'user' }, create_time: '1757376000000', thread_id: 'omt_1', root_id: 'om_root', mentions: [] } const mine = { ...reply, message_id: 'om_mine', sender: { id: 'ou_bot', sender_type: 'app', open_bot_id: 'ou_bot' } } const earlier = { ...reply, message_id: 'om_old', create_time: '1700000000000' } const lark = vi.spyOn(h.commands, 'lark').mockImplementation(async args => { if (args[0] === 'api') return { items: [{ message_id: 'om_root', chat_id: 'oc_1', thread_id: 'omt_1' }] } if (args[1] === '+messages-mget') return { messages: [] } return { thread_id: 'omt_1', has_more: false, messages: [mine, reply, earlier] } }) await pollFeishuThreads(h) expect(h.receive).toHaveBeenCalledTimes(1) expect(h.receive.mock.calls[0]![0]).toMatchObject({ id: key('feishu', 'om_reply'), threads: [{ source: 'feishu', id: 'oc_1:om_root' }], actor: { id: 'ou_guest' }, human: true, text: 'more details', meta: expect.objectContaining({ threadId: 'omt_1' }) }) expect(h.store.get('thread-seen:feishu:oc_1:om_root')).toEqual(expect.arrayContaining(['om_reply', 'om_mine', 'om_old'])) expect(h.store.threads.get('feishu:oc_1:om_root')?.meta).toMatchObject({ threadId: 'omt_1', url: 'https://applink.feishu.cn/client/chat/open?openChatId=oc_1', facts: { chat: { value: 'oc_1' } } }) lark.mockRejectedValue(new Error('rate limited')) await pollFeishuThreads(h) expect(h.store.threads.get('feishu:oc_1:om_root')?.meta.pollError).toContain('rate limited') h.store.close() }) it('translates a polled topic reply as the event consumer translates the live event', async () => { const h = host() // A direct-message chat the bot knows from its events. h.store.set('feishu-chats', { oc_dm: true }) const source = feishuTrigger({ message_id: 'om_root', chat_id: 'oc_dm', chat_type: 'p2p', sender_id: MEMBER, sender_type: 'user', content: 'fix it', create_time: '1757376000000' }, 'ou_bot', 'cli_bot')! h.store.triggers.save(source) subscribed(h, source.threads[0]!) h.store.threads.save({ ...h.store.threads.get('feishu:oc_dm:om_root')!, createdAt: '2025-09-01T00:00:00Z', lastAt: new Date().toISOString() }) const page = (message_id: string, sender: Record, extra: Record = {}) => ({ message_id, chat_id: 'oc_dm', msg_type: 'text', content: 'and the header', sender, create_time: '2026-09-11 12:00', thread_id: 'omt_dm', ...extra }) vi.spyOn(h.commands, 'lark').mockImplementation(async args => { if (args[0] === 'api') return { items: [{ message_id: 'om_root', chat_id: 'oc_dm', thread_id: 'omt_dm' }] } if (args[1] === '+messages-mget') return { messages: [] } // The page lists the reply without its root, and the bot's own message by its app. const messages = [page('om_mine', { id: 'cli_bot', sender_type: 'app' }), page('om_reply', { id: MEMBER, sender_type: 'user' })] return { thread_id: 'omt_dm', has_more: false, messages } }) await pollFeishuThreads(h) const live = feishuTrigger({ message_id: 'om_reply', chat_id: 'oc_dm', chat_type: 'p2p', sender_id: MEMBER, sender_type: 'user', content: 'and the header', create_time: String(Date.parse('2026-09-11 12:00')), root_id: 'om_root', thread_id: 'omt_dm' }, 'ou_bot', 'cli_bot') expect(h.receive.mock.calls.map(([trigger]) => trigger)).toEqual([live]) expect(live).toMatchObject({ addressed: 'direct', threads: [{ source: 'feishu', id: 'oc_dm:om_root' }] }) h.store.close() }) it('reads message times given as epoch milliseconds or as local times, and refuses others', () => { expect(feishuTime('1757376000000')).toBe('2025-09-09T00:00:00.000Z') expect(feishuTime('2026-09-11 12:00')).toBe(new Date(2026, 8, 11, 12, 0).toISOString()) expect(() => feishuTime('soon')).toThrow('Unreadable Feishu message time') }) it('polls a subscribed topic while a live ticket follows it or it had a message in the last two weeks', async () => { const h = host() const source = feishuTrigger({ message_id: 'om_root', chat_id: 'oc_1', chat_type: 'group', sender_id: MEMBER, sender_type: 'user', content: 'fix', create_time: '1' }, 'ou_bot', 'cli_bot')! h.store.triggers.save(source) subscribed(h, source.threads[0]!) h.store.threads.save({ ...h.store.threads.get('feishu:oc_1:om_root')!, lastAt: new Date(Date.now() - 15 * 24 * 3600_000).toISOString() }) const lark = vi.spyOn(h.commands, 'lark').mockResolvedValue({ items: [] }) await pollFeishuThreads(h) expect(lark).not.toHaveBeenCalled() h.store.tickets.save(openTicket('ticket')) await pollFeishuThreads(h) expect(lark).toHaveBeenCalled() h.store.close() }) it('keeps a quiet topic in history recovery while a subscribed Thread that is polled follows it', async () => { const h = host() const day = 24 * 3600_000 const source = feishuTrigger({ message_id: 'om_root', chat_id: 'oc_team', chat_type: 'group', sender_id: MEMBER, sender_type: 'user', content: 'fix', create_time: String(Date.now() - 30 * day), thread_id: 'omt_followed' }, 'ou_bot', 'cli_bot')! h.store.triggers.save(source) subscribed(h, source.threads[0]!) h.store.tickets.save(openTicket('ticket')) const old = new Date(Date.now() - 30 * day).toISOString() h.store.set('feishu-history-threads:oc_team', { omt_followed: old, omt_quiet: old }) vi.spyOn(h.commands, 'withSignal').mockReturnValue(h.commands) vi.spyOn(h.commands, 'lark').mockResolvedValue({ has_more: false, items: [] }) const now = new Date().toISOString() await backfillChat(h, 'oc_team', false, new Date(Date.now() - 60_000).toISOString(), now, new AbortController().signal) expect(h.store.get('feishu-history-threads:oc_team')).toEqual({ omt_followed: old }) h.store.close() }) it('refuses a history page with a message outside its chat or topic, and a page token that does not advance', async () => { const h = host() vi.spyOn(h.commands, 'withSignal').mockReturnValue(h.commands) const message = (fields: Record) => ({ message_id: 'om_1', chat_id: 'oc_team', create_time: String(Date.now() - 1000), sender: { id: MEMBER, sender_type: 'user' }, body: { content: '{"text":"hi"}' }, ...fields }) const lark = vi.spyOn(h.commands, 'lark') const now = new Date().toISOString() const recover = () => backfillChat(h, 'oc_team', false, new Date(Date.now() - 60_000).toISOString(), now, new AbortController().signal) lark.mockResolvedValueOnce({ has_more: false, items: [message({ chat_id: 'oc_other' })] }) await expect(recover()).rejects.toThrow('outside the requested chat or thread') // A topic's page that holds a message of another topic. h.store.set('feishu-history-threads:oc_team', { omt_1: now }) lark.mockResolvedValueOnce({ has_more: false, items: [] }).mockResolvedValueOnce({ has_more: false, items: [message({ thread_id: 'omt_2' })] }) await expect(recover()).rejects.toThrow('outside the requested chat or thread') h.store.set('feishu-history-threads:oc_team', {}) const stuck = { has_more: true, page_token: 'p1', items: [] } lark.mockResolvedValueOnce(stuck).mockResolvedValueOnce(stuck) await expect(recover()).rejects.toThrow('did not advance') lark.mockResolvedValueOnce({ has_more: true, items: [] }) await expect(recover()).rejects.toThrow('did not advance') h.store.close() }) it('stops reading a polled topic at the first message it knew, once the topic\'s first poll ran', async () => { const h = host() const source = feishuTrigger({ message_id: 'om_root', chat_id: 'oc_1', chat_type: 'group', sender_id: MEMBER, sender_type: 'user', content: '@bot fix', create_time: '1', mentions: [{ id: 'ou_bot' }] }, 'ou_bot', 'cli_bot')! h.store.triggers.save(source) subscribed(h, source.threads[0]!, { chatId: 'oc_1', messageId: 'om_root', sourceTriggerId: source.id }) h.store.threads.save({ ...h.store.threads.get('feishu:oc_1:om_root')!, lastAt: new Date().toISOString() }) h.store.set('thread-seen:feishu:oc_1:om_root', ['om_2']) h.store.set('thread-initialized:feishu:oc_1:om_root', true) const reply = (id: string) => ({ message_id: id, chat_id: 'oc_1', msg_type: 'text', content: `reply ${id}`, create_time: String(Date.now()), sender: { id: 'ou_guest', sender_type: 'user' }, thread_id: 'omt_1', root_id: 'om_root' }) const lark = vi.spyOn(h.commands, 'lark').mockImplementation(async args => { if (args[0] === 'api') return { items: [{ message_id: 'om_root', chat_id: 'oc_1', thread_id: 'omt_1' }] } if (args[1] === '+messages-mget') return { messages: [] } // Newest first: a new reply, then the one the Thread knew, and an older page that is not read. return { thread_id: 'omt_1', has_more: true, page_token: 'older', messages: [reply('om_3'), reply('om_2')] } }) await pollFeishuThreads(h) expect(h.receive.mock.calls.map(([trigger]) => trigger.meta.messageId)).toEqual(['om_3']) expect(lark.mock.calls.filter(([args]) => args[1] === '+threads-messages-list')).toHaveLength(1) h.store.close() }) it('recovers the topics of a chat while they had a message in the last two weeks, and forgets quieter ones', async () => { const h = host() const day = 24 * 3600_000 const start = Date.parse('2026-09-01T00:00:00.000Z') h.store.set('feishu-history-threads:oc_team', { omt_old: new Date(start).toISOString() }) let round = 0 const scans: string[] = [] vi.spyOn(h.commands, 'withSignal').mockReturnValue(h.commands) vi.spyOn(h.commands, 'lark').mockImplementation(async args => { const params = JSON.parse(args[args.indexOf('--params') + 1]!) as { container_id_type: string; container_id: string } scans.push(params.container_id) // Each day's chat scan yields one new thread; thread scans return no further messages. return { has_more: false, items: params.container_id_type === 'chat' ? [{ message_id: `om_${round}`, chat_id: 'oc_team', create_time: String(start + round * day), thread_id: `omt_${round}`, sender: { id: 'ou_bob', sender_type: 'user' }, body: { content: '{"text":"hi"}' } }] : [] } }) for (round = 0; round < 30; round++) { scans.length = 0 const since = new Date(start + round * day - 60_000).toISOString() await backfillChat(h, 'oc_team', false, since, new Date(start + round * day + 60_000).toISOString(), new AbortController().signal) if (round === 0) expect(scans).toContain('omt_old') } // The chat, and the topics with a message in the last 14 days. expect(scans).toHaveLength(1 + 14) expect(Object.keys(h.store.get>('feishu-history-threads:oc_team')!)).toHaveLength(14) expect(h.store.get>('feishu-history-threads:oc_team')!.omt_29).toBe(new Date(start + 29 * day).toISOString()) h.store.close() }) it('skips and counts a history message it cannot read, and takes in the others', async () => { const h = host() vi.spyOn(h.commands, 'withSignal').mockReturnValue(h.commands) const item = (id: string) => ({ message_id: id, chat_id: 'oc_team', create_time: String(Date.now()), sender: { id: MEMBER, sender_type: 'user' }, body: { content: '{"text":"@bot fix it"}' } }) vi.spyOn(h.commands, 'lark').mockResolvedValue({ has_more: false, items: [item('om_good'), { message_id: 'om_bad', chat_id: 'oc_team' }] }) const tally = { skipped: 0 } const since = new Date(Date.now() - 60_000).toISOString() await backfillChat(h, 'oc_team', false, since, new Date().toISOString(), new AbortController().signal, tally) expect(tally.skipped).toBe(1) expect(h.receive.mock.calls.map(([trigger]) => trigger.meta.messageId)).toEqual(['om_good']) h.store.close() }) it('waits a second before restarting the event consumer after a run that stayed up for a minute, and backs off otherwise', () => { expect(consumerRetry(undefined, 0)).toBe(1000) expect(consumerRetry(1000, 500)).toBe(2000) expect(consumerRetry(32_000, 500)).toBe(60_000) expect(consumerRetry(60_000, 120_000)).toBe(1000) }) it('syncs its part of the Policy from the sync chat: people who left are removed, names follow the chat, new people are appended', async () => { const h = host() // The sync reads the chat under its signal, so stopping the sync stops the read. const withSignal = vi.spyOn(h.commands, 'withSignal').mockReturnValue(h.commands) const lark = vi.spyOn(h.commands, 'lark').mockResolvedValue({ users: [{ member_id: 'ou_carol', name: 'Carol' }, { member_id: MEMBER, name: 'Alice Li' }], has_more: false }) const sync = feishuKind.policy!.sync! expect(sync.ready({ allowed: [] })).toBe(false) const signal = new AbortController().signal expect(await sync.run(h, { allowed: [{ id: MEMBER, name: 'Alice' }, { id: 'ou_bob', name: 'Bob' }], syncChat: 'oc_team' }, signal)) .toEqual({ allowed: [{ id: MEMBER, name: 'Alice Li' }, { id: 'ou_carol', name: 'Carol' }], syncChat: 'oc_team' }) expect(withSignal).toHaveBeenCalledWith(signal) expect(lark.mock.calls[0]![0]).toEqual(expect.arrayContaining(['+chat-members-list', '--chat-id', 'oc_team'])) lark.mockResolvedValue({ users: [], has_more: true }) await expect(sync.run(h, { allowed: [], syncChat: 'oc_team' }, new AbortController().signal)).rejects.toThrow('incomplete member list') h.store.close() }) }) describe('github source', () => { it('normalizes comments and reviews into triggers with review-thread channels, and marks only GitHub Apps and machine accounts as automated', () => { const trigger = commentTrigger({ id: 5, body: 'nit', user: { login: 'review-bot[bot]', type: 'Bot' }, html_url: 'https://github.com/acme/widgets/pull/7#discussion_r5', updated_at: '2026-09-01T00:00:00Z' }, { number: 7, isPr: true, prAuthor: 'agent-bot' }, 'inline', 'agent-bot') expect(trigger).toMatchObject({ source: 'github', threads: [{ source: 'github', id: '7' }], human: false, channel: { address: { number: '7', url: 'https://github.com/acme/widgets/pull/7#discussion_r5' } }, meta: { commentId: 5, kind: 'inline', prAuthor: 'agent-bot' } }) // A review bot on an ordinary account counts as a person here; the prompts name it as automated. expect(commentTrigger({ id: 6, body: 'x', user: { login: 'review-bot', type: 'User' } }, { number: 7, isPr: false }, 'conversation', 'agent-bot').human).toBe(true) expect(commentTrigger({ id: 7, body: 'x', user: { login: 'ci[bot]', type: 'Bot' } }, { number: 7, isPr: false }, 'conversation', 'agent-bot').human).toBe(false) // A review decision reaches the ticket even without a body; a plain review comment carries only its body. const review = (fields: Partial) => commentTrigger({ id: 8, user: { login: 'carol', type: 'User' }, submitted_at: '2026-09-01T00:00:00Z', ...fields }, { number: 7, isPr: true }, 'review', 'agent-bot').text expect(review({ state: 'APPROVED', commit_id: 'abc123' })).toBe('APPROVED review of commit abc123') expect(review({ state: 'CHANGES_REQUESTED', commit_id: 'abc123', body: 'Keep the margin' })).toBe('CHANGES_REQUESTED review of commit abc123\n\nKeep the margin') expect(review({ state: 'COMMENTED', commit_id: 'abc123' })).toBe('') // A mention of the agent addresses it, whatever the case; the agent's own messages are automated. expect(commentTrigger({ id: 9, body: '@Agent-Bot please fix', user: { login: 'alice', type: 'User' } }, { number: 7, isPr: true }, 'conversation', 'agent-bot')).toMatchObject({ human: true, addressed: 'mention' }) expect(commentTrigger({ id: 10, body: 'mail@agent-bot.example', user: { login: 'alice', type: 'User' } }, { number: 7, isPr: true }, 'conversation', 'agent-bot')).not.toHaveProperty('addressed') expect(commentTrigger({ id: 11, body: 'pushed', user: { login: 'Agent-Bot', type: 'User' } }, { number: 7, isPr: true }, 'conversation', 'agent-bot').human).toBe(false) }) it('reads the address of an Issue, a PR or a review comment of the repository, and nothing else', () => { expect(githubAddress('https://api.github.com/repos/acme/widgets/pulls/9', 'acme/widgets')).toEqual({ number: 9, pr: true }) expect(githubAddress('https://api.github.com/repos/Acme/Widgets/issues/12', 'acme/widgets')).toEqual({ number: 12, pr: false }) expect(githubAddress('https://github.com/acme/widgets/pull/7/#discussion_r5', 'acme/widgets')).toEqual({ number: 7, pr: true, discussion: 5 }) expect(githubAddress('https://github.com/acme/widgets/issues/7#issuecomment-3', 'acme/widgets')).toEqual({ number: 7, pr: false }) for (const url of ['https://github.com/acme/gadgets/pull/7', 'https://api.github.com/repos/acme/widgets/commits/abc', 'https://github.com/acme/widgets/pull/7/files', '']) { expect(githubAddress(url, 'acme/widgets')).toBeUndefined() } }) it('matches a branch to a ticket by its short name as the last path segment', () => { expect(ticketBranch({ execution: execution('fix-title-1a2b3c') }, 'agent/fix-title-1a2b3c')).toBe(true) expect(ticketBranch({ execution: execution('fix-title-1a2b3c') }, 'fix-title-1a2b3c')).toBe(true) expect(ticketBranch({ execution: execution('fix-title-1a2b3c') }, 'agent/fix-title-1a2b3c-2')).toBe(false) expect(ticketBranch({}, 'agent/x')).toBe(false) }) it('describes CI as one fact: failed checks first, then unfinished ones, otherwise passed', () => { const counts = ciCounts({ head: 'abc', checks: [{ name: 'e2e', status: 'completed', conclusion: 'failure' }, { name: 'lint', status: 'in_progress', conclusion: null }, { name: 'docs', status: 'completed', conclusion: 'skipped' }], statuses: [{ context: 'deploy', state: 'error' }, { context: 'preview', state: 'pending' }] }) expect(counts).toEqual({ failed: 2, running: 2 }) expect(ciFact(counts)).toEqual({ label: { zh: 'CI 失败', en: 'CI failed' }, value: '2', problem: true }) expect(ciFact({ failed: 0, running: 1 })).toEqual({ label: { zh: 'CI 进行中', en: 'CI running' }, value: '1' }) expect(ciFact({ failed: 0, running: 0 })).toEqual({ label: 'CI', value: { zh: '通过', en: 'passed' } }) }) it('starts a review comment\'s text with its file, line and diff lines, and keeps them and the comment it answers', () => { const comment: GithubComment = { id: 5, body: 'Use the theme token', user: { login: 'carol', type: 'User' }, path: 'src/render.ts', line: 43, diff_hunk: '@@ -40,6 +40,8 @@\n const title = view.title\n const margin = 12\n+ const padding = 8', in_reply_to_id: 4, html_url: 'https://github.com/acme/widgets/pull/9#discussion_r5', created_at: '2026-09-09T00:00:00Z' } const trigger = commentTrigger(comment, { number: 9, isPr: true }, 'inline', 'agent-bot') expect(trigger.text).toBe('src/render.ts:43\n const title = view.title\n const margin = 12\n+ const padding = 8\n\nUse the theme token') expect(trigger.meta).toMatchObject({ path: 'src/render.ts', line: 43, inReplyTo: 4, commentId: 5, kind: 'inline' }) }) it('gives an assessment of a review comment the earlier comments of its review thread', async () => { const h = host() const comment = (id: number, reply?: number) => ({ id, body: `comment ${id}`, user: { login: 'carol', type: 'User' }, path: 'src/render.ts', line: 43, html_url: `https://github.com/acme/widgets/pull/9#discussion_r${id}`, created_at: `2026-09-09T00:00:0${id}Z`, ...reply ? { in_reply_to_id: reply } : {} }) vi.spyOn(h.commands, 'pages').mockResolvedValue([comment(1), comment(2, 1), comment(3), comment(4, 1)]) const trigger = commentTrigger(comment(4, 1), { number: 9, isPr: true }, 'inline', 'agent-bot') const context = h.sources.threadContext(trigger)! const thread = await context.read(h.commands, 'thread') expect(thread).toMatchObject({ status: 'review_thread' }) expect(thread.messages.map(message => message.id)).toEqual([1, 2, 4].map(id => `https://github.com/acme/widgets/pull/9#discussion_r${id}`)) expect(context.messageId).toBe(thread.messages[2]!.id) expect((await context.read(h.commands, 'parent')).messages.map(message => message.id)).toEqual([thread.messages[0]!.id]) h.store.close() }) it('starts a CI Trigger\'s text with what failed or still runs, so its preview says it', () => { const check = (name: string, status: string, conclusion: string | null) => ({ name, status, conclusion }) const failing = { head: 'abc1234def', checks: [check('lint', 'completed', 'failure'), check('e2e', 'completed', 'timed_out'), check('unit', 'completed', 'success'), check('docs', 'in_progress', null)], statuses: [] } const read = 'github/pulls/9/checks' expect(ciSummary(failing, 'PR #9', read)).toBe('CI failed 2/4 on PR #9 at abc1234: lint, e2e; 1 still running\nRead github/pulls/9/checks for every check.') expect(ciSummary({ ...failing, checks: [check('docs', 'queued', null)] }, 'PR #9', read)).toMatch(/^CI running 1\/1 on PR #9 at abc1234: docs/) expect(ciSummary({ ...failing, checks: [check('unit', 'completed', 'success')] }, 'PR #9', read)).toMatch(/^CI passed 1\/1 on PR #9 at abc1234/) }) it('binds a PR to the unique live ticket on its branch and ends tickets when the PR closes', async () => { const h = host() const decisionId = 'd' const request = feishuTrigger({ message_id: 'om_1', chat_id: 'oc_1', chat_type: 'group', sender_id: MEMBER, sender_type: 'user', content: '@bot 修一下标题', create_time: '1', mentions: [{ id: 'ou_bot' }] }, 'ou_bot', 'cli_bot')! h.store.triggers.save(request) h.store.tickets.save({ id: 't1', title: 'one', decisionId, mandate: { strength: 'explicit', policyVersion: '', basis: { via: 'listed' }, actor: { source: 'feishu', id: MEMBER }, scope: '' }, channels: [{ source: 'feishu', thread: 'feishu:oc_1', address: { chatId: 'oc_1', messageId: 'om_1' } }], assignments: [{ id: 'a', state: 'running', createdAt: '' }], inputs: [], undelivered: [], createdAt: '', updatedAt: '', execution: execution('one'), origin: { triggerId: request.id, triggerIds: [request.id] } }) const agentsPull = (number: number): Pull => ({ ...openPull(number), user: { login: 'agent-bot' }, head: { sha: 'abc', ref: 'agent/one', repo: { full_name: 'acme/widgets' } } }) await observePull(h, agentsPull(9)) expect(pullOf(h.store.tickets.get('t1')!)).toBe(9) expect(h.store.threads.get('github:9')?.subscriptions).toEqual(['t1']) // A ticket that follows a PR moves to another only by its Executor's report. await observePull(h, agentsPull(10)) expect(pullOf(h.store.tickets.get('t1')!)).toBe(9) expect(await observePull(h, endedPull(9, '2026-09-02T00:00:00Z'))).toBe(true) expect(h.store.tickets.get('t1')).toMatchObject({ ended: { by: 'executor', outcome: 'merged', at: '2026-09-02T00:00:00.000Z' } }) expect(h.store.threads.get('github:9')?.subscriptions).toEqual(['t1']) // The notice its end gives follows the language of the source request. const notices = pullEndNotices(h.store.tickets.get('t1')!, request.text, 'acme/widgets') expect(notices).toEqual([expect.objectContaining({ kind: 'notice', channel: expect.objectContaining({ source: 'feishu' }), text: expect.stringContaining('已合并') })]) expect(h.executor.stop).toHaveBeenCalledOnce() // Resumed after the merge, the ticket works on: a later look at the merged PR reports nothing again. h.store.tickets.save({ ...h.store.tickets.get('t1')!, ended: undefined, assignments: [...h.store.tickets.get('t1')!.assignments, { id: 'a2', state: 'pending', createdAt: '2026-09-03T00:00:00Z' }] }) expect(await observePull(h, endedPull(9, '2026-09-02T00:00:00Z'))).toBe(true) expect(h.store.tickets.get('t1')!.ended).toBeUndefined() // Once the resumed work reports ready, the ticket ends with the merged PR, at that time and without a second notice. h.engine.report('t1', { state: 'ready', summary: 'answered' }) expect(h.store.tickets.get('t1')).toMatchObject({ ended: { outcome: 'merged' } }) expect(Date.parse(h.store.tickets.get('t1')!.ended!.at)).toBeGreaterThan(Date.parse('2026-09-03T00:00:00Z')) // Its notice has the id of the first one, so it goes out once. expect(pullEndNotices(h.store.tickets.get('t1')!, request.text, 'acme/widgets').map(notice => notice.id)).toEqual(notices.map(notice => notice.id)) h.store.close() }) it('keeps a ticket working on its other open PR when one of its PRs merges', async () => { const h = host() h.store.tickets.save({ id: 't3', title: 'three', decisionId: 'd3', mandate: { strength: 'explicit', policyVersion: '', basis: { via: 'listed' }, actor: { source: 'feishu', id: MEMBER }, scope: '' }, channels: [], assignments: [{ id: 'a1', state: 'running', createdAt: '2026-09-01T00:00:00Z' }], inputs: [], undelivered: [], execution: execution('fix-3'), origin: { triggerId: 'x', triggerIds: [] }, createdAt: '2026-09-01T00:00:00Z', updatedAt: '2026-09-01T00:00:00Z' }) expect(claimPull(h, 't3', agentsPull(21, 'agent/fix-3'))).toBeUndefined() expect(claimPull(h, 't3', agentsPull(22, 'agent/fix-3'))).toBeUndefined() expect(await observePull(h, endedPull(22, '2026-09-02T00:00:00Z'))).toBe(true) expect(h.store.tickets.get('t3')).toMatchObject({ follows: [{ thread: 'github:21', own: true }, { thread: 'github:22', own: true, closed: { at: '2026-09-02T00:00:00.000Z', outcome: 'merged' } }] }) expect(h.store.tickets.get('t3')).not.toHaveProperty('ended') expect(pullOf(h.store.tickets.get('t3')!)).toBe(21) expect(h.executor.stop).not.toHaveBeenCalled() expect(await observePull(h, endedPull(21, '2026-09-03T00:00:00Z'))).toBe(true) expect(h.store.tickets.get('t3')).toMatchObject({ ended: { outcome: 'merged', at: '2026-09-03T00:00:00.000Z' } }) // A PR's end is kept on the tickets that follow it, so binding and closing write no key-value record. expect(h.store.keys('github-')).toEqual([]) h.store.close() }) it('follows a PR it opened again when it reopens, while another PR it opened is open', async () => { const h = host() h.store.tickets.save({ ...openTicket('t5'), execution: execution('fix-5'), follows: [{ thread: 'github:21', own: true }, { thread: 'github:22', own: true, closed: { at: '2026-09-02T00:00:00.000Z', outcome: 'closed' } }] }) await observePull(h, agentsPull(22, 'agent/fix-5')) expect(h.store.tickets.get('t5')!.follows).toEqual([{ thread: 'github:21', own: true }, { thread: 'github:22', own: true }]) h.store.close() }) it('ends a ticket when its own PR merges, though the PR it started from is still open', async () => { const h = host() h.store.tickets.save({ id: 't4', title: 'four', decisionId: 'd4', mandate: { strength: 'explicit', policyVersion: '', basis: { via: 'listed' }, actor: { source: 'github', id: 'alice' }, scope: '' }, channels: [githubChannel(30, 'https://github.com/acme/widgets/pull/30#issuecomment-1')], assignments: [{ id: 'a1', state: 'running', createdAt: '2026-09-01T00:00:00Z' }], inputs: [], undelivered: [], execution: execution('fix-4'), origin: { triggerId: 'x', triggerIds: [] }, createdAt: '2026-09-01T00:00:00Z', updatedAt: '2026-09-01T00:00:00Z' }) h.store.threads.save({ key: 'github:30', ref: { source: 'github', id: '30' }, createdAt: '', subscriptions: ['t4'], correlated: [], pending: [], meta: { number: 30, isPr: true } }) expect(claimPull(h, 't4', agentsPull(31, 'agent/fix-4'))).toBeUndefined() expect(await observePull(h, endedPull(31, '2026-09-02T00:00:00Z'))).toBe(true) expect(h.store.tickets.get('t4')).toMatchObject({ ended: { outcome: 'merged' } }) h.store.close() }) it('writes the terminal notice in English for a request without Han characters', async () => { const h = host() const request = feishuTrigger({ message_id: 'om_2', chat_id: 'oc_1', chat_type: 'group', sender_id: MEMBER, sender_type: 'user', content: '@bot fix the title', create_time: '1', mentions: [{ id: 'ou_bot' }] }, 'ou_bot', 'cli_bot')! h.store.triggers.save(request) h.store.tickets.save({ id: 't2', title: 'two', decisionId: 'd2', mandate: { strength: 'explicit', policyVersion: '', basis: { via: 'listed' }, actor: { source: 'feishu', id: MEMBER }, scope: '' }, channels: [{ source: 'feishu', thread: 'feishu:oc_1', address: { chatId: 'oc_1', messageId: 'om_2' } }], assignments: [], inputs: [], undelivered: [], createdAt: '', updatedAt: '', ...pulled(12), origin: { triggerId: request.id, triggerIds: [request.id] } }) expect(await observePull(h, endedPull(12, '2026-09-02T00:00:00Z', false))).toBe(true) expect(pullEndNotices(h.store.tickets.get('t2')!, request.text, 'acme/widgets')) .toEqual([expect.objectContaining({ text: '[PR #12](https://github.com/acme/widgets/pull/12) was closed without merging.' })]) h.store.close() }) it('polls subscribed PR threads since their checkpoint and skips the bot\'s own comments', async () => { const h = host() const thread: Thread = { key: 'github:7', ref: { source: 'github', id: '7' }, createdAt: '2026-09-01T00:00:00Z', lastAt: new Date().toISOString(), subscriptions: ['t'], correlated: [], pending: [], meta: { number: 7, isPr: true } } h.store.threads.save(thread) const comment = (id: number, login: string, at: string, body: string) => ({ id, body, user: { login }, created_at: at, updated_at: at }) github(h, { 'issues/7': { ...issue(7, { title: 'Fix the margin' }), pull_request: { url: 'x' } }, 'pulls/7': openPull(7), 'issues/7/comments': [comment(1, 'agent-bot', '2026-09-01T01:00:00Z', 'mine'), comment(2, 'a', '2026-08-01T00:00:00Z', 'old'), comment(3, 'b', '2026-09-01T02:00:00Z', 'new')] }) await pollGithubThreads(h) expect(h.receive).toHaveBeenCalledTimes(1) expect(h.receive.mock.calls[0]![0]).toMatchObject({ actor: { id: 'b' }, text: 'new', threads: [{ source: 'github', id: '7' }] }) expect(h.store.get('github-cursor:7')).toBeDefined() // A Thread whose title the Source had not read yet is named by its number and title, with its number as a fact. expect(h.store.threads.get('github:7')!.meta).toMatchObject({ title: 'Fix the margin', label: '#7 Fix the margin', facts: { number: { value: '#7' } } }) h.store.close() }) it('polls a subscribed Thread for two weeks after the Ticket that follows it ended', async () => { const h = host() const day = 24 * 3600_000 h.store.threads.save({ key: 'github:7', ref: { source: 'github', id: '7' }, createdAt: '2026-09-01T00:00:00Z', lastAt: new Date(Date.now() - 30 * day).toISOString(), subscriptions: ['t1'], correlated: [], pending: [], meta: { number: 7, isPr: false, title: 'Login fails' } }) const ended = (ago: number) => h.store.tickets.save({ ...openTicket('t1'), ended: { at: new Date(Date.now() - ago).toISOString(), by: 'executor', reason: 'merged', outcome: 'merged' } }) const reads = github(h, {}) ended(3 * day) await pollGithubThreads(h) expect(bare(reads)).toEqual(['issues/7/comments']) ended(15 * day) await pollGithubThreads(h) expect(bare(reads)).toEqual(['issues/7/comments']) h.store.close() }) it('polls a subscribed Thread while a live ticket follows it or its last Trigger is under two weeks old', async () => { const h = host() const day = 24 * 3600_000 const thread = (lastAt: number, subscriptions = ['t']) => h.store.threads.save({ key: 'github:7', ref: { source: 'github', id: '7' }, createdAt: '2026-09-01T00:00:00Z', lastAt: new Date(Date.now() - lastAt).toISOString(), subscriptions, correlated: [], pending: [], meta: { number: 7, isPr: false, title: 'Login fails' } }) const reads = github(h, {}) thread(15 * day) await pollGithubThreads(h) expect(reads).toEqual([]) thread(13 * day) await pollGithubThreads(h) expect(bare(reads)).toEqual(['issues/7/comments']) // A live ticket keeps a quiet Thread polled. h.store.tickets.save(openTicket('t9')) thread(15 * day, ['t9']) await pollGithubThreads(h) expect(bare(reads)).toEqual(['issues/7/comments', 'issues/7/comments']) h.store.close() }) it('takes in a review decision without a body and skips a plain review comment without one', async () => { const h = host() h.store.threads.save({ key: 'github:7', ref: { source: 'github', id: '7' }, createdAt: '2026-09-01T00:00:00Z', lastAt: new Date().toISOString(), subscriptions: ['t'], correlated: [], pending: [], meta: { number: 7, isPr: true, title: 'Fix the margin' } }) github(h, { 'pulls/7': openPull(7), 'pulls/7/reviews': [ { id: 4, state: 'APPROVED', commit_id: 'abc123', user: { login: 'carol', type: 'User' }, submitted_at: '2026-09-02T00:00:00Z' }, { id: 5, state: 'COMMENTED', commit_id: 'abc123', body: '', user: { login: 'dave', type: 'User' }, submitted_at: '2026-09-02T00:01:00Z' }, ] }) await pollGithubThreads(h) expect(h.receive).toHaveBeenCalledTimes(1) expect(h.receive.mock.calls[0]![0]).toMatchObject({ actor: { id: 'carol' }, text: 'APPROVED review of commit abc123', meta: { kind: 'review' } }) h.store.close() }) it('reads each PR once per round and takes its end, auto-merge and CI from that read', async () => { const h = host() h.store.tickets.save({ ...openTicket('t9'), ...pulled(9) }) h.store.threads.save({ key: 'github:9', ref: { source: 'github', id: '9' }, createdAt: '', lastAt: new Date().toISOString(), subscriptions: ['t9'], correlated: [], pending: [], meta: { number: 9, isPr: true, title: 'Fix' } }) const pr = { number: 9, title: 'Fix', state: 'open', auto_merge: { merge_method: 'merge' } as unknown, html_url: 'https://github.com/acme/widgets/pull/9', user: { login: 'agent-bot' }, head: { sha: 'abc', ref: 'agent/nine', repo: { full_name: 'acme/widgets' } } } const checks = { check_runs: [{ name: 'test', status: 'in_progress', conclusion: null }] } const routes: Record = { notifications: [notification(9, 'pulls')], 'pulls/9': pr, 'commits/abc/check-runs': checks } const reads = github(h, routes) await pollRound(h) // The notification, the ticket's PR and the subscribed Thread share one read of the PR. expect(reads.filter(path => path === 'pulls/9')).toHaveLength(1) const facts = () => h.store.threads.get('github:9')!.meta.facts as Record expect(facts()).toMatchObject({ autoMerge: { label: { zh: '自动合并', en: 'Auto-merge' } }, ci: { value: '1' } }) routes['pulls/9'] = { ...pr, auto_merge: null } routes['commits/abc/check-runs'] = { check_runs: [{ name: 'test', status: 'completed', conclusion: 'failure' }] } await pollRound(h) expect(facts()).not.toHaveProperty('autoMerge') // Only a change after the first look is a Trigger. expect(h.receive.mock.calls.map(([trigger]) => trigger.meta)).toEqual([expect.objectContaining({ ci: true, revision: 1 })]) routes['pulls/9'] = { ...pr, state: 'closed', merged: true, merged_at: '2026-09-09T00:00:00Z' } await pollRound(h) expect(h.store.tickets.get('t9')).toMatchObject({ ended: { outcome: 'merged' } }) h.store.close() }) it('reads each notified Issue alone and reads a failed one again next round, from where it was', async () => { const h = host() h.store.set('github-since', '2026-09-08T00:00:00.000Z') h.store.threads.save({ key: 'github:12', ref: { source: 'github', id: '12' }, createdAt: '', subscriptions: [], correlated: [], pending: [], meta: {} }) const mention = (id: number, number: number, at: string): GithubComment => ({ id, body: '@agent-bot take this', user: { login: 'alice', type: 'User' }, html_url: `https://github.com/acme/widgets/issues/${number}#issuecomment-${id}`, created_at: at, updated_at: at }) const routes: Record = { notifications: [notification(12), notification(13)], 'issues/12': refused(502, 'Bad Gateway'), 'issues/13': issue(13, { created_at: '2026-08-01T00:00:00Z' }), 'issues/13/comments': [mention(1, 13, '2026-09-08T01:00:00Z')] } github(h, routes) // A read that failed for another reason than a missing Issue fails the round once every step ran. await expect(pollRound(h)).rejects.toThrow('#12: Error: gh: Bad Gateway (HTTP 502)') expect(h.receive.mock.calls.map(([trigger]) => trigger.meta.number)).toEqual([13]) // The cursor moved together with the Issue it does not cover yet. expect(Date.parse(h.store.get('github-since')!)).toBeGreaterThan(Date.parse('2026-09-08T00:00:00.000Z')) expect(h.store.get('github-retry')).toEqual({ 12: { since: '2026-09-08T00:00:00.000Z', pr: false, attempts: 1, failedAt: expect.any(String) } }) expect(h.store.threads.get('github:12')!.meta.pollError).toContain('Bad Gateway') Object.assign(routes, { notifications: [], 'issues/12': issue(12, { created_at: '2026-08-01T00:00:00Z' }), 'issues/12/comments': [mention(2, 12, '2026-09-08T02:00:00Z')] }) await pollRound(h) expect(h.receive.mock.calls.map(([trigger]) => trigger.meta.number)).toEqual([13, 12]) expect(h.store.get('github-retry')).toEqual({}) expect(h.store.threads.get('github:12')!.meta).not.toHaveProperty('pollError') h.store.close() }) it('drops an Issue GitHub reports as deleted after recording it, and reads one it answers 404 for again', async () => { const h = host() h.store.set('github-since', '2026-09-08T00:00:00.000Z') h.store.tickets.save(openTicket('t14')) h.store.threads.save({ key: 'github:14', ref: { source: 'github', id: '14' }, createdAt: '', lastAt: new Date().toISOString(), subscriptions: ['t14'], correlated: [], pending: [], meta: { number: 14, isPr: false, title: 'Gone' } }) const deleted = refused(410, 'This issue was deleted') github(h, { notifications: [notification(14), notification(15)], 'issues/14': deleted, 'issues/15': deleted }) await pollRound(h) expect(h.store.get('github-retry')).toEqual({}) // Dropping a read moves no read position. expect(h.store.get('github-cursor:14')).toBeUndefined() expect(h.store.threads.get('github:14')!.meta.pollError).toContain('deleted') // Without a Thread to keep it, the Source's health names it. expect(h.health.github).toMatchObject({ state: 'ready', detail: '2 notifications; next poll ≥ 60s; deleted on GitHub: #14, #15' }) // GitHub also answers 404 for a private repository the agent lost access to: that is a failure read again. github(h, { notifications: [notification(16)] }) await expect(pollRound(h)).rejects.toThrow('#16: Error: gh: Not Found (HTTP 404)') expect(h.store.get('github-retry')).toEqual({ 16: expect.objectContaining({ pr: false }) }) h.store.close() }) it('skips and counts a notification or comment it cannot read, and names the count in the health', async () => { const h = host() h.store.set('github-since', '2026-09-08T00:00:00.000Z') const comment = { id: 3, body: '@agent-bot please fix', user: { login: 'alice', type: 'User' }, html_url: 'https://github.com/acme/widgets/issues/12#issuecomment-3', created_at: '2026-09-09T00:00:00Z', updated_at: '2026-09-09T00:00:00Z' } github(h, { notifications: [notification(12), { id: 'n-odd', subject: {} }], 'issues/12': issue(12, { created_at: '2026-08-01T00:00:00Z' }), 'issues/12/comments': [comment, { body: 'no id' }] }) await pollRound(h) expect(h.receive.mock.calls.map(([trigger]) => trigger.text)).toEqual(['@agent-bot please fix']) expect(h.health.github).toMatchObject({ state: 'ready', detail: '1 notifications; next poll ≥ 60s; 2 unreadable items skipped' }) h.store.close() }) it('reads every page of the notifications when GitHub says there are more, and waits as long as GitHub asks', async () => { const h = host() h.store.set('github-since', '2026-09-08T00:00:00.000Z') const calls: string[][] = [] vi.spyOn(h.commands, 'run').mockImplementation(async (_program, args) => { calls.push(args) const path = args[1]!.replace(/^repos\/acme\/widgets\//, '').split('?')[0]! if (path === 'notifications' && args.includes('--include')) { return 'HTTP/2.0 200 OK\r\nLink: ; rel="next"\r\nX-Poll-Interval: 120\r\n\r\n[]' } if (path === 'notifications') return JSON.stringify([[notification(12)], [notification(13)]]) if (/^issues\/\d+$/.test(path)) return JSON.stringify(issue(Number(path.split('/')[1]), { created_at: '2026-08-01T00:00:00Z' })) return JSON.stringify(args.includes('--slurp') ? [[]] : []) }) expect(await pollRound(h)).toBe(120) const notifications = calls.filter(args => args[1]!.includes('/notifications')) expect(notifications.map(args => args.includes('--paginate'))).toEqual([false, true]) const paths = calls.map(args => args[1]!.split('?')[0]) expect(paths).toEqual(expect.arrayContaining(['repos/acme/widgets/issues/12', 'repos/acme/widgets/issues/13'])) expect(h.health.github).toMatchObject({ state: 'ready', detail: '2 notifications; next poll ≥ 120s' }) h.store.close() }) it('drops a notified Issue whose reads fail for a day, so the Source leaves the error, and keeps its read position', async () => { const h = host() h.store.set('github-since', '2026-09-08T00:00:00.000Z') h.store.threads.save({ key: 'github:16', ref: { source: 'github', id: '16' }, createdAt: '', subscriptions: [], correlated: [], pending: [], meta: {} }) h.store.set('github-cursor:16', '2026-09-01T00:00:00.000Z') github(h, { notifications: [notification(16)] }) await expect(pollRound(h)).rejects.toThrow('#16: Error: gh: Not Found (HTTP 404)') const first = h.store.get>('github-retry')![16]! expect(first).toMatchObject({ attempts: 1, failedAt: expect.any(String) }) // However many reads failed, a day of failures has not passed: the read is retried, and the round still fails. h.store.set('github-retry', { 16: { ...first, attempts: 99 } }) github(h, { notifications: [] }) await expect(pollRound(h)).rejects.toThrow('#16') // A day after the first failed read, it is dropped: its Thread keeps the error, the health names it, and the round does not // fail. Its read position stays, so a later read takes in what the failed reads missed. h.store.set('github-retry', { 16: { ...first, failedAt: new Date(Date.now() - 25 * 3600_000).toISOString() } }) await pollRound(h) expect(h.store.get('github-retry')).toEqual({}) expect(h.store.get('github-cursor:16')).toBe('2026-09-01T00:00:00.000Z') expect(h.store.threads.get('github:16')?.meta.pollError).toContain('Not Found') expect(h.health.github).toMatchObject({ state: 'ready', detail: '0 notifications; next poll ≥ 60s; dropped after their reads kept failing: #16' }) h.store.close() }) it('polls the PRs of open tickets and subscribed Threads although the notifications fail, and then fails the round', async () => { const h = host() h.store.set('github-since', '2026-09-08T00:00:00.000Z') h.store.threads.save({ key: 'github:7', ref: { source: 'github', id: '7' }, createdAt: '2026-09-01T00:00:00Z', lastAt: new Date().toISOString(), subscriptions: ['t'], correlated: [], pending: [], meta: { number: 7, isPr: false, title: 'Login fails' } }) github(h, { notifications: refused(503, 'Service Unavailable'), 'issues/7/comments': [{ id: 3, body: 'still broken', user: { login: 'b', type: 'User' }, created_at: '2026-09-02T00:00:00Z', updated_at: '2026-09-02T00:00:00Z' }] }) await expect(pollRound(h)).rejects.toThrow('HTTP 503') expect(h.receive).toHaveBeenCalledWith(expect.objectContaining({ text: 'still broken' })) expect(h.store.get('github-since')).toBe('2026-09-08T00:00:00.000Z') h.store.close() }) it('takes in the description of a notified PR someone opened since the cursor, as a new PR\'s stream does', async () => { const h = host() h.store.set('github-since', '2026-09-08T00:00:00.000Z') const pr = { ...openPull(9), body: '@agent-bot please review', created_at: '2026-09-09T00:00:00Z' } github(h, { notifications: [notification(9, 'pulls')], 'pulls/9': pr }) await pollRound(h) const opened = h.receive.mock.calls.map(([trigger]) => trigger).filter(trigger => trigger.meta.kind === 'pull') const id = issueTrigger({ ...pr, id: 9 } as GithubIssue, 'agent-bot').id expect(opened).toEqual([expect.objectContaining({ id, addressed: 'mention', actor: { source: 'github', id: 'carol' }, meta: expect.objectContaining({ number: 9, isPr: true, prAuthor: 'carol' }) })]) // The agent's own PR, and one opened before the cursor, bring no description. h.receive.mockClear() h.store.set('github-since', '2026-09-08T00:00:00.000Z') github(h, { notifications: [notification(9, 'pulls')], 'pulls/9': { ...pr, user: { login: 'agent-bot' } } }) await pollRound(h) h.store.set('github-since', '2026-09-10T00:00:00.000Z') github(h, { notifications: [{ ...notification(9, 'pulls'), updated_at: '2026-09-11T00:00:00Z' }], 'pulls/9': pr }) await pollRound(h) expect(h.receive.mock.calls.filter(([trigger]) => trigger.meta.kind === 'pull')).toEqual([]) h.store.close() }) it('reads a subscribed default-branch CI Thread as no Issue, and keeps no poll error on it', async () => { const h = host() h.store.tickets.save(openTicket('ticket')) subscribed(h, { source: 'github', id: 'ci:main' }) h.store.threads.save({ ...h.store.threads.get('github:ci:main')!, lastAt: new Date().toISOString() }) const reads = github(h, {}) await pollGithubThreads(h) expect(reads).toEqual([]) expect(h.store.threads.get('github:ci:main')!.meta).not.toHaveProperty('pollError') h.store.close() }) it('drops an Issue or a ticket\'s PR that GitHub answers 410 for, and keeps a failed PR read on its Thread', async () => { const h = host() h.store.set('github-since', '2026-09-08T00:00:00.000Z') h.store.tickets.save({ ...openTicket('ticket'), follows: [{ thread: 'github:7', own: true }, { thread: 'github:8', own: true }] }) subscribed(h, { source: 'github', id: '5' }, { number: 5, isPr: false, title: 'Gone' }) h.store.threads.save({ ...h.store.threads.get('github:5')!, lastAt: new Date().toISOString() }) // The PRs' Threads, which nothing subscribes to, so only the ticket's PR reads reach them. for (const number of [7, 8]) { h.store.threads.save({ key: `github:${number}`, ref: { source: 'github', id: String(number) }, createdAt: '', subscriptions: [], correlated: [], pending: [], meta: {} }) } github(h, { notifications: [], 'issues/5/comments': refused(410, 'Gone'), 'pulls/7': refused(410, 'Gone') }) // Every PR read but #8's succeeds or is dropped; #8 fails like a network error. vi.mocked(h.commands.run).mockImplementation(async (_program, args) => { const path = args[1]!.replace(/^repos\/acme\/widgets\//, '').split('?')[0]! if (path === 'notifications') return 'HTTP/2.0 200 OK\r\n\r\n[]' if (path === 'issues/5/comments' || path === 'pulls/7') throw refused(410, 'Gone') if (path === 'pulls/8') throw refused(502, 'Bad Gateway') return JSON.stringify(args.includes('--slurp') ? [[]] : []) }) await expect(pollRound(h)).rejects.toThrow(/#8: .*502/) expect(h.store.threads.get('github:8')!.meta.pollError).toContain('502') expect(h.store.threads.get('github:7')!.meta.pollError).toContain('410') vi.mocked(h.commands.run).mockImplementation(async (_program, args) => { const path = args[1]!.replace(/^repos\/acme\/widgets\//, '').split('?')[0]! if (path === 'notifications') return 'HTTP/2.0 200 OK\r\n\r\n[]' if (path === 'issues/5/comments' || path === 'pulls/7' || path === 'pulls/8') throw refused(410, 'Gone') return JSON.stringify(args.includes('--slurp') ? [[]] : []) }) // With every failure a 410, the round succeeds and names what GitHub deleted. await pollRound(h) expect(h.health.github).toMatchObject({ state: 'ready', detail: expect.stringContaining('deleted on GitHub: #5, #7, #8') }) h.store.close() }) it('waits for setup, pauses while the Source is off, and counts only consecutive failed polls towards an error', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) try { const start = async (overrides: Partial, github: { enabled: boolean; config: Record } = { enabled: true, config: {} }) => { const board = new HealthBoard() const settings = SettingsSchema.parse({ sources: { github } }) const sources = new SourceRegistry(() => settings, board) healthOf.set(sources, board) const value = host({ sources, ...overrides }) const disposers: (() => unknown)[] = [] applyGithub({ orchestrator: value, effect: (effect: () => () => unknown) => { disposers.push(effect()) } } as never) return { value, stop: async () => { for (const dispose of disposers) await dispose(); value.store.close() } } } const pending = await start({ configured: () => false }) await vi.waitFor(() => expect(pending.value.health.github).toMatchObject({ state: 'paused', code: 'setupPending' })) await pending.stop() const off = await start({}, { enabled: false, config: {} }) await vi.waitFor(() => expect(off.value.health.github).toMatchObject({ state: 'paused', code: 'sourceOff' })) await off.stop() // Rounds fail, succeed, fail, fail: only the last two in a row make the error. A round reads through a Commands of its own. const outcomes = [false, true, false, false] let round = 0 const run = vi.spyOn(Commands.prototype, 'run').mockImplementation(async () => { if (!outcomes[round++]) throw refused(502, 'Bad Gateway') return 'HTTP/2.0 200 OK\r\n\r\n[]' }) const flaky = await start({}, { enabled: true, config: { failuresBeforeError: 2 } }) // Each round reads its notifications once, then waits a minute for the next. const until = async (count: number) => { while (round < count) { await vi.waitFor(() => expect(round).toBeGreaterThan(0)) if (round < count) await vi.advanceTimersByTimeAsync(60_000) } } await until(3) expect(flaky.value.health.github).toMatchObject({ state: 'ready' }) await until(4) await vi.waitFor(() => expect(flaky.value.health.github).toMatchObject({ state: 'error', code: 'pollFailures', values: { count: '2' } })) await flaky.stop() run.mockRestore() } finally { vi.useRealTimers() } }) it('reports a failure that escapes the polling loop as its health instead of an unhandled rejection', async () => { // The fixture's registry already holds the kind; this one starts empty so the plugin can register it. const value = host({ sources: emptyRegistry(), ready: Promise.reject(new Error('lock held')) }) const disposers: (() => unknown)[] = [] applyGithub({ orchestrator: value, effect: (effect: () => () => unknown) => { disposers.push(effect()) } } as never) await vi.waitFor(() => expect(value.health.github).toMatchObject({ state: 'error', detail: expect.stringContaining('lock held') })) for (const dispose of disposers) await dispose() value.store.close() }) /** An Issue as GitHub returns it, opened by alice. */ const issue = (number: number, fields: Partial = {}): GithubIssue => ({ id: 1000 + number, number, title: 'Login fails', body: 'details', state: 'open', created_at: '2026-09-09T00:00:00Z', html_url: `https://github.com/acme/widgets/issues/${number}`, user: { login: 'alice', type: 'User' }, ...fields }) /** GitHub as `pollRound` reads it after `since`: one notification per Issue, the Issues, and their conversation comments by number. */ function notify(h: ReturnType, issues: GithubIssue[], comments: Record = {}, since = '2026-09-08T00:00:00.000Z') { h.store.set('github-since', since) // A notification is as new as the latest change it reports: the Issue or its newest comment. const updated = (item: GithubIssue) => [item.updated_at ?? item.created_at, ...(comments[item.number] ?? []).map(comment => comment.updated_at ?? '')].sort().at(-1)! const notifications = issues.map(item => ({ id: `n${item.number}`, updated_at: updated(item), reason: 'mention', repository: { full_name: 'acme/widgets' }, subject: { url: `https://api.github.com/repos/acme/widgets/issues/${item.number}`, type: 'Issue', title: item.title } })) // An Issue that is a PR also answers as the PR. const routes: Record = { notifications, ...Object.fromEntries(issues.flatMap(item => [[`issues/${item.number}`, item], [`pulls/${item.number}`, openPull(item.number)], [`issues/${item.number}/comments`, comments[item.number] ?? []]])) } return { routes, reads: github(h, routes) } } it('takes in a new Issue from its notification as a Trigger with its body, addressed to the agent when the body mentions it', async () => { const h = host() // A pull request reported at its Issue address, and an Issue the agent opened itself, add nothing. const agents = { user: { login: 'agent-bot', type: 'User' } } notify(h, [issue(12, { body: '@agent-bot please fix the login' }), issue(13, { pull_request: { url: 'x' } }), issue(14, agents)]) await pollRound(h) expect(h.receive).toHaveBeenCalledOnce() const id = key('github-issue', '12') const url = 'https://github.com/acme/widgets/issues/12' expect(h.receive.mock.calls[0]![0]).toEqual({ id, source: 'github', threads: [{ source: 'github', id: '12' }], actor: { source: 'github', id: 'alice' }, human: true, at: '2026-09-09T00:00:00Z', text: 'Login fails\n\n@agent-bot please fix the login', addressed: 'mention', channel: githubChannel(12, url), url, meta: { number: 12, isPr: false, kind: 'issue' } }) // An allowed sender's mention is an explicit request on that poll. expect(h.engine.candidate(id)?.strength).toBe('explicit') expect(h.store.threads.get('github:12')!.meta).toMatchObject({ title: 'Login fails', label: '#12 Login fails' }) h.store.close() }) it('takes in a comment on an earlier Issue from its notification, without the Issue body', async () => { const h = host() const url = 'https://github.com/acme/widgets/issues/12' const comment: GithubComment = { id: 5, body: '@agent-bot can you take this?', user: { login: 'alice', type: 'User' }, html_url: `${url}#issuecomment-5`, created_at: '2026-09-09T00:00:00Z', updated_at: '2026-09-09T00:00:00Z' } const { reads } = notify(h, [issue(12, { created_at: '2026-08-01T00:00:00Z' })], { 12: [comment] }) await pollRound(h) const expected = commentTrigger(comment, { number: 12, isPr: false, url }, 'conversation', 'agent-bot') expect(h.receive.mock.calls.map(([trigger]) => trigger)).toEqual([expected]) // An Issue has only its conversation; the comments are read since the notification cursor. const since = encodeURIComponent('2026-09-08T00:00:00.000Z') expect(reads.filter(path => path.includes('/comments'))).toEqual([`issues/12/comments?per_page=100&since=${since}`]) h.store.close() }) it('keeps one body Trigger for an Issue that a later poll reads again', async () => { const h = host() // Opened just before the poll, the Issue is still after the cursor the next poll reads from. notify(h, [issue(12, { created_at: new Date(Date.now() - 1000).toISOString() })], {}, new Date(Date.now() - 60_000).toISOString()) await pollRound(h) await pollRound(h) expect(h.receive).toHaveBeenCalledTimes(2) expect(h.store.read.threadTriggers('github:12', 10).map(trigger => trigger.id)).toEqual([key('github-issue', '12')]) expect(h.store.decisions.byThread('github:12').map(decision => decision.triggerIds)).toEqual([[key('github-issue', '12')]]) h.store.close() }) it('takes in an Issue without a mention as a Trigger that is not addressed, and a bot\'s Issue as automated', async () => { const h = host() notify(h, [issue(12, { body: 'Login fails on Safari' }), issue(15, { user: { login: 'dependabot[bot]', type: 'Bot' } })]) await pollRound(h) const taken = (number: number) => h.receive.mock.calls.map(([trigger]) => trigger).find(trigger => trigger.meta.number === number) const [plain, bot] = [taken(12), taken(15)] expect(plain).toMatchObject({ id: key('github-issue', '12'), human: true }) expect(plain).not.toHaveProperty('addressed') expect(bot).toMatchObject({ id: key('github-issue', '15'), human: false }) // The Issue's own read position moves past what this round read. expect(Date.parse(h.store.get('github-cursor:12')!)).toBeGreaterThan(Date.parse('2026-09-08T00:00:00.000Z')) h.store.close() }) it('keeps an Issue body the unclaimed-Issue Source stored before as the one Trigger of that body', async () => { const h = host() const stored: Trigger = { id: key('github-issue', '12'), source: 'github-issue', threads: [{ source: 'github', id: '12' }], actor: { source: 'github', id: 'alice' }, human: true, at: '2026-09-09T00:00:00Z', text: 'Login fails\n\n@agent-bot please fix the login', meta: { number: 12, isPr: false, eligibleAt: '' } } h.store.triggers.save(stored) notify(h, [issue(12, { body: '@agent-bot please fix the login' })]) await pollRound(h) expect(await h.receive.mock.results[0]!.value).toBe('duplicate') expect(h.store.read.threadTriggers('github:12', 10)).toEqual([stored]) h.store.close() }) it('takes in an Issue body only when the Issue was opened since the cursor, not when an edit or a comment changed it', async () => { const h = host() // Whoever edits the body, its author would be the actor, so a mention added by an edit is not read. const edited = issue(12, { created_at: '2026-08-01T00:00:00Z', updated_at: '2026-09-09T00:00:00Z', body: 'Login fails. @agent-bot can you look?' }) notify(h, [edited]) await pollRound(h) expect(h.receive).not.toHaveBeenCalled() h.store.close() }) it('reads a PR that a notification names by its Issue address as the PR', async () => { const h = host() h.store.set('github-since', '2026-09-08T00:00:00.000Z') const review = { id: 4, state: 'APPROVED', commit_id: 'abc', user: { login: 'carol', type: 'User' }, submitted_at: '2026-09-09T00:00:00Z' } const reads = github(h, { notifications: [notification(9)], 'issues/9': { ...issue(9), pull_request: { url: 'x' } }, 'pulls/9/reviews': [review], 'pulls/9': { title: 'Fix', state: 'open', html_url: 'https://github.com/acme/widgets/pull/9', user: { login: 'carol' }, head: { sha: 'abc', ref: 'fix', repo: null } } }) await pollRound(h) expect(bare(reads)).toEqual(['notifications', 'issues/9', 'pulls/9', 'issues/9/comments', 'pulls/9/comments', 'pulls/9/reviews']) expect(h.receive.mock.calls.map(([trigger]) => trigger)).toEqual([expect.objectContaining({ text: 'APPROVED review of commit abc', meta: expect.objectContaining({ isPr: true }) })]) h.store.close() }) it('moves no read position when the Source is turned off during a read', async () => { const h = host() const comment: GithubComment = { id: 5, body: 'more', user: { login: 'alice', type: 'User' }, created_at: '2026-09-09T00:00:00Z', updated_at: '2026-09-09T00:00:00Z' } const { routes } = notify(h, [issue(12, { created_at: '2026-08-01T00:00:00Z' })], { 12: [comment] }) routes['issues/12/comments'] = () => { h.store.saveSettings({ ...h.store.settings(), sources: { ...h.store.settings().sources, github: { enabled: false, config: {} } } }) return [comment] } await pollRound(h) expect(h.receive).not.toHaveBeenCalled() expect(h.store.get('github-cursor:12')).toBeUndefined() expect(h.store.get('github-since')).toBe('2026-09-08T00:00:00.000Z') h.store.close() }) it('lets a ticket follow its source PR or the agent\'s PR from its branch, and refuses others', async () => { const h = host() const source = commentTrigger({ id: 1, body: '@agent-bot fix', user: { login: 'alice' }, html_url: 'https://github.com/acme/widgets/pull/30#issuecomment-1' }, { number: 30, isPr: true }, 'conversation', 'agent-bot') h.store.triggers.save(source) h.store.tickets.save({ ...openTicket('t1'), execution: execution('fix-1'), origin: { triggerId: source.id, triggerIds: [source.id] } }) h.store.tickets.save({ ...openTicket('t2'), execution: execution('other-2'), ...pulled(41) }) const agents = (number: number, ref: string, repo = 'acme/widgets'): Pull => ({ ...openPull(number), user: { login: 'agent-bot' }, head: { sha: 'abc', ref, repo: { full_name: repo } } }) expect(claimPull(h, 't1', agents(40, 'agent/fix-1', 'someone/fork'))).toContain('PR does not belong to this ticket') expect(claimPull(h, 't1', { ...openPull(31), head: { sha: 'abc', ref: 'agent/fix-1', repo: { full_name: 'acme/widgets' } } })).toContain('PR does not belong to this ticket') expect(claimPull(h, 't1', agents(41, 'agent/fix-1'))).toBe('PR already belongs to another active ticket') // A PR that is not open is not bound, and that is no refusal. expect(claimPull(h, 't1', { ...agents(42, 'agent/fix-1'), state: 'closed' })).toBeUndefined() expect(pullOf(h.store.tickets.get('t1')!)).toBeUndefined() expect(claimPull(h, 't1', openPull(30))).toBeUndefined() expect(h.store.tickets.get('t1')!.follows).toEqual([{ thread: 'github:30', own: false }]) expect(claimPull(h, 't1', agents(43, 'agent/fix-1'))).toBeUndefined() expect(h.store.tickets.get('t1')).toMatchObject({ follows: [{ thread: 'github:30', own: false }, { thread: 'github:43', own: true }], channels: [] }) expect(pullOf(h.store.tickets.get('t1')!)).toBe(43) expect(h.store.threads.get('github:43')).toMatchObject({ subscriptions: ['t1'], meta: { isPr: true, prAuthor: 'agent-bot' } }) // The PR the ticket started from is not one it opened: when its own PR merges, the ticket ends instead of following it. expect(await observePull(h, endedPull(43, '2026-09-02T00:00:00Z'))).toBe(true) expect(h.store.tickets.get('t1')).toMatchObject({ ended: { outcome: 'merged' } }) expect(claimPull(h, 't1', openPull(30))).toBe('Ticket ended because its PR was closed') h.store.close() }) }) describe('source context', () => { it('gives each kind the setup problem whose place it is', () => { const feishu: SetupProblem = { place: 'feishu', code: 'feishuNoBot', values: {}, detail: 'No bot' } const github: SetupProblem = { place: 'github', code: 'agentCliUnset', values: {}, detail: 'No CLI' } const h = host({ feishuProblem: feishu, setupProblem: github }) expect(h.context(feishuKind).problem).toBe(feishu) expect(h.context(githubKind).problem).toBe(github) expect(h.context({ kind: 'probe', health: [] }).problem).toBeUndefined() h.store.close() }) it('lets a kind report only its own health entries', () => { const h = host() const github = h.context(githubKind) github.report('github', { state: 'ready', detail: 'ok' }) expect(h.health.github).toEqual({ state: 'ready', detail: 'ok' }) expect(() => github.report('feishu', { state: 'error', detail: 'x' })).toThrow('no health entry feishu') h.store.close() }) it('patches a Thread\'s meta as it is now, so a change another writer made meanwhile stays', () => { const h = host() h.store.threads.save({ key: 'github:7', ref: { source: 'github', id: '7' }, createdAt: '', subscriptions: [], correlated: [], pending: [], meta: { title: 'old', pollError: 'boom' } }) const snapshot = h.store.threads.get('github:7')! h.store.threads.save({ ...snapshot, subscriptions: ['t'], meta: { ...snapshot.meta, label: '#7 old' } }) h.threads.patch('github:7', { title: 'new', pollError: undefined }) expect(h.store.threads.get('github:7')).toMatchObject({ subscriptions: ['t'], meta: { title: 'new', label: '#7 old' } }) expect(h.store.threads.get('github:7')!.meta).not.toHaveProperty('pollError') // A Thread that does not exist is not created. h.threads.patch('github:8', { title: 'x' }) expect(h.store.threads.get('github:8')).toBeUndefined() h.store.close() }) it('reads the configuration of an instance that is on, removes a record set to undefined, and merges ticket meta', () => { const h = host() expect(h.config('feishu')).toMatchObject({ directMessages: true }) h.store.saveSettings({ ...h.store.settings(), sources: { ...h.store.settings().sources, feishu: { enabled: false, config: {} } } }) expect(h.config('feishu')).toBeUndefined() h.state.set('github-retry', { 1: {} }) h.state.set('github-retry', undefined) expect(h.store.keys('github-retry')).toEqual([]) h.store.tickets.save({ ...openTicket('t1'), execution: execution('one') }) h.tickets.bindThread('t1', { source: 'github', id: '4' }, true) // The ticket follows the Thread; its other fields stay as they were. expect(h.store.tickets.get('t1')).toMatchObject({ follows: [{ thread: 'github:4', own: true }], execution: execution('one'), origin: { triggerId: 'source-t1' } }) h.store.close() }) }) it('reports GitHub as failing only after the configured number of consecutive failed polls', () => { const eof = new Error('Get "https://api.github.com/x": EOF') expect(pollFailure(1, 3, eof)).toBeUndefined() expect(pollFailure(2, 3, eof)).toBeUndefined() expect(pollFailure(3, 3, eof)).toEqual({ state: 'error', detail: '3 consecutive failed polls; last: Error: Get "https://api.github.com/x": EOF', code: 'pollFailures', values: { count: '3', error: 'Error: Get "https://api.github.com/x": EOF' } }) expect(githubKind.schema.parse({})).toEqual({ repository: '', cli: '', pollSeconds: 60, failuresBeforeError: 3, observeIssues: false, observePulls: false, observeComments: false, observeCi: false }) }) describe('feishu event consumer', () => { let dir: string let controller: AbortController let running: Promise | undefined // The fake CLI is a real process, which starts slower while the whole suite runs. const until = { timeout: 10_000 } beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'feishu-consumer-')); controller = new AbortController() }) afterEach(async () => { controller.abort() await running vi.useRealTimers() rmSync(dir, { recursive: true, force: true }) }) /** * A bot CLI whose `event consume` behaves as `mode` says: `never` gets ready, `warn` warns once ready, `ready` stays up, * and `flaky` exits after its first ready. It records each run and the PID of the latest. */ function cli(mode: 'never' | 'warn' | 'ready' | 'flaky' | 'events' | 'late'): string { const path = join(dir, 'lark-bot') writeFileSync(path, `#!/bin/sh [ "$1" = event ] || { echo '{"ok":true,"data":{}}'; exit 0; } n=$(cat '${dir}/runs' 2>/dev/null || echo 0); n=$((n + 1)); echo $n > '${dir}/runs'; echo $$ > '${dir}/pid' case ${mode} in never) exec sleep 60 ;; warn) echo '[event] ready' >&2; echo 'WARN the connection was reset' >&2; exec sleep 60 ;; ready) echo '[event] ready' >&2; exec sleep 60 ;; flaky) echo '[event] ready' >&2; [ "$n" -gt 1 ] && exec sleep 60; exit 1 ;; events) echo '[event] ready' >&2; cat '${dir}/events'; touch '${dir}/printed'; exec sleep 60 ;; late) echo '[event] ready' >&2; sleep 1; cat '${dir}/events'; touch '${dir}/printed'; exec sleep 60 ;; esac `, { mode: 0o755 }) return path } const runs = () => existsSync(join(dir, 'runs')) ? Number(readFileSync(join(dir, 'runs'), 'utf8')) : 0 const alive = (pid: number) => { try { process.kill(pid, 0); return true } catch { return false } } /** The Feishu Source's context, on a fresh Store whose Feishu Source is on, with `cli` as the bot CLI. */ function consumerSource(path: string, config: Record = { chats: [] }) { const store = new Store(join(dir, 'db.sqlite')) store.saveSettings(SettingsSchema.parse({ sources: { feishu: { enabled: true, config } } })) const board = new HealthBoard() const sources = new SourceRegistry(() => store.settings(), board) sources.register(feishuKind) const assessor: Assessor = { request: vi.fn(async () => {}) } const executor: Executor = { start: vi.fn(async () => {}), deliver: vi.fn(async () => {}), stop: vi.fn(async () => {}) } const outbox = { send: () => {}, status: () => undefined, withdraw: () => {} } const policy = () => settingsPolicy(store.settings(), sources) const engine = new Engine({ repo: store, scope: sources, policy, assessor, executor, outbox }) const deployment = testDeployment({ feishu: { ...testDeployment().feishu!, cli: path } }) const health = board.all const commands = new Commands(() => deployment, {}, controller.signal) const deps: SourceContextDeps = { store, engine, outbox: { send: () => {} }, commands, sources, deployment, ready: Promise.resolve(), configured: () => true, receive: (trigger: Trigger) => engine.receive(trigger) } const turnOff = () => store.saveSettings(SettingsSchema.parse({ sources: { feishu: { enabled: false, config: { chats: [] } } } })) return { ctx: sourceContextOf(deps, feishuKind), health, store, turnOff } } /** A message event line as `event consume` prints it; a group message mentions the bot. */ const event = (id: string, chat: string, type: 'group' | 'p2p') => JSON.stringify({ message_id: id, chat_id: chat, chat_type: type, sender_id: 'ou_alice', sender_type: 'user', content: 'fix it', create_time: String(Date.now()), ...type === 'group' ? { mentions: [{ id: 'ou_bot', name: 'Bot' }] } : {} }) it('takes in the event lines it reads, records each chat as direct or not, and reports a malformed line without stopping', async () => { writeFileSync(join(dir, 'events'), [event('om_1', 'oc_group', 'group'), event('om_2', 'oc_dm', 'p2p'), 'not json', event('om_3', 'oc_group', 'group')].join('\n') + '\n') const { ctx, health, store } = consumerSource(cli('events')) running = runFeishu(ctx, controller.signal) await vi.waitFor(() => expect(store.triggers.get(feishuTriggerId('om_3'))).toBeDefined(), until) expect(['om_1', 'om_2'].map(id => store.triggers.get(feishuTriggerId(id))?.meta.direct)).toEqual([false, true]) expect(store.get(KV.feishu.chats)).toEqual({ oc_group: false, oc_dm: true }) expect(health.feishu).toMatchObject({ state: 'error', detail: expect.stringContaining('JSON') }) controller.abort(); await running; store.close() }, 20_000) it('drops the events it reads once the Source is off', async () => { writeFileSync(join(dir, 'events'), event('om_9', 'oc_group', 'group') + '\n') const { ctx, health, store, turnOff } = consumerSource(cli('late')) running = runFeishu(ctx, controller.signal) await vi.waitFor(() => expect(health.feishu).toMatchObject({ state: 'ready' }), until) turnOff() // The CLI prints the event a second after it is ready, before the consumer stops for the Source being off. await vi.waitFor(() => expect(existsSync(join(dir, 'printed'))).toBe(true), until) await new Promise(resolve => setTimeout(resolve, 300)) expect(store.triggers.get(feishuTriggerId('om_9'))).toBeUndefined() controller.abort(); await running; store.close() }, 20_000) it('keeps a chat\'s history cursor when its read fails, and recovers known direct chats only while direct messages are on', async () => { const hour = new Date(Date.now() - 3_600_000).toISOString() const recovered = async (directMessages: boolean) => { const chat = (chatId: string) => ({ chatId, listen: 'mentions' }) const { ctx, health, store } = consumerSource(cli('ready'), { chats: [chat('oc_a'), chat('oc_b')], directMessages }) store.set(KV.feishu.chats, { oc_dm: true, oc_a: false }) for (const chat of ['oc_a', 'oc_b', 'oc_dm']) store.set(KV.feishu.history(chat), hour) const chats: string[] = [] const lark = vi.spyOn(Commands.prototype, 'lark').mockImplementation(async args => { const params = args[2] === '/open-apis/im/v1/messages' ? JSON.parse(args.at(-1)!) as { container_id: string } : undefined if (params) chats.push(params.container_id) if (params?.container_id === 'oc_a') throw new Error('rate limited') return { has_more: false, items: [] } }) const stop = new AbortController() const loops = runFeishu(ctx, AbortSignal.any([stop.signal, controller.signal])) await vi.waitFor(() => expect(['ready', 'error']).toContain(health.history?.state), until) stop.abort(); await loops; lark.mockRestore() const cursors = Object.fromEntries(['oc_a', 'oc_b', 'oc_dm'].map(chat => [chat, store.get(KV.feishu.history(chat))])) const result = { chats, cursors, history: health.history } store.close(); rmSync(join(dir, 'db.sqlite'), { force: true }); rmSync(join(dir, 'db.sqlite-wal'), { force: true }) rmSync(join(dir, 'db.sqlite-shm'), { force: true }) return result } const on = await recovered(true) expect(on.chats).toEqual(['oc_a', 'oc_b', 'oc_dm']) expect(on.history).toMatchObject({ state: 'error', detail: expect.stringContaining('oc_a: Error: rate limited') }) expect(on.cursors.oc_a).toBe(hour) expect(Date.parse(on.cursors.oc_b!)).toBeGreaterThan(Date.parse(hour)) expect(Date.parse(on.cursors.oc_dm!)).toBeGreaterThan(Date.parse(hour)) const off = await recovered(false) expect(off.chats).toEqual(['oc_a', 'oc_b']) expect(off.cursors.oc_dm).toBe(hour) }, 20_000) it('reports an error when the consumer is not ready within 45 seconds', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const { ctx, health, store } = consumerSource(cli('never')) running = runFeishu(ctx, controller.signal) await vi.waitFor(() => expect(runs()).toBe(1), until) const pid = Number(readFileSync(join(dir, 'pid'), 'utf8')) await vi.advanceTimersByTimeAsync(45_000) await vi.waitFor(() => expect(health.feishu).toMatchObject({ state: 'error', detail: expect.stringContaining('did not become ready within 45 seconds') }), until) expect(alive(pid)).toBe(false) controller.abort(); await running; store.close() }, 20_000) it('reports a warning the consumer prints as an error', async () => { const { ctx, health, store } = consumerSource(cli('warn')) running = runFeishu(ctx, controller.signal) await vi.waitFor(() => expect(health.feishu).toMatchObject({ state: 'error', detail: 'WARN the connection was reset' }), until) controller.abort(); await running; store.close() }, 20_000) it('stops the consumer within five seconds once the Source is off', async () => { vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) const { ctx, health, store, turnOff } = consumerSource(cli('ready')) running = runFeishu(ctx, controller.signal) await vi.waitFor(() => expect(health.feishu).toMatchObject({ state: 'ready', detail: 'Listening for bot events' }), until) const pid = Number(readFileSync(join(dir, 'pid'), 'utf8')) turnOff() await vi.advanceTimersByTimeAsync(5000) await vi.waitFor(() => expect(health.feishu).toMatchObject({ state: 'paused', code: 'sourceOff' }), until) expect(alive(pid)).toBe(false) controller.abort(); await running; store.close() }, 20_000) it('starts the consumer again after it exits, and is ready once the next run listens', async () => { const { ctx, health, store } = consumerSource(cli('flaky')) running = runFeishu(ctx, controller.signal) const exited = { state: 'error', detail: expect.stringContaining('exited (1, ready=true)') } await vi.waitFor(() => expect(health.feishu).toMatchObject(exited), until) // The first restart waits a second. await vi.waitFor(() => expect(runs()).toBe(2), until) await vi.waitFor(() => expect(health.feishu).toMatchObject({ state: 'ready' }), until) controller.abort(); await running; store.close() }, 20_000) }) describe('observing the GitHub repository', () => { /** Hour `hour` of the day before yesterday, well within the polling horizon. */ const at = (hour: number) => new Date(Math.floor(Date.now() / 86_400_000 - 2) * 86_400_000 + hour * 3_600_000).toISOString() const issue = (number: number, fields: Partial = {}): GithubIssue => ({ id: 1000 + number, number, title: `Issue ${number}`, body: 'details', state: 'open', created_at: at(0), updated_at: at(0), html_url: `https://github.com/acme/widgets/issues/${number}`, user: { login: 'carol', type: 'User' }, ...fields }) const observe = (h: ReturnType, config: Record) => h.store.saveSettings({ ...h.store.settings(), sources: { ...h.store.settings().sources, github: { enabled: true, config } } }) /** GitHub as the repository's lists and its default branch answer; a request with the ETag of an unchanged list answers 304. */ function repository(h: ReturnType, answers: { issues?: unknown[]; comments?: unknown[]; reviewComments?: unknown[]; branch?: { sha: string; checks: unknown[] } }) { const calls: string[][] = [] vi.spyOn(h.commands, 'run').mockImplementation(async (_program, args) => { calls.push(args) const bare = args[1]!.replace(/^repos\/acme\/widgets\/?/, '').split('?')[0]! const listed = (list: unknown[] | undefined) => `HTTP/2.0 200 OK\r\nETag: "same"\r\n\r\n${JSON.stringify(list ?? [])}` if (bare === 'notifications') return 'HTTP/2.0 200 OK\r\n\r\n[]' if (args.includes('If-None-Match: "same"')) throw commandError({ stderr: 'gh: HTTP 304', code: 1 }) if (bare === 'issues') return listed(answers.issues) if (bare === 'issues/comments') return listed(answers.comments) if (bare === 'pulls/comments') return listed(answers.reviewComments) if (bare === '') return JSON.stringify({ default_branch: 'main' }) if (bare === 'commits/main') return JSON.stringify({ sha: answers.branch!.sha }) if (bare.endsWith('/check-runs')) return JSON.stringify([{ check_runs: answers.branch!.checks }]) return JSON.stringify(args.includes('--slurp') ? [[]] : []) }) return calls } it('takes in the new Issues and PRs from when it started observing them, and asks again with the ETag while nothing is newer', async () => { const h = host() observe(h, { observeIssues: true, observePulls: true }) h.store.set('github-since', at(-24)) h.store.set('github-stream:issues', { since: at(0), started: { issues: at(0), pulls: at(0) } }) const calls = repository(h, { issues: [issue(3, { created_at: at(-24), updated_at: at(1) }), issue(4, { created_at: at(2), updated_at: at(2) }), { ...issue(5, { created_at: at(3), updated_at: at(3) }), pull_request: { url: 'x' } }, issue(6, { created_at: at(4), updated_at: at(4), user: { login: 'agent-bot', type: 'User' } })] }) await pollRound(h) // #3 was opened before the Source observed Issues, and #6 is the agent's own. expect(h.receive.mock.calls.map(([trigger]) => [trigger.meta.number, trigger.meta.kind, trigger.id])) .toEqual([[4, 'issue', issueTrigger(issue(4), 'agent-bot').id], [5, 'pull', issueTrigger(issue(5), 'agent-bot').id]]) const started = { issues: at(0), pulls: at(0) } expect(h.store.get('github-stream:issues')).toEqual({ since: cursorAt(at(4)), started }) const list = () => calls.filter(args => args[1]!.startsWith('repos/acme/widgets/issues?')) expect(list()[0]![1]).toContain(`since=${encodeURIComponent(at(0))}`) // The next round repeats the request at the newest Issue, whose ETag the round after sends, which GitHub answers 304 for. await pollRound(h) expect(h.store.get('github-stream:issues')).toEqual({ since: cursorAt(at(4)), started, etag: '"same"' }) h.receive.mockClear() await pollRound(h) expect(list().at(-1)).toEqual(expect.arrayContaining(['-H', 'If-None-Match: "same"'])) expect(h.receive).not.toHaveBeenCalled() // Switched off, the stream forgets where it was, so it starts afresh once switched on again. observe(h, {}) await pollRound(h) expect(h.store.get('github-stream:issues')).toBeUndefined() h.store.close() }) it('takes in the repository\'s conversation and review comments, on the Issue or PR each is on', async () => { const h = host() observe(h, { observeComments: true }) h.store.set('github-since', at(-24)) for (const stream of ['comments', 'review-comments']) { h.store.set(`github-stream:${stream}`, { since: at(0), started: { comments: at(0) } }) } const comment = (id: number, fields: Record) => ({ id, body: `comment ${id}`, user: { login: 'carol', type: 'User' }, created_at: at(1), updated_at: at(1), ...fields }) repository(h, { comments: [comment(1, { issue_url: 'https://api.github.com/repos/acme/widgets/issues/7', html_url: 'https://github.com/acme/widgets/issues/7#issuecomment-1' }), comment(2, { issue_url: 'https://api.github.com/repos/acme/widgets/issues/9', html_url: 'https://github.com/acme/widgets/pull/9#issuecomment-2', user: { login: 'dependabot[bot]', type: 'Bot' } })], reviewComments: [comment(3, { pull_request_url: 'https://api.github.com/repos/acme/widgets/pulls/9', path: 'src/a.ts', line: 4, html_url: 'https://github.com/acme/widgets/pull/9#discussion_r3' })] }) await pollRound(h) expect(h.receive.mock.calls.map(([trigger]) => [trigger.meta.number, trigger.meta.isPr, trigger.meta.kind, trigger.human])) .toEqual([[7, false, 'conversation', true], [9, true, 'conversation', false], [9, true, 'inline', true]]) h.store.close() }) it('fails the round for a stream whose read fails, keeps its cursor, and still reads the other streams', async () => { const h = host() observe(h, { observeIssues: true, observeComments: true }) h.store.set('github-since', at(-24)) const kind = (stream: string) => stream === 'issues' ? 'issues' : 'comments' for (const stream of ['issues', 'comments', 'review-comments']) { h.store.set(`github-stream:${stream}`, { since: at(0), started: { [kind(stream)]: at(0) } }) } const comment = { id: 1, body: 'looks off', user: { login: 'carol', type: 'User' }, created_at: at(1), updated_at: at(1), issue_url: 'https://api.github.com/repos/acme/widgets/issues/7', html_url: 'https://github.com/acme/widgets/issues/7#issuecomment-1' } repository(h, { comments: [comment] }) const run = h.commands.run as unknown as { getMockImplementation(): (program: string, args: string[]) => Promise } const answer = run.getMockImplementation() vi.mocked(h.commands.run).mockImplementation(async (program, args) => { if (args[1]!.startsWith('repos/acme/widgets/issues?')) throw refused(502, 'Bad Gateway') return answer(program, args) }) await expect(pollRound(h)).rejects.toThrow(/issues: .*502/) expect(h.store.get('github-stream:issues')).toEqual({ since: at(0), started: { issues: at(0) } }) expect(h.receive).toHaveBeenCalledWith(expect.objectContaining({ text: 'looks off' })) expect(h.store.get('github-stream:comments')).toEqual({ since: cursorAt(at(1)), started: { comments: at(0) } }) h.store.close() }) it('reads a stream whose cursor is older than the polling horizon from the horizon, without the ETag of the older request', async () => { const h = host() observe(h, { observeIssues: true }) h.store.set('github-since', at(-24)) const old = new Date(Date.now() - 20 * 86_400_000).toISOString() h.store.set('github-stream:issues', { since: old, started: { issues: old }, etag: '"same"' }) const calls = repository(h, {}) await pollRound(h) const list = calls.find(args => args[1]!.startsWith('repos/acme/widgets/issues?'))! expect(list).not.toContain('-H') const since = Date.parse(decodeURIComponent(/since=([^&]+)/.exec(list[1]!)![1]!)) // The horizon, on the hour. expect(Date.now() - POLL_HORIZON_MS - since).toBeGreaterThanOrEqual(0) expect(Date.now() - POLL_HORIZON_MS - since).toBeLessThan(3_600_000) h.store.close() }) it('starts the default branch\'s CI afresh when the default branch is renamed, without a Trigger', async () => { const h = host() observe(h, { observeCi: true }) h.store.set('github-since', at(-24)) h.store.set('github-branch-ci', { branch: 'master', failures: [], revision: 2 }) const answers = { branch: { sha: 'ddd4444', checks: [{ name: 'lint', status: 'completed', conclusion: 'failure' }] } } repository(h, answers) await pollRound(h) expect(h.receive).not.toHaveBeenCalled() expect(h.store.get('github-branch-ci')).toEqual({ branch: 'main', failures: ['lint'], revision: 2 }) h.store.close() }) it('takes in new PRs only from when their switch turned on, apart from when new Issues were', async () => { const h = host() observe(h, { observeIssues: true, observePulls: true }) h.store.set('github-since', at(-24)) // The stream took in Issues from hour 0; PRs were switched on only now. h.store.set('github-stream:issues', { since: at(0), started: { issues: at(0) } }) repository(h, { issues: [{ ...issue(5, { created_at: at(2), updated_at: at(2) }), pull_request: { url: 'x' } }, issue(6, { created_at: at(3), updated_at: at(3) })] }) await pollRound(h) expect(h.receive.mock.calls.map(([trigger]) => [trigger.meta.number, trigger.meta.kind])).toEqual([[6, 'issue']]) expect(h.store.get<{ started: Record }>('github-stream:issues')!.started).toEqual({ issues: at(0), pulls: expect.any(String) }) // Switched off, a kind forgets its start. observe(h, { observePulls: true }) await pollRound(h) expect(Object.keys(h.store.get<{ started: Record }>('github-stream:issues')!.started)).toEqual(['pulls']) h.store.close() }) it('keeps a stream\'s cursor five seconds before its newest item, and a stale cursor on the hour, so the ETag is sent again', async () => { const h = host() observe(h, { observeIssues: true }) h.store.set('github-since', at(-24)) h.store.set('github-stream:issues', { since: at(0), started: { issues: at(0) } }) repository(h, { issues: [issue(6, { created_at: at(3), updated_at: at(3) })] }) await pollRound(h) expect(h.store.get<{ since: string }>('github-stream:issues')!.since).toBe(new Date(Date.parse(at(3)) - 5000).toISOString()) // A cursor behind the horizon reads from the horizon on the hour, the same in the next round, which sends the ETag. const old = new Date(Date.now() - 20 * 86_400_000).toISOString() h.store.set('github-stream:issues', { since: old, started: { issues: old } }) const calls = repository(h, {}) await pollRound(h); await pollRound(h) const lists = calls.filter(args => args[1]!.startsWith('repos/acme/widgets/issues?')) const since = (args: string[]) => decodeURIComponent(/since=([^&]+)/.exec(args[1]!)![1]!) expect(since(lists[0]!)).toBe(since(lists[1]!)) expect(Date.parse(since(lists[0]!)) % 3_600_000).toBe(0) expect(lists[1]).toEqual(expect.arrayContaining(['-H', 'If-None-Match: "same"'])) h.store.close() }) it('gives each change of the default branch\'s CI its own id, also when one commit fails, passes and fails again', async () => { const h = host() observe(h, { observeCi: true }) h.store.set('github-since', at(-24)) const check = (name: string, conclusion: string) => ({ name, status: 'completed', conclusion }) const answers = { branch: { sha: 'eee5555', checks: [check('lint', 'success')] } } repository(h, answers) await pollRound(h) for (const conclusion of ['failure', 'success', 'failure']) { answers.branch = { sha: 'eee5555', checks: [check('lint', conclusion)] } await pollRound(h) } const ids = h.receive.mock.calls.map(([trigger]) => trigger.id) expect(ids).toHaveLength(3) expect(new Set(ids).size).toBe(3) // Switched off and on again, the CI starts from a new first look, and its ids keep counting. observe(h, {}) await pollRound(h) observe(h, { observeCi: true }) await pollRound(h) answers.branch = { sha: 'eee5555', checks: [check('lint', 'success')] } await pollRound(h) expect(new Set(h.receive.mock.calls.map(([trigger]) => trigger.id)).size).toBe(4) h.store.close() }) it('tells of the default branch\'s CI when its failed checks change and once it passes again, after a first look', async () => { const h = host() observe(h, { observeCi: true }) h.store.set('github-since', at(-24)) const check = (name: string, conclusion: string | null) => ({ name, status: conclusion ? 'completed' : 'in_progress', conclusion }) const answers = { branch: { sha: 'aaa1111', checks: [check('lint', 'success'), check('e2e', 'success')] } } repository(h, answers) const texts = () => h.receive.mock.calls.map(([trigger]) => trigger.text.split('\n')[0]) await pollRound(h) expect(texts()).toEqual([]) answers.branch = { sha: 'bbb2222', checks: [check('lint', 'failure'), check('e2e', null)] } await pollRound(h) answers.branch = { sha: 'bbb2222', checks: [check('lint', 'failure'), check('e2e', 'success')] } await pollRound(h) // A rerun that still runs is no recovery yet; the recovery is told once every check passes. answers.branch = { sha: 'ccc3333', checks: [check('lint', null), check('e2e', null)] } await pollRound(h) answers.branch = { sha: 'ccc3333', checks: [check('lint', 'success'), check('e2e', 'success')] } await pollRound(h) expect(texts()).toEqual(['CI failed 1/2 on main at bbb2222: lint; 1 still running', 'CI passed 2/2 on main at ccc3333']) expect(h.receive.mock.calls[0]![0]).toMatchObject({ human: false, actor: { id: '' }, threads: [{ source: 'github', id: 'ci:main' }], meta: { ci: true, branch: 'main', sha: 'bbb2222' }, url: 'https://github.com/acme/widgets/commit/bbb2222' }) expect(h.receive.mock.calls[0]![0].text).toContain('Read github/commits/bbb2222/checks for every check.') h.store.close() }) })