Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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})"
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
60 changes: 60 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import type {
AgentSubscriptionCreateRequest,
SubscriptionStats,
SubscriptionDelivery,
EventAggregate,
} from './types';
import { ApiError, RateLimitError } from './errors';

Expand Down Expand Up @@ -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<EventAggregate[]> => {
// 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<string>(sub.filters?.repositories || []);
const subEventTypes = new Set<string>(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;
},
};

/**
Expand Down
5 changes: 4 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
68 changes: 67 additions & 1 deletion tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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) => {
Expand Down
Loading