diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2c01d5..3342598 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,7 @@ jobs: - name: Check bundle size run: | SIZE=$(du -sb dist/ | cut -f1) - MAX_SIZE=$((100 * 1024)) # 100 KB + MAX_SIZE=$((110 * 1024)) # 110 KB if [ $SIZE -gt $MAX_SIZE ]; then echo "❌ Bundle size too large: ${SIZE} bytes (max: ${MAX_SIZE})" diff --git a/README.md b/README.md index 70d0052..21f12b8 100644 --- a/README.md +++ b/README.md @@ -565,6 +565,26 @@ console.log(`Success rate: ${stats.success_rate}%`); await client.agentSubscriptions.delete(subscription.id); \`\`\` +#### On-Demand Polling + +For `on_demand` subscriptions, use `poll()` to fetch new events matching your subscription's filters: + +\`\`\`typescript +// Simple polling — reads repo/event filters from the subscription automatically +const events = await client.agentSubscriptions.poll( + 'acfdd1a3-e29c-451c-8db9-be8e76918c4f', + { since: lastCheckTimestamp } +); + +console.log(`Found ${events.length} new events`); +events.forEach(e => { + console.log(`${e.aggregate_type} on ${e.repository}: ${e.summary?.conclusion || e.summary?.status}`); +}); + +// Update your timestamp for next poll +lastCheckTimestamp = new Date().toISOString(); +\`\`\` + ### Events API \`\`\`typescript diff --git a/package.json b/package.json index 9249b49..24a2674 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@alteriom/webhook-client", - "version": "1.1.0", + "version": "1.2.0", "description": "Type-safe TypeScript client for Alteriom Webhook Connector", "engines": { "node": ">=22.0.0" diff --git a/src/client.ts b/src/client.ts index ceff59f..d5d8627 100644 --- a/src/client.ts +++ b/src/client.ts @@ -60,6 +60,7 @@ import type { AgentSubscriptionCreateRequest, SubscriptionStats, SubscriptionDelivery, + EventAggregate, } from './types'; import { ApiError, RateLimitError } from './errors'; @@ -893,6 +894,65 @@ export class AlteriomWebhookClient { return data; }); }, + + /** + * Poll for new events matching this subscription's filters. + * Designed for on_demand delivery mode — reads subscription repo/event filters, + * then fetches matching aggregates updated since the given timestamp. + * + * @param subscriptionId - The subscription ID to poll + * @param options.since - ISO timestamp to fetch events after (default: 1 hour ago) + * @param options.limit - Max events per page (default: 100) + * @returns Array of matching EventAggregate objects + */ + poll: async ( + subscriptionId: string, + options?: { since?: string; limit?: number } + ): Promise => { + // 1. Get subscription to read repo + event filters + const sub = await this.agentSubscriptions.get(subscriptionId); + + // 2. Build aggregates query params + const since = options?.since || new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const limit = options?.limit || 100; + + // 3. Fetch all pages of matching aggregates + const allAggregates: EventAggregate[] = []; + let cursor: string | undefined = undefined; + const MAX_PAGES = 10; + + for (let page = 0; page < MAX_PAGES; page++) { + const params: AggregateListParams = { + since, + limit, + cursor, + }; + + // Filter by subscription repos if specified + // Note: API doesn't support multi-repo filter natively yet, so we fetch and filter client-side + const response = await this.aggregates.list(params); + + if (!response.data || response.data.length === 0) break; + + // Filter by subscription repos + event types if specified + const subRepos = new Set(sub.filters?.repositories || []); + const subEventTypes = new Set(sub.filters?.event_types || []); + + const filtered = response.data.filter(agg => { + const repoMatch = subRepos.size === 0 || subRepos.has(agg.repository) || + [...subRepos].some((r: string) => r.endsWith('/*') && agg.repository.startsWith(r.replace('/*', '/'))); + const eventMatch = subEventTypes.size === 0 || subEventTypes.has(agg.aggregate_type); + return repoMatch && eventMatch; + }); + + allAggregates.push(...(filtered as unknown as EventAggregate[])); + + if (!response.hasMore || !response.cursor) break; + cursor = response.cursor; + } + + return allAggregates; + }, }; /** diff --git a/src/types.ts b/src/types.ts index 9884080..2294415 100644 --- a/src/types.ts +++ b/src/types.ts @@ -998,7 +998,10 @@ export interface QueryLog { // ============================================================================ /** - * Agent subscription configuration + * Agent subscription configuration. + * + * For `on_demand` delivery mode, use `client.agentSubscriptions.poll(subscriptionId, { since })` + * to fetch new events matching this subscription's repo and event type filters. */ export interface AgentSubscription { id: string; diff --git a/tests/client.test.ts b/tests/client.test.ts index 0c57e68..7b01d3d 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -23,6 +23,7 @@ describe('AlteriomWebhookClient - API Endpoints', () => { get: jest.fn(), post: jest.fn(), put: jest.fn(), + patch: jest.fn(), delete: jest.fn(), interceptors: { request: { @@ -241,7 +242,72 @@ describe('AlteriomWebhookClient - API Endpoints', () => { }); }); - describe('Endpoint Path Verification', () => { + describe('agentSubscriptions', () => { + it('should call GET /api/v1/subscriptions for list', async () => { + mockAxiosInstance.get.mockResolvedValue({ data: { subscriptions: [], total: 0 } }); + await client.agentSubscriptions.list(); + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/api/v1/subscriptions', { params: undefined }); + }); + + it('should call GET /api/v1/subscriptions/:id for get', async () => { + mockAxiosInstance.get.mockResolvedValue({ data: { id: 'sub-1', agent_name: 'test' } }); + await client.agentSubscriptions.get('sub-1'); + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/api/v1/subscriptions/sub-1'); + }); + + it('should call POST /api/v1/subscriptions for create', async () => { + mockAxiosInstance.post.mockResolvedValue({ data: { id: 'sub-1' } }); + await client.agentSubscriptions.create({ agent_name: 'test', delivery_mode: 'on_demand', events: [] }); + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/api/v1/subscriptions', expect.any(Object)); + }); + + it('should call PATCH /api/v1/subscriptions/:id for update', async () => { + mockAxiosInstance.patch.mockResolvedValue({ data: { id: 'sub-1' } }); + await client.agentSubscriptions.update('sub-1', { agent_name: 'updated' }); + expect(mockAxiosInstance.patch).toHaveBeenCalledWith('/api/v1/subscriptions/sub-1', { agent_name: 'updated' }); + }); + + it('should call DELETE /api/v1/subscriptions/:id for delete', async () => { + mockAxiosInstance.delete.mockResolvedValue({ data: null }); + await client.agentSubscriptions.delete('sub-1'); + expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/api/v1/subscriptions/sub-1'); + }); + + it('should call GET /api/v1/subscriptions/:id/stats for stats', async () => { + mockAxiosInstance.get.mockResolvedValue({ data: { total_deliveries: 0, success_rate: 0 } }); + await client.agentSubscriptions.stats('sub-1'); + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/api/v1/subscriptions/sub-1/stats'); + }); + + it('poll() should fetch subscription then aggregates and filter by repos', async () => { + mockAxiosInstance.get.mockImplementation((url: string) => { + if (url === '/api/v1/subscriptions/sub-1') { + return Promise.resolve({ data: { + id: 'sub-1', + repositories: ['Alteriom/test-repo'], + event_types: ['workflow_run'], + filters: { repositories: ['Alteriom/test-repo'], event_types: ['workflow_run'] } + }}); + } + if (url.includes('/api/v1/aggregates')) { + return Promise.resolve({ data: { + data: [ + { id: '1', aggregate_type: 'workflow_run', repository: 'Alteriom/test-repo', last_event_at: new Date().toISOString(), summary: {} }, + { id: '2', aggregate_type: 'pull_request', repository: 'Other/repo', last_event_at: new Date().toISOString(), summary: {} }, + ], + hasMore: false, cursor: null, pagination: { total: 2 } + }}); + } + return Promise.resolve({ data: {} }); + }); + + const events = await client.agentSubscriptions.poll('sub-1', { since: '2026-01-01T00:00:00Z' }); + expect(events).toHaveLength(1); + expect(events[0].repository).toBe('Alteriom/test-repo'); + }); + }); + + describe('Endpoint Path Verification', () => { it('should call endpoints with /api/ prefix (no version)', async () => { // Mock proper response structures mockAxiosInstance.get.mockImplementation((url: string) => {