Files
dsh-superagent/tests/sources.spec.ts
T
2026-10-31 20:05:39 +08:00

1408 lines
100 KiB
TypeScript

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<SourceContextDeps> = {}) {
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<SourceRegistry, HealthBoard>()
function emptyRegistry(): SourceRegistry {
const board = new HealthBoard()
const sources = new SourceRegistry(() => SettingsSchema.parse({}), board)
healthOf.set(sources, board)
return sources
}
const subscribed = (h: ReturnType<typeof host>, ref: { source: string; id: string }, meta: Record<string, unknown> = {}) =>
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<typeof host>, routes: Record<string, unknown>): 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<SourceContextDeps>) => {
// 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<string, string>, extra: Record<string, unknown> = {}) => ({ 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<string, unknown>) => ({ 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<Record<string, string>>('feishu-history-threads:oc_team')!)).toHaveLength(14)
expect(h.store.get<Record<string, string>>('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<GithubComment>) => 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<string, unknown> = { 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<string, unknown>
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<string, unknown> = { 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<string>('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: <https://api.github.com/notifications?page=2>; 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<Record<string, { failedAt: string; attempts: number }>>('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<SourceContextDeps>,
github: { enabled: boolean; config: Record<string, unknown> } = { 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> = {}): 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<typeof host>, issues: GithubIssue[], comments: Record<number, GithubComment[]> = {},
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<string, unknown> = { 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<string>('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<unknown> | 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<string, unknown> = { 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> = {}): 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<typeof host>, config: Record<string, boolean>) =>
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<typeof host>, 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<string, unknown>) => ({ 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<string> }
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<string, string> }>('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<string, string> }>('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()
})
})