From 424acfbfbfff6759996d9e8fa2c9c101cea16401 Mon Sep 17 00:00:00 2001 From: RelayFile Adapters Bot Date: Sun, 26 Apr 2026 20:42:11 +0200 Subject: [PATCH 1/3] feat(adapter-github): extract canonical GitHub REST operation builders + request types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracks relayfile-adapters#24 — one canonical home for GitHub API knowledge so cloud route.ts, cloud apifallback, and sage nango-integrations syncs all consume the same operations instead of holding duplicate REST path builders. New exports under @relayfile/adapter-github: - Operation builders: listIssues, listPullRequests, listComments, listReleases - Repository helpers: getRepository, listOrgs, listRepos - Request/operation types: GitHubOperation, GitHubRepoRef, pagination and input interfaces Endpoint and query-parameter shapes are the UNION of what Nango, cloud, and sage consumers need. Validated against: 1. Nango integration-templates for GitHub (canonical baseline) 2. sage nango-integrations/github-sage/syncs/*.ts (production-proven) 3. cloud packages/web/app/api/v1/github/query/route.ts + apifallback (consumers) Live verification: Representative operations were sent through the real Nango proxy using sage NANGO_SECRET_KEY, with connection IDs dynamically discovered via nango.listConnections(). Operations returned non-error responses. Catches endpoint/scope drift at extract time so we never publish an operation builder that fails in production. Version: minor bump (additive — new exports, no breaking changes). Follow-up PRs (tracked in relayfile-adapters#24): - cloud refactor: import from @relayfile/adapter-github, drop local copies - sage refactor: same for nango-integrations/github-sage/syncs/* Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/github/package.json | 4 +- packages/github/src/index.ts | 1 + packages/github/src/operations.test.ts | 183 +++++++++++++++++ packages/github/src/operations.ts | 263 +++++++++++++++++++++++++ 4 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 packages/github/src/operations.test.ts create mode 100644 packages/github/src/operations.ts diff --git a/packages/github/package.json b/packages/github/package.json index 31f143a..6ec356c 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/adapter-github", - "version": "0.1.4", + "version": "0.2.0", "description": "GitHub adapter scaffold for Relayfile", "type": "module", "main": "dist/index.js", @@ -24,7 +24,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..9fd31f4 --- /dev/null +++ b/packages/github/src/operations.test.ts @@ -0,0 +1,183 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + getRepository, + listComments, + listIssues, + listOrgs, + listPullRequests, + listReleases, + listRepos, + 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('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..757daff --- /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.isFinite(value) || value <= 0) { + throw new Error(`GitHub ${fieldName} must be a positive integer`); + } + + return Math.floor(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); +} From dfd8aed5974a1dc954eff2b705e1573f6ce0ab5a Mon Sep 17 00:00:00 2001 From: RelayFile Adapters Bot Date: Sun, 26 Apr 2026 23:12:52 +0200 Subject: [PATCH 2/3] fix(adapter-github): address PR review findings --- packages/github/src/operations.test.ts | 95 ++++++++++++++++++++++++++ packages/github/src/operations.ts | 4 +- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/packages/github/src/operations.test.ts b/packages/github/src/operations.test.ts index 9fd31f4..b1f7f2c 100644 --- a/packages/github/src/operations.test.ts +++ b/packages/github/src/operations.test.ts @@ -2,6 +2,8 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { + getPull, + getPullDiff, getRepository, listComments, listIssues, @@ -9,6 +11,8 @@ import { listPullRequests, listReleases, listRepos, + searchIssues, + searchRepos, type GitHubOperation, type GitHubRepoRef, } from './operations.js'; @@ -170,6 +174,97 @@ describe('operations', () => { 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', diff --git a/packages/github/src/operations.ts b/packages/github/src/operations.ts index 757daff..7bafe96 100644 --- a/packages/github/src/operations.ts +++ b/packages/github/src/operations.ts @@ -247,11 +247,11 @@ function normalizePositiveInteger(value: number | undefined, fieldName: string): return undefined; } - if (!Number.isFinite(value) || value <= 0) { + if (!Number.isInteger(value) || value <= 0) { throw new Error(`GitHub ${fieldName} must be a positive integer`); } - return Math.floor(value); + return value; } function formatPositiveInteger(value: number, fieldName: string): string { From 0ccb349a0c464131da3b1d01054e4ab905b71388 Mon Sep 17 00:00:00 2001 From: RelayFile Adapters Bot Date: Sun, 26 Apr 2026 23:26:33 +0200 Subject: [PATCH 3/3] chore(adapter-github): drop manual version bump --- packages/github/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/github/package.json b/packages/github/package.json index 6ec356c..9d1936e 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/adapter-github", - "version": "0.2.0", + "version": "0.1.8", "description": "GitHub adapter scaffold for Relayfile", "type": "module", "main": "dist/index.js",