diff --git a/packages/lib/src/integrations/execution/transform-output.test.ts b/packages/lib/src/integrations/execution/transform-output.test.ts index 517493c55..84d23af96 100644 --- a/packages/lib/src/integrations/execution/transform-output.test.ts +++ b/packages/lib/src/integrations/execution/transform-output.test.ts @@ -99,6 +99,48 @@ describe('applyMapping', () => { expect(applyMapping(null, { a: 'b' })).toBeNull(); expect(applyMapping(undefined, { a: 'b' })).toBeUndefined(); }); + + it('given dot-notation source key, should traverse nested properties', () => { + const data = { + user: { login: 'octocat', id: 1 }, + head: { ref: 'feature-branch' }, + }; + const mapping = { + author: 'user.login', + branch: 'head.ref', + }; + + const result = applyMapping(data, mapping); + + expect(result).toEqual({ + author: 'octocat', + branch: 'feature-branch', + }); + }); + + it('given dot-notation with missing intermediate, should return undefined', () => { + const data = { user: null }; + const mapping = { author: 'user.login' }; + + const result = applyMapping(data, mapping) as Record; + + expect(result.author).toBeUndefined(); + }); + + it('given array with dot-notation mapping, should resolve nested paths per element', () => { + const data = [ + { user: { login: 'alice' }, title: 'PR 1' }, + { user: { login: 'bob' }, title: 'PR 2' }, + ]; + const mapping = { author: 'user.login', title: 'title' }; + + const result = applyMapping(data, mapping); + + expect(result).toEqual([ + { author: 'alice', title: 'PR 1' }, + { author: 'bob', title: 'PR 2' }, + ]); + }); }); describe('truncateStrings', () => { diff --git a/packages/lib/src/integrations/execution/transform-output.ts b/packages/lib/src/integrations/execution/transform-output.ts index 2084b2b07..f0ed7c1f8 100644 --- a/packages/lib/src/integrations/execution/transform-output.ts +++ b/packages/lib/src/integrations/execution/transform-output.ts @@ -57,8 +57,27 @@ export const extractPath = (data: unknown, path: string): unknown => { return current; }; +/** + * Resolve a potentially dotted path against a source object. + * Flat keys use direct lookup; dotted keys traverse nested properties. + */ +const resolveSourceKey = (source: Record, key: string): unknown => { + if (!key.includes('.')) { + return source[key]; + } + let current: unknown = source; + for (const segment of key.split('.')) { + if (current === null || current === undefined || typeof current !== 'object') { + return undefined; + } + current = (current as Record)[segment]; + } + return current; +}; + /** * Apply field mapping to an object. + * Source keys support dot-notation for nested access (e.g. 'user.login'). */ export const applyMapping = ( data: unknown, @@ -80,7 +99,7 @@ export const applyMapping = ( const result: Record = {}; for (const [targetKey, sourceKey] of Object.entries(mapping)) { - result[targetKey] = source[sourceKey]; + result[targetKey] = resolveSourceKey(source, sourceKey); } return result; diff --git a/packages/lib/src/integrations/index.ts b/packages/lib/src/integrations/index.ts index c30e0b920..4065e5f8b 100644 --- a/packages/lib/src/integrations/index.ts +++ b/packages/lib/src/integrations/index.ts @@ -167,3 +167,13 @@ export { getAuditLogsByAgent, getAuditLogsByTool, } from './repositories/audit-repository'; + +// Built-in Provider Adapters +export { + builtinProviders, + builtinProviderList, + getBuiltinProvider, + isBuiltinProvider, + genericWebhookProvider, + githubProvider, +} from './providers'; diff --git a/packages/lib/src/integrations/providers/generic-webhook.test.ts b/packages/lib/src/integrations/providers/generic-webhook.test.ts new file mode 100644 index 000000000..eceebefb5 --- /dev/null +++ b/packages/lib/src/integrations/providers/generic-webhook.test.ts @@ -0,0 +1,231 @@ +/** + * Generic Webhook Provider Tests + * + * Validates the webhook provider config structure, tool definitions, + * and integration with buildHttpRequest and convertToolSchemaToZod. + */ + +import { describe, it, expect } from 'vitest'; +import { genericWebhookProvider } from './generic-webhook'; +import { buildHttpRequest } from '../execution/build-request'; +import { convertToolSchemaToZod } from '../converter/ai-sdk'; +import type { HttpExecutionConfig } from '../types'; + +describe('genericWebhookProvider', () => { + describe('provider structure', () => { + it('given the provider config, should have correct identity', () => { + expect(genericWebhookProvider.id).toBe('generic-webhook'); + expect(genericWebhookProvider.name).toBe('Generic Webhook'); + }); + + it('given the provider config, should use custom_header auth with webhook secret', () => { + const { authMethod } = genericWebhookProvider; + expect(authMethod.type).toBe('custom_header'); + if (authMethod.type !== 'custom_header') throw new Error('unexpected auth type'); + expect(authMethod.config.headers).toHaveLength(1); + expect(authMethod.config.headers[0].name).toBe('X-Webhook-Secret'); + expect(authMethod.config.headers[0].credentialKey).toBe('webhookSecret'); + }); + + it('given the provider config, should use placeholder base URL', () => { + expect(genericWebhookProvider.baseUrl).toBe('https://placeholder.invalid'); + }); + + it('given the provider config, should set User-Agent header', () => { + expect(genericWebhookProvider.defaultHeaders).toEqual({ + 'User-Agent': 'PageSpace-Webhook/1.0', + }); + }); + + it('given the provider config, should have credential schema with optional webhookSecret', () => { + const schema = genericWebhookProvider.credentialSchema; + expect(schema).toBeDefined(); + expect((schema as Record).required).toEqual([]); + }); + + it('given the provider config, should rate limit at 60 req/min', () => { + expect(genericWebhookProvider.rateLimit).toEqual({ + requests: 60, + windowMs: 60_000, + }); + }); + + it('given the provider config, should have 3 tools', () => { + expect(genericWebhookProvider.tools).toHaveLength(3); + }); + }); + + describe('send_webhook tool', () => { + const tool = genericWebhookProvider.tools.find((t) => t.id === 'send_webhook')!; + + it('given the tool, should be a write category POST', () => { + expect(tool.category).toBe('write'); + expect(tool.execution.type).toBe('http'); + const config = (tool.execution as { config: HttpExecutionConfig }).config; + expect(config.method).toBe('POST'); + expect(config.bodyEncoding).toBe('json'); + }); + + it('given the tool, should require body input', () => { + expect((tool.inputSchema as { required: string[] }).required).toContain('body'); + }); + + it('given body and path, should build correct POST request', () => { + const config = (tool.execution as { config: HttpExecutionConfig }).config; + const input = { body: { event: 'deploy', status: 'success' }, path: 'events' }; + const baseUrl = 'https://hooks.example.com'; + + const result = buildHttpRequest(config, input, baseUrl); + + expect(result.method).toBe('POST'); + expect(result.url).toBe('https://hooks.example.com/events'); + expect(result.body).toBe('{"event":"deploy","status":"success"}'); + }); + + it('given body without path, should POST to root URL', () => { + const config = (tool.execution as { config: HttpExecutionConfig }).config; + const input = { body: { ping: true } }; + const baseUrl = 'https://hooks.example.com'; + + const result = buildHttpRequest(config, input, baseUrl); + + expect(result.url).toBe('https://hooks.example.com/'); + }); + }); + + describe('send_get_webhook tool', () => { + const tool = genericWebhookProvider.tools.find((t) => t.id === 'send_get_webhook')!; + + it('given the tool, should be a read category GET', () => { + expect(tool.category).toBe('read'); + const config = (tool.execution as { config: HttpExecutionConfig }).config; + expect(config.method).toBe('GET'); + }); + + it('given no required inputs, should have empty required array', () => { + expect((tool.inputSchema as { required: string[] }).required).toEqual([]); + }); + + it('given a path, should build correct GET request', () => { + const config = (tool.execution as { config: HttpExecutionConfig }).config; + const input = { path: 'status' }; + const baseUrl = 'https://hooks.example.com'; + + const result = buildHttpRequest(config, input, baseUrl); + + expect(result.method).toBe('GET'); + expect(result.url).toBe('https://hooks.example.com/status'); + expect(result.body).toBeUndefined(); + }); + + it('given no path, should build GET to root', () => { + const config = (tool.execution as { config: HttpExecutionConfig }).config; + const result = buildHttpRequest(config, {}, 'https://hooks.example.com'); + + expect(result.url).toBe('https://hooks.example.com/'); + }); + + it('given path with question mark, should percent-encode it (not treated as query string)', () => { + const config = (tool.execution as { config: HttpExecutionConfig }).config; + const result = buildHttpRequest( + config, + { path: 'status?key=value' }, + 'https://hooks.example.com' + ); + + expect(result.url).toBe('https://hooks.example.com/status%3Fkey=value'); + }); + + it('given path with multiple query-like params, should percent-encode the question mark', () => { + const config = (tool.execution as { config: HttpExecutionConfig }).config; + const result = buildHttpRequest( + config, + { path: 'status?a=1&b=2' }, + 'https://hooks.example.com' + ); + + expect(result.url).toBe('https://hooks.example.com/status%3Fa=1&b=2'); + }); + + it('given path with spaces, should percent-encode them', () => { + const config = (tool.execution as { config: HttpExecutionConfig }).config; + const result = buildHttpRequest( + config, + { path: 'hello world' }, + 'https://hooks.example.com' + ); + + expect(result.url).toBe('https://hooks.example.com/hello%20world'); + }); + + it('given path with hash, should percent-encode it (not treated as fragment)', () => { + const config = (tool.execution as { config: HttpExecutionConfig }).config; + const result = buildHttpRequest( + config, + { path: 'status#section' }, + 'https://hooks.example.com' + ); + + expect(result.url).toBe('https://hooks.example.com/status%23section'); + }); + + it('given path with ampersand, should pass it through unencoded', () => { + const config = (tool.execution as { config: HttpExecutionConfig }).config; + const result = buildHttpRequest( + config, + { path: 'data&more' }, + 'https://hooks.example.com' + ); + + expect(result.url).toBe('https://hooks.example.com/data&more'); + }); + + it('given path with slashes, should preserve path segments', () => { + const config = (tool.execution as { config: HttpExecutionConfig }).config; + const result = buildHttpRequest( + config, + { path: 'api/v2/events' }, + 'https://hooks.example.com' + ); + + expect(result.url).toBe('https://hooks.example.com/api/v2/events'); + }); + }); + + describe('send_form_webhook tool', () => { + const tool = genericWebhookProvider.tools.find((t) => t.id === 'send_form_webhook')!; + + it('given the tool, should be a write category POST with form encoding', () => { + expect(tool.category).toBe('write'); + const config = (tool.execution as { config: HttpExecutionConfig }).config; + expect(config.method).toBe('POST'); + expect(config.bodyEncoding).toBe('form'); + }); + + it('given the tool, should require body input', () => { + expect((tool.inputSchema as { required: string[] }).required).toContain('body'); + }); + + it('given body and path, should build form-encoded request', () => { + const config = (tool.execution as { config: HttpExecutionConfig }).config; + const input = { body: { channel: '#general', text: 'hello' }, path: 'notify' }; + const baseUrl = 'https://hooks.example.com'; + + const result = buildHttpRequest(config, input, baseUrl); + + expect(result.method).toBe('POST'); + expect(result.url).toBe('https://hooks.example.com/notify'); + expect(result.body).toBe('channel=%23general&text=hello'); + }); + }); + + describe('schema compatibility', () => { + it('given all tool input schemas, should convert to valid Zod schemas', () => { + for (const tool of genericWebhookProvider.tools) { + const zodSchema = convertToolSchemaToZod(tool.inputSchema); + expect(zodSchema).toBeDefined(); + expect(zodSchema.parse).toBeTypeOf('function'); + } + }); + }); +}); diff --git a/packages/lib/src/integrations/providers/generic-webhook.ts b/packages/lib/src/integrations/providers/generic-webhook.ts new file mode 100644 index 000000000..68631220e --- /dev/null +++ b/packages/lib/src/integrations/providers/generic-webhook.ts @@ -0,0 +1,138 @@ +/** + * Generic Webhook Provider Adapter + * + * Sends HTTP requests to arbitrary webhook URLs. + * The actual URL comes from connection.baseUrlOverride at runtime; + * baseUrl here is a placeholder the execution saga replaces. + * + * Path encoding: The `path` parameter is interpolated into the URL pathname + * via `interpolatePath` (which performs no encoding), then passed through the + * WHATWG URL API's `pathname` setter, which percent-encodes characters that + * are invalid in a URL path component. This means: + * - `?` is encoded to `%3F` (not treated as a query delimiter) + * - `#` is encoded to `%23` (not treated as a fragment delimiter) + * - Spaces are encoded to `%20` + * - Unicode characters are percent-encoded + * - `/`, `&`, `=`, `:`, `@` pass through unencoded + * Callers cannot embed query strings in the `path` parameter; use + * `connection.baseUrlOverride` to set the full URL including query string. + */ + +import type { IntegrationProviderConfig } from '../types'; + +export const genericWebhookProvider: IntegrationProviderConfig = { + id: 'generic-webhook', + name: 'Generic Webhook', + description: 'Send HTTP requests to any webhook URL', + authMethod: { + type: 'custom_header', + config: { + headers: [ + { + name: 'X-Webhook-Secret', + valueFrom: 'credential', + credentialKey: 'webhookSecret', + }, + ], + }, + }, + baseUrl: 'https://placeholder.invalid', + defaultHeaders: { + 'User-Agent': 'PageSpace-Webhook/1.0', + }, + credentialSchema: { + type: 'object', + properties: { + webhookSecret: { + type: 'string', + description: 'Optional secret sent in X-Webhook-Secret header', + }, + }, + required: [], + }, + rateLimit: { requests: 60, windowMs: 60_000 }, + tools: [ + { + id: 'send_webhook', + name: 'Send Webhook', + description: 'Send a JSON POST request to the webhook URL', + category: 'write', + inputSchema: { + type: 'object', + properties: { + body: { + type: 'object', + description: 'JSON payload to send', + }, + path: { + type: 'string', + description: 'Optional path appended to the webhook URL', + }, + }, + required: ['body'], + }, + execution: { + type: 'http', + config: { + method: 'POST', + pathTemplate: '/{path}', + bodyTemplate: { $param: 'body' }, + bodyEncoding: 'json', + }, + }, + }, + { + id: 'send_get_webhook', + name: 'Send GET Webhook', + description: 'Send a GET request to the webhook URL', + category: 'read', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Optional path segment appended to the webhook URL. Special characters (?, #, spaces) are percent-encoded by the URL API.', + }, + }, + required: [], + }, + execution: { + type: 'http', + config: { + method: 'GET', + pathTemplate: '/{path}', + }, + }, + }, + { + id: 'send_form_webhook', + name: 'Send Form Webhook', + description: 'Send a form-encoded POST request to the webhook URL', + category: 'write', + inputSchema: { + type: 'object', + properties: { + body: { + type: 'object', + description: 'Form data to send as URL-encoded body', + }, + path: { + type: 'string', + description: 'Optional path appended to the webhook URL', + }, + }, + required: ['body'], + }, + execution: { + type: 'http', + config: { + method: 'POST', + pathTemplate: '/{path}', + bodyTemplate: { $param: 'body' }, + bodyEncoding: 'form', + }, + }, + }, + ], +}; diff --git a/packages/lib/src/integrations/providers/github.test.ts b/packages/lib/src/integrations/providers/github.test.ts new file mode 100644 index 000000000..fb874dc6f --- /dev/null +++ b/packages/lib/src/integrations/providers/github.test.ts @@ -0,0 +1,283 @@ +/** + * GitHub Provider Tests + * + * Validates the GitHub provider config structure, tool definitions, + * rate limits, and integration with buildHttpRequest and convertToolSchemaToZod. + */ + +import { describe, it, expect } from 'vitest'; +import { githubProvider } from './github'; +import { buildHttpRequest } from '../execution/build-request'; +import { convertToolSchemaToZod } from '../converter/ai-sdk'; +import type { HttpExecutionConfig } from '../types'; + +describe('githubProvider', () => { + describe('provider structure', () => { + it('given the provider config, should have correct identity', () => { + expect(githubProvider.id).toBe('github'); + expect(githubProvider.name).toBe('GitHub'); + }); + + it('given the provider config, should use OAuth2 auth with repo and read:user scopes', () => { + const { authMethod } = githubProvider; + expect(authMethod.type).toBe('oauth2'); + if (authMethod.type !== 'oauth2') throw new Error('unexpected auth type'); + expect(authMethod.config.scopes).toContain('repo'); + expect(authMethod.config.scopes).toContain('read:user'); + expect(authMethod.config.pkceRequired).toBe(false); + }); + + it('given the provider config, should have correct OAuth2 URLs', () => { + const { authMethod } = githubProvider; + if (authMethod.type !== 'oauth2') throw new Error('unexpected auth type'); + expect(authMethod.config.authorizationUrl).toBe('https://github.com/login/oauth/authorize'); + expect(authMethod.config.tokenUrl).toBe('https://github.com/login/oauth/access_token'); + }); + + it('given the provider config, should target GitHub API with correct headers', () => { + expect(githubProvider.baseUrl).toBe('https://api.github.com'); + expect(githubProvider.defaultHeaders).toEqual({ + 'Accept': 'application/vnd.github.v3+json', + 'X-GitHub-Api-Version': '2022-11-28', + }); + }); + + it('given the provider config, should have health check on /user', () => { + expect(githubProvider.healthCheck).toEqual({ + endpoint: '/user', + expectedStatus: 200, + }); + }); + + it('given the provider config, should require accessToken in credential schema', () => { + const schema = githubProvider.credentialSchema as Record; + expect(schema).toBeDefined(); + expect((schema.required as string[])).toContain('accessToken'); + }); + + it('given the provider config, should rate limit at 30 req/min', () => { + expect(githubProvider.rateLimit).toEqual({ + requests: 30, + windowMs: 60_000, + }); + }); + + it('given the provider config, should have 6 tools', () => { + expect(githubProvider.tools).toHaveLength(6); + }); + }); + + describe('tool categories', () => { + it('given read tools, should all be category read', () => { + const readToolIds = ['list_repos', 'get_issues', 'get_pull_request', 'list_pull_requests']; + for (const id of readToolIds) { + const tool = githubProvider.tools.find((t) => t.id === id)!; + expect(tool.category).toBe('read'); + } + }); + + it('given write tools, should all be category write', () => { + const writeToolIds = ['create_issue', 'create_pr_comment']; + for (const id of writeToolIds) { + const tool = githubProvider.tools.find((t) => t.id === id)!; + expect(tool.category).toBe('write'); + } + }); + + it('given write tools, should have tighter rate limits (10/min)', () => { + const writeToolIds = ['create_issue', 'create_pr_comment']; + for (const id of writeToolIds) { + const tool = githubProvider.tools.find((t) => t.id === id)!; + expect(tool.rateLimit).toEqual({ requests: 10, windowMs: 60_000 }); + } + }); + + it('given read tools, should not have tool-level rate limits', () => { + const readToolIds = ['list_repos', 'get_issues', 'get_pull_request', 'list_pull_requests']; + for (const id of readToolIds) { + const tool = githubProvider.tools.find((t) => t.id === id)!; + expect(tool.rateLimit).toBeUndefined(); + } + }); + }); + + describe('list_repos tool', () => { + const tool = githubProvider.tools.find((t) => t.id === 'list_repos')!; + + it('given no required params, should have empty required array', () => { + expect((tool.inputSchema as { required: string[] }).required).toEqual([]); + }); + + it('given optional query params, should build correct GET request', () => { + const config = (tool.execution as { config: HttpExecutionConfig }).config; + const input = { type: 'owner', sort: 'updated', per_page: 10 }; + + const result = buildHttpRequest(config, input, 'https://api.github.com'); + + expect(result.method).toBe('GET'); + expect(result.url).toContain('/user/repos'); + expect(result.url).toContain('type=owner'); + expect(result.url).toContain('sort=updated'); + expect(result.url).toContain('per_page=10'); + expect(result.body).toBeUndefined(); + }); + + it('given no params, should build request without query string params', () => { + const config = (tool.execution as { config: HttpExecutionConfig }).config; + const result = buildHttpRequest(config, {}, 'https://api.github.com'); + + expect(result.url).toBe('https://api.github.com/user/repos'); + }); + + it('given the tool, should have output transform with mapping', () => { + expect(tool.outputTransform).toBeDefined(); + expect(tool.outputTransform!.mapping).toHaveProperty('full_name'); + expect(tool.outputTransform!.mapping).toHaveProperty('html_url'); + expect(tool.outputTransform!.maxLength).toBe(500); + }); + }); + + describe('get_issues tool', () => { + const tool = githubProvider.tools.find((t) => t.id === 'get_issues')!; + + it('given the tool, should require owner and repo', () => { + const required = (tool.inputSchema as { required: string[] }).required; + expect(required).toContain('owner'); + expect(required).toContain('repo'); + }); + + it('given owner, repo, and state filter, should build correct request', () => { + const config = (tool.execution as { config: HttpExecutionConfig }).config; + const input = { owner: 'acme', repo: 'webapp', state: 'open' }; + + const result = buildHttpRequest(config, input, 'https://api.github.com'); + + expect(result.url).toContain('/repos/acme/webapp/issues'); + expect(result.url).toContain('state=open'); + }); + }); + + describe('create_issue tool', () => { + const tool = githubProvider.tools.find((t) => t.id === 'create_issue')!; + + it('given the tool, should require owner, repo, and title', () => { + const required = (tool.inputSchema as { required: string[] }).required; + expect(required).toContain('owner'); + expect(required).toContain('repo'); + expect(required).toContain('title'); + }); + + it('given required and optional params, should build correct POST request', () => { + const config = (tool.execution as { config: HttpExecutionConfig }).config; + const input = { + owner: 'acme', + repo: 'webapp', + title: 'Bug: login broken', + body: 'Steps to reproduce...', + labels: ['bug', 'urgent'], + }; + + const result = buildHttpRequest(config, input, 'https://api.github.com'); + + expect(result.method).toBe('POST'); + expect(result.url).toBe('https://api.github.com/repos/acme/webapp/issues'); + + const body = JSON.parse(result.body!); + expect(body.title).toBe('Bug: login broken'); + expect(body.body).toBe('Steps to reproduce...'); + expect(body.labels).toEqual(['bug', 'urgent']); + }); + + it('given only required params, should omit optional body fields', () => { + const config = (tool.execution as { config: HttpExecutionConfig }).config; + const input = { owner: 'acme', repo: 'webapp', title: 'Feature request' }; + + const result = buildHttpRequest(config, input, 'https://api.github.com'); + const body = JSON.parse(result.body!); + + expect(body.title).toBe('Feature request'); + // JSON.stringify omits undefined values + expect(body).not.toHaveProperty('labels'); + expect(body).not.toHaveProperty('assignees'); + }); + }); + + describe('create_pr_comment tool', () => { + const tool = githubProvider.tools.find((t) => t.id === 'create_pr_comment')!; + + it('given the tool, should require owner, repo, issue_number, and body', () => { + const required = (tool.inputSchema as { required: string[] }).required; + expect(required).toEqual(['owner', 'repo', 'issue_number', 'body']); + }); + + it('given all required params, should build correct POST with integer path param', () => { + const config = (tool.execution as { config: HttpExecutionConfig }).config; + const input = { + owner: 'acme', + repo: 'webapp', + issue_number: 42, + body: 'LGTM!', + }; + + const result = buildHttpRequest(config, input, 'https://api.github.com'); + + expect(result.method).toBe('POST'); + expect(result.url).toBe( + 'https://api.github.com/repos/acme/webapp/issues/42/comments' + ); + expect(JSON.parse(result.body!)).toEqual({ body: 'LGTM!' }); + }); + }); + + describe('get_pull_request tool', () => { + const tool = githubProvider.tools.find((t) => t.id === 'get_pull_request')!; + + it('given the tool, should require owner, repo, and pull_number', () => { + const required = (tool.inputSchema as { required: string[] }).required; + expect(required).toContain('owner'); + expect(required).toContain('repo'); + expect(required).toContain('pull_number'); + }); + + it('given all required params, should build correct GET request', () => { + const config = (tool.execution as { config: HttpExecutionConfig }).config; + const input = { owner: 'acme', repo: 'webapp', pull_number: 7 }; + + const result = buildHttpRequest(config, input, 'https://api.github.com'); + + expect(result.url).toBe('https://api.github.com/repos/acme/webapp/pulls/7'); + expect(result.body).toBeUndefined(); + }); + }); + + describe('list_pull_requests tool', () => { + const tool = githubProvider.tools.find((t) => t.id === 'list_pull_requests')!; + + it('given the tool, should require owner and repo', () => { + const required = (tool.inputSchema as { required: string[] }).required; + expect(required).toContain('owner'); + expect(required).toContain('repo'); + }); + + it('given owner, repo, and filters, should build correct GET with query params', () => { + const config = (tool.execution as { config: HttpExecutionConfig }).config; + const input = { owner: 'acme', repo: 'webapp', state: 'open', direction: 'desc' }; + + const result = buildHttpRequest(config, input, 'https://api.github.com'); + + expect(result.url).toContain('/repos/acme/webapp/pulls'); + expect(result.url).toContain('state=open'); + expect(result.url).toContain('direction=desc'); + }); + }); + + describe('schema compatibility', () => { + it('given all tool input schemas, should convert to valid Zod schemas', () => { + for (const tool of githubProvider.tools) { + const zodSchema = convertToolSchemaToZod(tool.inputSchema); + expect(zodSchema).toBeDefined(); + expect(zodSchema.parse).toBeTypeOf('function'); + } + }); + }); +}); diff --git a/packages/lib/src/integrations/providers/github.ts b/packages/lib/src/integrations/providers/github.ts new file mode 100644 index 000000000..95fba5154 --- /dev/null +++ b/packages/lib/src/integrations/providers/github.ts @@ -0,0 +1,382 @@ +/** + * GitHub Provider Adapter + * + * Provides AI agents with access to GitHub repositories, issues, + * and pull requests via the GitHub REST API v3. + */ + +import type { IntegrationProviderConfig } from '../types'; + +export const githubProvider: IntegrationProviderConfig = { + id: 'github', + name: 'GitHub', + description: 'Access GitHub repositories, issues, and pull requests', + documentationUrl: 'https://docs.github.com/en/rest', + authMethod: { + type: 'oauth2', + config: { + authorizationUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + revokeUrl: 'https://github.com/settings/connections/applications', + scopes: ['repo', 'read:user'], + pkceRequired: false, + }, + }, + baseUrl: 'https://api.github.com', + defaultHeaders: { + 'Accept': 'application/vnd.github.v3+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + healthCheck: { + endpoint: '/user', + expectedStatus: 200, + }, + credentialSchema: { + type: 'object', + properties: { + accessToken: { + type: 'string', + description: 'OAuth2 access token', + }, + refreshToken: { + type: 'string', + description: 'OAuth2 refresh token', + }, + }, + required: ['accessToken'], + }, + rateLimit: { requests: 30, windowMs: 60_000 }, + tools: [ + // ─── Read Tools ────────────────────────────────────────────────────── + { + id: 'list_repos', + name: 'List Repositories', + description: 'List repositories for the authenticated user', + category: 'read', + inputSchema: { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['all', 'owner', 'public', 'private', 'member'], + description: 'Type of repositories to list', + }, + sort: { + type: 'string', + enum: ['created', 'updated', 'pushed', 'full_name'], + description: 'Sort field', + }, + per_page: { + type: 'integer', + description: 'Results per page (max 100)', + }, + page: { + type: 'integer', + description: 'Page number', + }, + }, + required: [], + }, + execution: { + type: 'http', + config: { + method: 'GET', + pathTemplate: '/user/repos', + queryParams: { + type: { $param: 'type' }, + sort: { $param: 'sort' }, + per_page: { $param: 'per_page', transform: 'string' }, + page: { $param: 'page', transform: 'string' }, + }, + }, + }, + outputTransform: { + mapping: { + full_name: 'full_name', + html_url: 'html_url', + description: 'description', + language: 'language', + stargazers_count: 'stargazers_count', + updated_at: 'updated_at', + private: 'private', + }, + maxLength: 500, + }, + }, + { + id: 'get_issues', + name: 'Get Issues', + description: 'List issues for a repository', + category: 'read', + inputSchema: { + type: 'object', + properties: { + owner: { + type: 'string', + description: 'Repository owner', + }, + repo: { + type: 'string', + description: 'Repository name', + }, + state: { + type: 'string', + enum: ['open', 'closed', 'all'], + description: 'Issue state filter', + }, + labels: { + type: 'string', + description: 'Comma-separated list of label names', + }, + sort: { + type: 'string', + enum: ['created', 'updated', 'comments'], + description: 'Sort field', + }, + }, + required: ['owner', 'repo'], + }, + execution: { + type: 'http', + config: { + method: 'GET', + pathTemplate: '/repos/{owner}/{repo}/issues', + queryParams: { + state: { $param: 'state' }, + labels: { $param: 'labels' }, + sort: { $param: 'sort' }, + }, + }, + }, + outputTransform: { + mapping: { + number: 'number', + title: 'title', + state: 'state', + html_url: 'html_url', + user: 'user.login', + labels: 'labels', + created_at: 'created_at', + }, + maxLength: 500, + }, + }, + { + id: 'get_pull_request', + name: 'Get Pull Request', + description: 'Get details of a specific pull request', + category: 'read', + inputSchema: { + type: 'object', + properties: { + owner: { + type: 'string', + description: 'Repository owner', + }, + repo: { + type: 'string', + description: 'Repository name', + }, + pull_number: { + type: 'integer', + description: 'Pull request number', + }, + }, + required: ['owner', 'repo', 'pull_number'], + }, + execution: { + type: 'http', + config: { + method: 'GET', + pathTemplate: '/repos/{owner}/{repo}/pulls/{pull_number}', + }, + }, + outputTransform: { + mapping: { + number: 'number', + title: 'title', + state: 'state', + html_url: 'html_url', + user: 'user.login', + head_ref: 'head.ref', + base_ref: 'base.ref', + mergeable: 'mergeable', + draft: 'draft', + created_at: 'created_at', + }, + maxLength: 500, + }, + }, + { + id: 'list_pull_requests', + name: 'List Pull Requests', + description: 'List pull requests for a repository', + category: 'read', + inputSchema: { + type: 'object', + properties: { + owner: { + type: 'string', + description: 'Repository owner', + }, + repo: { + type: 'string', + description: 'Repository name', + }, + state: { + type: 'string', + enum: ['open', 'closed', 'all'], + description: 'Pull request state filter', + }, + sort: { + type: 'string', + enum: ['created', 'updated', 'popularity', 'long-running'], + description: 'Sort field', + }, + direction: { + type: 'string', + enum: ['asc', 'desc'], + description: 'Sort direction', + }, + }, + required: ['owner', 'repo'], + }, + execution: { + type: 'http', + config: { + method: 'GET', + pathTemplate: '/repos/{owner}/{repo}/pulls', + queryParams: { + state: { $param: 'state' }, + sort: { $param: 'sort' }, + direction: { $param: 'direction' }, + }, + }, + }, + outputTransform: { + mapping: { + number: 'number', + title: 'title', + state: 'state', + html_url: 'html_url', + user: 'user.login', + draft: 'draft', + created_at: 'created_at', + }, + maxLength: 500, + }, + }, + + // ─── Write Tools ───────────────────────────────────────────────────── + { + id: 'create_issue', + name: 'Create Issue', + description: 'Create a new issue in a repository', + category: 'write', + inputSchema: { + type: 'object', + properties: { + owner: { + type: 'string', + description: 'Repository owner', + }, + repo: { + type: 'string', + description: 'Repository name', + }, + title: { + type: 'string', + description: 'Issue title', + }, + body: { + type: 'string', + description: 'Issue body (markdown)', + }, + labels: { + type: 'array', + items: { type: 'string' }, + description: 'Labels to apply', + }, + assignees: { + type: 'array', + items: { type: 'string' }, + description: 'Usernames to assign', + }, + }, + required: ['owner', 'repo', 'title'], + }, + execution: { + type: 'http', + config: { + method: 'POST', + pathTemplate: '/repos/{owner}/{repo}/issues', + bodyTemplate: { + title: { $param: 'title' }, + body: { $param: 'body' }, + labels: { $param: 'labels' }, + assignees: { $param: 'assignees' }, + }, + bodyEncoding: 'json', + }, + }, + outputTransform: { + mapping: { + number: 'number', + title: 'title', + html_url: 'html_url', + state: 'state', + }, + maxLength: 500, + }, + rateLimit: { requests: 10, windowMs: 60_000 }, + }, + { + id: 'create_pr_comment', + name: 'Create PR Comment', + description: 'Add a comment to a pull request (or issue)', + category: 'write', + inputSchema: { + type: 'object', + properties: { + owner: { + type: 'string', + description: 'Repository owner', + }, + repo: { + type: 'string', + description: 'Repository name', + }, + issue_number: { + type: 'integer', + description: 'Issue or pull request number', + }, + body: { + type: 'string', + description: 'Comment body (markdown)', + }, + }, + required: ['owner', 'repo', 'issue_number', 'body'], + }, + execution: { + type: 'http', + config: { + method: 'POST', + pathTemplate: '/repos/{owner}/{repo}/issues/{issue_number}/comments', + bodyTemplate: { + body: { $param: 'body' }, + }, + bodyEncoding: 'json', + }, + }, + outputTransform: { + mapping: { + id: 'id', + html_url: 'html_url', + body: 'body', + }, + maxLength: 500, + }, + rateLimit: { requests: 10, windowMs: 60_000 }, + }, + ], +}; diff --git a/packages/lib/src/integrations/providers/index.test.ts b/packages/lib/src/integrations/providers/index.test.ts new file mode 100644 index 000000000..aad5f0e3f --- /dev/null +++ b/packages/lib/src/integrations/providers/index.test.ts @@ -0,0 +1,88 @@ +/** + * Provider Registry Tests + * + * Validates the built-in provider registry, lookup utilities, + * and structural invariants. + */ + +import { describe, it, expect } from 'vitest'; +import { + builtinProviders, + builtinProviderList, + getBuiltinProvider, + isBuiltinProvider, + genericWebhookProvider, + githubProvider, +} from './index'; + +describe('builtinProviders registry', () => { + it('given the registry, should contain both providers', () => { + expect(Object.keys(builtinProviders)).toHaveLength(2); + expect(builtinProviders).toHaveProperty('generic-webhook'); + expect(builtinProviders).toHaveProperty('github'); + }); + + it('given the registry, should map to the correct provider objects', () => { + expect(builtinProviders['generic-webhook']).toBe(genericWebhookProvider); + expect(builtinProviders['github']).toBe(githubProvider); + }); + + it('given all provider IDs, should be unique', () => { + const ids = builtinProviderList.map((p) => p.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); + +describe('builtinProviderList', () => { + it('given the list, should contain all registered providers', () => { + expect(builtinProviderList).toHaveLength(2); + expect(builtinProviderList).toContain(genericWebhookProvider); + expect(builtinProviderList).toContain(githubProvider); + }); +}); + +describe('getBuiltinProvider', () => { + it('given a known provider ID, should return the provider config', () => { + expect(getBuiltinProvider('github')).toBe(githubProvider); + expect(getBuiltinProvider('generic-webhook')).toBe(genericWebhookProvider); + }); + + it('given an unknown provider ID, should return null', () => { + expect(getBuiltinProvider('unknown-provider')).toBeNull(); + expect(getBuiltinProvider('')).toBeNull(); + }); +}); + +describe('isBuiltinProvider', () => { + it('given a known provider ID, should return true', () => { + expect(isBuiltinProvider('github')).toBe(true); + expect(isBuiltinProvider('generic-webhook')).toBe(true); + }); + + it('given an unknown provider ID, should return false', () => { + expect(isBuiltinProvider('slack')).toBe(false); + expect(isBuiltinProvider('')).toBe(false); + }); +}); + +describe('provider structural invariants', () => { + it('given all providers, should each have at least one tool', () => { + for (const provider of builtinProviderList) { + expect(provider.tools.length).toBeGreaterThan(0); + } + }); + + it('given all providers, should each have a non-empty id and name', () => { + for (const provider of builtinProviderList) { + expect(provider.id).toBeTruthy(); + expect(provider.name).toBeTruthy(); + } + }); + + it('given all tools across all providers, should have unique IDs within their provider', () => { + for (const provider of builtinProviderList) { + const toolIds = provider.tools.map((t) => t.id); + expect(new Set(toolIds).size).toBe(toolIds.length); + } + }); +}); diff --git a/packages/lib/src/integrations/providers/index.ts b/packages/lib/src/integrations/providers/index.ts new file mode 100644 index 000000000..fe57a395e --- /dev/null +++ b/packages/lib/src/integrations/providers/index.ts @@ -0,0 +1,28 @@ +/** + * Built-in Provider Registry + * + * Central registry of all built-in integration provider adapters. + * Each adapter is a static IntegrationProviderConfig object — pure data. + * The execution engine handles all runtime concerns. + */ + +import type { IntegrationProviderConfig } from '../types'; +import { genericWebhookProvider } from './generic-webhook'; +import { githubProvider } from './github'; + +export { genericWebhookProvider } from './generic-webhook'; +export { githubProvider } from './github'; + +export const builtinProviders: Record = { + [genericWebhookProvider.id]: genericWebhookProvider, + [githubProvider.id]: githubProvider, +}; + +export const builtinProviderList: IntegrationProviderConfig[] = + Object.values(builtinProviders); + +export const getBuiltinProvider = ( + id: string +): IntegrationProviderConfig | null => builtinProviders[id] ?? null; + +export const isBuiltinProvider = (id: string): boolean => id in builtinProviders;