diff --git a/packages/github/package.json b/packages/github/package.json index 680a0f2..5314208 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -32,7 +32,7 @@ "github.mapping.yaml" ], "scripts": { - "build": "tsc -p tsconfig.json", + "build": "npm run build --workspace @relayfile/adapter-core && tsc -p tsconfig.json", "test": "npm run build && node --import tsx --test 'src/**/*.test.ts'", "lint": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit", diff --git a/packages/github/src/index.ts b/packages/github/src/index.ts index 76ac355..8aaad83 100644 --- a/packages/github/src/index.ts +++ b/packages/github/src/index.ts @@ -312,6 +312,7 @@ function readNestedValue(payload: Record, ...path: string[]): u export * from './config.js'; export * from './path-mapper.js'; export * from './types.js'; +export * from './operations.js'; export * from './webhook/event-map.js'; export * from './writeback.js'; diff --git a/packages/github/src/operations.test.ts b/packages/github/src/operations.test.ts new file mode 100644 index 0000000..b1f7f2c --- /dev/null +++ b/packages/github/src/operations.test.ts @@ -0,0 +1,278 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + getPull, + getPullDiff, + getRepository, + listComments, + listIssues, + listOrgs, + listPullRequests, + listReleases, + listRepos, + searchIssues, + searchRepos, + type GitHubOperation, + type GitHubRepoRef, +} from './operations.js'; + +describe('operations', () => { + it('listIssues builds the issues endpoint with joined labels and pagination', () => { + const operation = listIssues({ + owner: 'AgentWorkforce', + repo: 'cloud', + state: 'open', + labels: ['bug', 'p1'], + since: '2026-01-01T00:00:00Z', + per_page: 50, + page: 2, + }); + + assert.strictEqual(operation.method, 'GET'); + assert.strictEqual(operation.path, '/repos/AgentWorkforce/cloud/issues'); + assert.deepStrictEqual(operation.query, { + state: 'open', + labels: 'bug,p1', + since: '2026-01-01T00:00:00Z', + per_page: 50, + page: 2, + }); + }); + + it('listPullRequests preserves query filters for the pulls endpoint', () => { + const operation = listPullRequests({ + owner: 'AgentWorkforce', + repo: 'cloud', + state: 'closed', + base: 'main', + head: 'AgentWorkforce:feature/refactor-adapter', + sort: 'updated', + direction: 'desc', + per_page: 25, + page: 4, + }); + + assert.strictEqual(operation.method, 'GET'); + assert.strictEqual(operation.path, '/repos/AgentWorkforce/cloud/pulls'); + assert.deepStrictEqual(operation.query, { + state: 'closed', + base: 'main', + head: 'AgentWorkforce:feature/refactor-adapter', + sort: 'updated', + direction: 'desc', + per_page: 25, + page: 4, + }); + }); + + it('listComments targets issue comments and preserves comment pagination filters', () => { + const operation = listComments({ + owner: 'AgentWorkforce', + repo: 'cloud', + number: 42, + since: '2026-02-15T10:00:00Z', + per_page: 30, + page: 3, + }); + + assert.strictEqual(operation.method, 'GET'); + assert.strictEqual(operation.path, '/repos/AgentWorkforce/cloud/issues/42/comments'); + assert.deepStrictEqual(operation.query, { + since: '2026-02-15T10:00:00Z', + per_page: 30, + page: 3, + }); + }); + + it('listReleases targets the repository releases endpoint', () => { + const operation = listReleases({ + owner: 'AgentWorkforce', + repo: 'cloud', + }); + + assert.strictEqual(operation.method, 'GET'); + assert.strictEqual(operation.path, '/repos/AgentWorkforce/cloud/releases'); + assert.deepStrictEqual(operation.query, { per_page: 100 }); + }); + + it('getRepository targets the repository metadata endpoint', () => { + const operation = getRepository({ + owner: 'AgentWorkforce', + repo: 'cloud', + }); + + assert.strictEqual(operation.method, 'GET'); + assert.strictEqual(operation.path, '/repos/AgentWorkforce/cloud'); + assert.strictEqual(operation.query, undefined); + }); + + it('listOrgs targets the authenticated user orgs endpoint with pagination', () => { + const operation = listOrgs({ + per_page: 25, + page: 3, + }); + + assert.strictEqual(operation.method, 'GET'); + assert.strictEqual(operation.path, '/user/orgs'); + assert.deepStrictEqual(operation.query, { + per_page: 25, + page: 3, + }); + }); + + it('listRepos switches between user repos and org repos endpoints', () => { + const userRepos = listRepos(); + const orgRepos = listRepos({ org: 'AgentWorkforce' }); + + assert.strictEqual(userRepos.method, 'GET'); + assert.strictEqual(userRepos.path, '/user/repos'); + assert.deepStrictEqual(userRepos.query, { per_page: 100 }); + + assert.strictEqual(orgRepos.method, 'GET'); + assert.strictEqual(orgRepos.path, '/orgs/AgentWorkforce/repos'); + assert.deepStrictEqual(orgRepos.query, { per_page: 100 }); + }); + + it('encodes owner, repo, and org path segments and omits undefined query values', () => { + const repoOperation = listPullRequests({ + owner: 'Agent Workforce', + repo: 'cloud/api', + state: undefined, + base: undefined, + head: 'AgentWorkforce:feature/refactor-adapter', + sort: undefined, + direction: undefined, + page: undefined, + per_page: 20, + }); + const orgOperation = listRepos({ + org: 'Agent Workforce/Platform', + type: undefined, + sort: undefined, + direction: undefined, + page: undefined, + per_page: 15, + }); + + assert.strictEqual(repoOperation.path, '/repos/Agent%20Workforce/cloud%2Fapi/pulls'); + assert.deepStrictEqual(repoOperation.query, { + state: 'all', + head: 'AgentWorkforce:feature/refactor-adapter', + per_page: 20, + }); + assert.strictEqual('base' in (repoOperation.query ?? {}), false); + assert.strictEqual('sort' in (repoOperation.query ?? {}), false); + assert.strictEqual('direction' in (repoOperation.query ?? {}), false); + assert.strictEqual('page' in (repoOperation.query ?? {}), false); + + assert.strictEqual(orgOperation.path, '/orgs/Agent%20Workforce%2FPlatform/repos'); + assert.deepStrictEqual(orgOperation.query, { per_page: 15 }); + assert.strictEqual('type' in (orgOperation.query ?? {}), false); + assert.strictEqual('sort' in (orgOperation.query ?? {}), false); + assert.strictEqual('direction' in (orgOperation.query ?? {}), false); + assert.strictEqual('page' in (orgOperation.query ?? {}), false); + }); + + it('getPull targets the pull request endpoint', () => { + const operation = getPull({ + owner: 'AgentWorkforce', + repo: 'cloud', + number: 42, + }); + + assert.strictEqual(operation.method, 'GET'); + assert.strictEqual(operation.path, '/repos/AgentWorkforce/cloud/pulls/42'); + assert.strictEqual(operation.query, undefined); + }); + + it('getPullDiff reuses the same pure pull request operation', () => { + const input = { + owner: 'AgentWorkforce', + repo: 'cloud', + number: 42, + } as const; + + assert.deepStrictEqual(getPullDiff(input), getPull(input)); + }); + + it('searchIssues builds the search issues endpoint and optional repo scope', () => { + const operation = searchIssues({ + query: 'is:open label:bug', + repoSlug: 'AgentWorkforce/cloud', + sort: 'updated', + order: 'desc', + per_page: 20, + page: 3, + }); + + assert.strictEqual(operation.method, 'GET'); + assert.strictEqual(operation.path, '/search/issues'); + assert.deepStrictEqual(operation.query, { + q: 'is:open label:bug repo:AgentWorkforce/cloud', + sort: 'updated', + order: 'desc', + per_page: 20, + page: 3, + }); + }); + + it('searchRepos builds the repositories search endpoint', () => { + const operation = searchRepos({ + query: 'cloud', + sort: 'stars', + order: 'desc', + per_page: 10, + page: 2, + }); + + assert.strictEqual(operation.method, 'GET'); + assert.strictEqual(operation.path, '/search/repositories'); + assert.deepStrictEqual(operation.query, { + q: 'cloud in:name', + sort: 'stars', + order: 'desc', + per_page: 10, + page: 2, + }); + }); + + it('throws for invalid pagination inputs, including non-integers', () => { + const invalidValues = [0, -1, 0.5, 1.5, Number.POSITIVE_INFINITY, Number.NaN]; + + for (const per_page of invalidValues) { + assert.throws( + () => + listIssues({ + owner: 'AgentWorkforce', + repo: 'cloud', + per_page, + }), + /GitHub per_page must be a positive integer/, + ); + } + + for (const page of invalidValues) { + assert.throws( + () => + listIssues({ + owner: 'AgentWorkforce', + repo: 'cloud', + page, + }), + /GitHub page must be a positive integer/, + ); + } + }); + + it('provides compile-time coverage for GitHubOperation and GitHubRepoRef', () => { + const repoRef: GitHubRepoRef = { + owner: 'AgentWorkforce', + repo: 'cloud', + }; + const operation: GitHubOperation = getRepository(repoRef); + + assert.strictEqual(operation.method, 'GET'); + assert.strictEqual(operation.path, '/repos/AgentWorkforce/cloud'); + }); +}); diff --git a/packages/github/src/operations.ts b/packages/github/src/operations.ts new file mode 100644 index 0000000..7bafe96 --- /dev/null +++ b/packages/github/src/operations.ts @@ -0,0 +1,263 @@ +export type GitHubOperationMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; + +type GitHubOperationQueryValue = string | number | boolean | undefined; + +const DEFAULT_PER_PAGE = 100; + +export interface GitHubOperation { + method: GitHubOperationMethod; + path: string; + query?: Record; + body?: unknown; +} + +export interface GitHubRepoRef { + owner: string; + repo: string; +} + +export interface GitHubPaginationInput { + per_page?: number; + page?: number; +} + +export interface GitHubListIssuesInput extends GitHubRepoRef, GitHubPaginationInput { + state?: 'open' | 'closed' | 'all'; + labels?: string | string[]; + since?: string; + assignee?: string; + sort?: string; + direction?: 'asc' | 'desc'; +} + +export interface GitHubListPullRequestsInput extends GitHubRepoRef, GitHubPaginationInput { + state?: 'open' | 'closed' | 'all'; + base?: string; + head?: string; + sort?: string; + direction?: 'asc' | 'desc'; +} + +export interface GitHubListCommentsInput extends GitHubRepoRef, GitHubPaginationInput { + number: number; + since?: string; +} + +export interface GitHubListReleasesInput extends GitHubRepoRef, GitHubPaginationInput {} + +export interface GitHubListReposInput extends GitHubPaginationInput { + org?: string; + type?: string; + sort?: string; + direction?: 'asc' | 'desc'; +} + +export interface GitHubPullRequestRef extends GitHubRepoRef { + number: number; +} + +export interface GitHubSearchIssuesInput extends GitHubPaginationInput { + query: string; + repoSlug?: string; + sort?: string; + order?: 'asc' | 'desc'; +} + +export interface GitHubSearchReposInput extends GitHubPaginationInput { + query: string; + sort?: string; + order?: 'asc' | 'desc'; +} + +export function listIssues(input: GitHubListIssuesInput): GitHubOperation { + return { + method: 'GET', + path: buildRepoPath(input, '/issues'), + query: compactQuery({ + state: input.state ?? 'all', + labels: serializeLabels(input.labels), + since: normalizeOptionalString(input.since), + assignee: normalizeOptionalString(input.assignee), + sort: normalizeOptionalString(input.sort), + direction: input.direction, + ...paginationQuery(input), + }), + }; +} + +export function listPullRequests(input: GitHubListPullRequestsInput): GitHubOperation { + return { + method: 'GET', + path: buildRepoPath(input, '/pulls'), + query: compactQuery({ + state: input.state ?? 'all', + base: normalizeOptionalString(input.base), + head: normalizeOptionalString(input.head), + sort: normalizeOptionalString(input.sort), + direction: input.direction, + ...paginationQuery(input), + }), + }; +} + +export function listComments(input: GitHubListCommentsInput): GitHubOperation { + return { + method: 'GET', + path: `${buildRepoPath(input, '/issues')}/${formatPositiveInteger(input.number, 'number')}/comments`, + query: compactQuery({ + since: normalizeOptionalString(input.since), + ...paginationQuery(input), + }), + }; +} + +export function listReleases(input: GitHubListReleasesInput): GitHubOperation { + return { + method: 'GET', + path: buildRepoPath(input, '/releases'), + query: compactQuery(paginationQuery(input)), + }; +} + +export function getRepository(input: GitHubRepoRef): GitHubOperation { + return { + method: 'GET', + path: buildRepoPath(input), + }; +} + +export function listOrgs(input: GitHubPaginationInput = {}): GitHubOperation { + return { + method: 'GET', + path: '/user/orgs', + query: compactQuery(paginationQuery(input)), + }; +} + +export function listRepos(input: GitHubListReposInput = {}): GitHubOperation { + const org = normalizeOptionalString(input.org); + + return { + method: 'GET', + path: org ? `/orgs/${encodePathSegment(org, 'org')}/repos` : '/user/repos', + query: compactQuery({ + type: normalizeOptionalString(input.type), + sort: normalizeOptionalString(input.sort), + direction: input.direction, + ...paginationQuery(input), + }), + }; +} + +export function getPull(input: GitHubPullRequestRef): GitHubOperation { + return { + method: 'GET', + path: `${buildRepoPath(input, '/pulls')}/${formatPositiveInteger(input.number, 'number')}`, + }; +} + +// Diff vs. JSON content is transport-specific and should be selected by the caller's Accept header. +export function getPullDiff(input: GitHubPullRequestRef): GitHubOperation { + return getPull(input); +} + +export function searchIssues(input: GitHubSearchIssuesInput): GitHubOperation { + const query = requireNonEmptyString(input.query, 'query'); + const repoSlug = normalizeOptionalString(input.repoSlug); + + return { + method: 'GET', + path: '/search/issues', + query: compactQuery({ + q: repoSlug ? `${query} repo:${repoSlug}` : query, + sort: normalizeOptionalString(input.sort), + order: input.order, + ...paginationQuery(input), + }), + }; +} + +export function searchRepos(input: GitHubSearchReposInput): GitHubOperation { + return { + method: 'GET', + path: '/search/repositories', + query: compactQuery({ + q: `${requireNonEmptyString(input.query, 'query')} in:name`, + sort: normalizeOptionalString(input.sort), + order: input.order, + ...paginationQuery(input), + }), + }; +} + +function buildRepoPath(input: GitHubRepoRef, suffix = ''): string { + return `/repos/${encodePathSegment(input.owner, 'owner')}/${encodePathSegment(input.repo, 'repo')}${suffix}`; +} + +function paginationQuery(input: GitHubPaginationInput): Record<'per_page' | 'page', number | undefined> { + return { + per_page: normalizePositiveInteger(input.per_page, 'per_page') ?? DEFAULT_PER_PAGE, + page: normalizePositiveInteger(input.page, 'page'), + }; +} + +function serializeLabels(labels: string | string[] | undefined): string | undefined { + if (typeof labels === 'string') { + return normalizeOptionalString(labels); + } + + if (!Array.isArray(labels)) { + return undefined; + } + + const normalized = labels + .map((label) => normalizeOptionalString(label)) + .filter((label): label is string => label !== undefined); + + return normalized.length > 0 ? normalized.join(',') : undefined; +} + +function compactQuery( + query: Record, +): Record | undefined { + const entries = Object.entries(query).filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined); + + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +function encodePathSegment(value: string, fieldName: string): string { + return encodeURIComponent(requireNonEmptyString(value, fieldName)); +} + +function requireNonEmptyString(value: string, fieldName: string): string { + const normalized = normalizeOptionalString(value); + if (!normalized) { + throw new Error(`GitHub ${fieldName} must be a non-empty string`); + } + + return normalized; +} + +function normalizeOptionalString(value: string | undefined): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function normalizePositiveInteger(value: number | undefined, fieldName: string): number | undefined { + if (value === undefined) { + return undefined; + } + + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`GitHub ${fieldName} must be a positive integer`); + } + + return value; +} + +function formatPositiveInteger(value: number, fieldName: string): string { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`GitHub ${fieldName} must be a positive integer`); + } + + return String(value); +}