From 810a3e7fe83813718c09e14c90e94dfcc0a45cd9 Mon Sep 17 00:00:00 2001 From: mroops0111 Date: Mon, 15 Jun 2026 23:52:37 +0800 Subject: [PATCH] feat(source-loader): add GithubLoader for GitHub Issues ingest Closes #28. New @braidhq/source-loader-github plugin. Pulls issues from a GitHub repo via REST, renders each as /issues/.md with deterministic YAML frontmatter + body + sorted ## Comments. `sync` keeps a since-cursor in `.braid-github-cursor.json` and only rewrites files whose content actually changed, so downstream sha fingerprints don't churn on untouched issues. Registered in composeFs only when GH_TOKEN is set: anonymous GitHub gives 60 req/h, which won't survive a realistic sync, so skipping registration surfaces the misconfig at workspace load time rather than mid-batch. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/server/package.json | 1 + packages/server/src/composeFs.ts | 13 +- packages/source-loader-github/package.json | 44 +++ .../source-loader-github/src/GithubLoader.ts | 296 ++++++++++++++++++ packages/source-loader-github/src/index.ts | 1 + .../test/GithubLoader.test.ts | 263 ++++++++++++++++ packages/source-loader-github/tsconfig.json | 12 + .../source-loader-github/tsconfig.test.json | 5 + .../source-loader-github/vitest.config.ts | 8 + pnpm-lock.yaml | 51 +++ 10 files changed, 693 insertions(+), 1 deletion(-) create mode 100644 packages/source-loader-github/package.json create mode 100644 packages/source-loader-github/src/GithubLoader.ts create mode 100644 packages/source-loader-github/src/index.ts create mode 100644 packages/source-loader-github/test/GithubLoader.test.ts create mode 100644 packages/source-loader-github/tsconfig.json create mode 100644 packages/source-loader-github/tsconfig.test.json create mode 100644 packages/source-loader-github/vitest.config.ts diff --git a/packages/server/package.json b/packages/server/package.json index b4d8790..d9bfdbb 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -29,6 +29,7 @@ "@braidhq/schema": "workspace:*", "@braidhq/source-loader-gdrive": "workspace:*", "@braidhq/source-loader-git": "workspace:*", + "@braidhq/source-loader-github": "workspace:*", "@braidhq/storage-kuzu": "workspace:*", "@hono/node-server": "^2.0.4", "@hono/zod-openapi": "^0.19.10", diff --git a/packages/server/src/composeFs.ts b/packages/server/src/composeFs.ts index 9213ee0..6988cd5 100644 --- a/packages/server/src/composeFs.ts +++ b/packages/server/src/composeFs.ts @@ -20,6 +20,7 @@ import { dddOntology } from '@braidhq/ontology-ddd' import { AgentId, AgentKind, StorageKind as StorageKindSchema } from '@braidhq/schema' import { GoogleDriveLoader } from '@braidhq/source-loader-gdrive' import { GitLoader } from '@braidhq/source-loader-git' +import { GithubLoader } from '@braidhq/source-loader-github' import { kuzuStoragePlugin } from '@braidhq/storage-kuzu' import { composeApp } from './composition.js' import { SubprocessSkillRunner } from './infrastructure/agent/SubprocessSkillRunner.js' @@ -71,7 +72,10 @@ export interface ComposeFsOptions { * composition. The defaults bundle is: * - storage: `kuzuStoragePlugin` * - ontology: `dddOntology` - * - source-loader: `GitLoader` (+ `GoogleDriveLoader` if OAuth configured) + * - source-loader: `GitLoader`, `GoogleDriveLoader` (always; throws at + * ingest if OAuth env is missing), `GithubLoader` (only when + * `GH_TOKEN` is set, since anonymous GitHub access is rate-limited + * to 60 req/h) * - agent: `claudeCodeAgentPlugin` * * `composeFsApp` is the opinionated entry that ships with batteries. @@ -174,6 +178,13 @@ export async function composeFsApp(options: ComposeFsOptions = {}): Promise 0) + pluginRegistry.register(new GithubLoader()) for (const plugin of options.extraSourceLoaderPlugins ?? []) pluginRegistry.register(plugin) diff --git a/packages/source-loader-github/package.json b/packages/source-loader-github/package.json new file mode 100644 index 0000000..314a939 --- /dev/null +++ b/packages/source-loader-github/package.json @@ -0,0 +1,44 @@ +{ + "name": "@braidhq/source-loader-github", + "type": "module", + "version": "0.0.1", + "description": "GitHub Issues source-loader plugin for Braid. Ingest issues + comments into markdown.", + "license": "MIT", + "exports": { + ".": "./src/index.ts" + }, + "main": "./src/index.ts", + "types": "./src/index.ts", + "files": [ + "README.md", + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json", + "test": "vitest run", + "clean": "rm -rf dist .turbo *.tsbuildinfo coverage" + }, + "dependencies": { + "@braidhq/core": "workspace:*", + "@braidhq/schema": "workspace:*", + "yaml": "^2.9.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@braidhq/test-utils": "workspace:*", + "typescript": "^6.0.3", + "vitest": "^4.1.0" + }, + "publishConfig": { + "access": "public", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + } +} diff --git a/packages/source-loader-github/src/GithubLoader.ts b/packages/source-loader-github/src/GithubLoader.ts new file mode 100644 index 0000000..e365034 --- /dev/null +++ b/packages/source-loader-github/src/GithubLoader.ts @@ -0,0 +1,296 @@ +import type { IngestReport, SourceLoaderPlugin, SyncReport } from '@braidhq/core' +import type { AbsolutePath, LoaderKind, Timestamp } from '@braidhq/schema' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import process from 'node:process' +import { LoaderKind as LoaderKindSchema, PluginId as PluginIdSchema } from '@braidhq/schema' +import { stringify as stringifyYaml } from 'yaml' +import { z } from 'zod' + +/** + * Inject `fetchFn` for tests; real callers use globalThis.fetch. + */ +export type FetchFn = typeof globalThis.fetch + +export const GithubLoaderConfig = z.object({ + owner: z.string().min(1), + repo: z.string().min(1), + state: z.enum(['open', 'closed', 'all']).default('all'), + labels: z.array(z.string().min(1)).optional(), + includeComments: z.boolean().default(true), + /** + * GitHub's REST treats PRs as a subtype of issues. Default `false` so a + * source declared as "issues" doesn't silently pick up PR threads too. + */ + includePullRequests: z.boolean().default(false), + /** + * Auth token. Supports `${VAR}` interpolation against the server's process + * env. Defaults to `${GH_TOKEN}`. An empty string after interpolation + * means anonymous (60 req/h rate limit, public repos only). + */ + // eslint-disable-next-line no-template-curly-in-string -- literal `${VAR}` placeholder for env interpolation, NOT a template string + token: z.string().default('${GH_TOKEN}'), + /** REST base URL. Override for GitHub Enterprise. */ + apiBaseUrl: z.string().default('https://api.github.com'), +}) +export type GithubLoaderConfig = z.infer + +interface RawIssue { + number: number + title: string + state: string + user: { login: string } | null + labels: Array<{ name: string } | string> + body: string | null + html_url: string + created_at: string + updated_at: string + pull_request?: unknown + comments: number +} + +interface RawComment { + user: { login: string } | null + body: string | null + created_at: string + updated_at: string +} + +interface CursorFile { + owner: string + repo: string + since: string +} + +const CURSOR_FILENAME = '.braid-github-cursor.json' + +/** + * Source loader for a GitHub repository's Issues. The `destination` is + * owned by the loader: each issue is written as + * `/issues/.md` with a deterministic YAML + * frontmatter + body + `## Comments` section. Untouched issues stay + * byte-identical across `sync` so downstream sha-based fingerprints + * don't churn. + * + * Auth: pass the token via `${GH_TOKEN}` (or any other env var) in + * `config.token`. Tokens are never persisted on disk; only the rendered + * markdown lands in `destination`. + */ +export class GithubLoader implements SourceLoaderPlugin { + readonly id = PluginIdSchema.parse('source-loader-github') + readonly type = 'source-loader' as const + readonly kind: LoaderKind = LoaderKindSchema.parse('github') + readonly configSchema = GithubLoaderConfig + + constructor(private readonly fetchFn: FetchFn = globalThis.fetch) {} + + async ingest(rawConfig: unknown, destination: AbsolutePath): Promise { + const config = GithubLoaderConfig.parse(rawConfig) + const issuesDir = join(destination, 'issues') + await mkdir(issuesDir, { recursive: true }) + const headers = this.buildHeaders(config) + const issues = await this.fetchIssues(config, headers, undefined) + let mostRecent = '' + for (const issue of issues) { + if (issue.updated_at > mostRecent) + mostRecent = issue.updated_at + const comments = await this.fetchCommentsIfNeeded(config, headers, issue) + const markdown = renderIssueMarkdown(config, issue, comments) + const path = join(issuesDir, `${issue.number}.md`) + await writeIfChanged(path, markdown) + } + if (mostRecent) + await writeCursor(destination, { owner: config.owner, repo: config.repo, since: mostRecent }) + return { + localPath: destination, + metadata: { owner: config.owner, repo: config.repo, issueCount: issues.length }, + fetchedAt: new Date().toISOString() as Timestamp, + } + } + + async sync(rawConfig: unknown, destination: AbsolutePath): Promise { + const config = GithubLoaderConfig.parse(rawConfig) + const issuesDir = join(destination, 'issues') + await mkdir(issuesDir, { recursive: true }) + const headers = this.buildHeaders(config) + const cursor = await readCursor(destination) + const sinceParam = cursor?.owner === config.owner && cursor.repo === config.repo + ? cursor.since + : undefined + const issues = await this.fetchIssues(config, headers, sinceParam) + let added = 0 + let updated = 0 + let mostRecent = cursor?.since ?? '' + for (const issue of issues) { + if (issue.updated_at > mostRecent) + mostRecent = issue.updated_at + const comments = await this.fetchCommentsIfNeeded(config, headers, issue) + const markdown = renderIssueMarkdown(config, issue, comments) + const path = join(issuesDir, `${issue.number}.md`) + const result = await writeIfChanged(path, markdown) + if (result === 'added') + added++ + else if (result === 'updated') + updated++ + } + if (mostRecent) + await writeCursor(destination, { owner: config.owner, repo: config.repo, since: mostRecent }) + return { + changed: added + updated > 0, + added, + updated, + removed: 0, + metadata: { owner: config.owner, repo: config.repo, since: sinceParam ?? null }, + fetchedAt: new Date().toISOString() as Timestamp, + } + } + + private buildHeaders(config: GithubLoaderConfig): Record { + const headers: Record = { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'braid-source-loader-github', + } + const token = interpolateEnv(config.token).trim() + if (token.length > 0) + headers.Authorization = `Bearer ${token}` + return headers + } + + private async fetchIssues( + config: GithubLoaderConfig, + headers: Record, + since: string | undefined, + ): Promise { + const params = new URLSearchParams() + params.set('state', config.state) + params.set('per_page', '100') + params.set('sort', 'updated') + params.set('direction', 'asc') + if (config.labels && config.labels.length > 0) + params.set('labels', config.labels.join(',')) + if (since) + params.set('since', since) + let url = `${config.apiBaseUrl}/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}/issues?${params.toString()}` + const out: RawIssue[] = [] + while (url) { + const response = await this.fetchFn(url, { headers }) + if (!response.ok) { + const body = await response.text().catch(() => '') + throw new Error(`GithubLoader: GET ${url} failed (${response.status}): ${body.slice(0, 200)}`) + } + const page = (await response.json()) as RawIssue[] + for (const issue of page) { + if (!config.includePullRequests && issue.pull_request !== undefined) + continue + out.push(issue) + } + url = parseNextLink(response.headers.get('link')) ?? '' + } + return out + } + + private async fetchCommentsIfNeeded( + config: GithubLoaderConfig, + headers: Record, + issue: RawIssue, + ): Promise { + if (!config.includeComments || issue.comments === 0) + return [] + const params = new URLSearchParams() + params.set('per_page', '100') + let url = `${config.apiBaseUrl}/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}/issues/${issue.number}/comments?${params.toString()}` + const out: RawComment[] = [] + while (url) { + const response = await this.fetchFn(url, { headers }) + if (!response.ok) { + const body = await response.text().catch(() => '') + throw new Error(`GithubLoader: GET ${url} failed (${response.status}): ${body.slice(0, 200)}`) + } + const page = (await response.json()) as RawComment[] + out.push(...page) + url = parseNextLink(response.headers.get('link')) ?? '' + } + out.sort((a, b) => a.created_at.localeCompare(b.created_at)) + return out + } +} + +function renderIssueMarkdown( + config: GithubLoaderConfig, + issue: RawIssue, + comments: readonly RawComment[], +): string { + const labels = (issue.labels ?? []) + .map(l => typeof l === 'string' ? l : l.name) + .filter((name): name is string => typeof name === 'string' && name.length > 0) + .sort() + const frontmatter = { + number: issue.number, + title: issue.title, + state: issue.state, + author: issue.user?.login ?? null, + labels, + createdAt: issue.created_at, + updatedAt: issue.updated_at, + url: issue.html_url, + } + const yaml = stringifyYaml(frontmatter, { lineWidth: 0 }).trimEnd() + const body = (issue.body ?? '').trimEnd() + const parts = [`---\n${yaml}\n---`, '', body] + if (config.includeComments && comments.length > 0) { + parts.push('', '## Comments') + for (const comment of comments) { + const author = comment.user?.login ?? 'unknown' + parts.push('', `### ${author} — ${comment.created_at}`, '', (comment.body ?? '').trimEnd()) + } + } + return `${parts.join('\n').trimEnd()}\n` +} + +async function writeIfChanged(path: string, content: string): Promise<'added' | 'updated' | 'unchanged'> { + let existing: string | undefined + try { + existing = await readFile(path, 'utf-8') + } + catch { + existing = undefined + } + if (existing === content) + return 'unchanged' + await writeFile(path, content, 'utf-8') + return existing === undefined ? 'added' : 'updated' +} + +async function readCursor(destination: string): Promise { + try { + const raw = await readFile(join(destination, CURSOR_FILENAME), 'utf-8') + return JSON.parse(raw) as CursorFile + } + catch { + return undefined + } +} + +async function writeCursor(destination: string, cursor: CursorFile): Promise { + await writeFile( + join(destination, CURSOR_FILENAME), + `${JSON.stringify(cursor, null, 2)}\n`, + 'utf-8', + ) +} + +function parseNextLink(header: string | null): string | undefined { + if (!header) + return undefined + for (const part of header.split(',')) { + const match = part.trim().match(/^<([^>]+)>;\s*rel="next"$/) + if (match) + return match[1] + } + return undefined +} + +function interpolateEnv(input: string): string { + return input.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (_match, name: string) => process.env[name] ?? '') +} diff --git a/packages/source-loader-github/src/index.ts b/packages/source-loader-github/src/index.ts new file mode 100644 index 0000000..bde5cad --- /dev/null +++ b/packages/source-loader-github/src/index.ts @@ -0,0 +1 @@ +export * from './GithubLoader.js' diff --git a/packages/source-loader-github/test/GithubLoader.test.ts b/packages/source-loader-github/test/GithubLoader.test.ts new file mode 100644 index 0000000..76d1800 --- /dev/null +++ b/packages/source-loader-github/test/GithubLoader.test.ts @@ -0,0 +1,263 @@ +import type { AbsolutePath } from '@braidhq/schema' +import { mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { parse as parseYaml } from 'yaml' +import { GithubLoader } from '../src/GithubLoader.js' + +interface MockIssue { + number: number + title: string + state?: string + user?: { login: string } | null + labels?: Array<{ name: string }> + body?: string | null + html_url?: string + created_at: string + updated_at: string + pull_request?: object + comments?: number +} + +interface MockComment { + user?: { login: string } | null + body?: string | null + created_at: string + updated_at: string +} + +interface MockRouter { + issues: readonly MockIssue[] + comments?: Record + pageSize?: number +} + +function buildMockFetch(router: MockRouter, recorder?: { calls: string[], lastHeaders: Headers | null }): typeof globalThis.fetch { + return async (input, init) => { + const url = typeof input === 'string' ? input : input.toString() + if (recorder) { + recorder.calls.push(url) + recorder.lastHeaders = new Headers(init?.headers ?? {}) + } + const parsed = new URL(url) + const issuesMatch = parsed.pathname.match(/^\/repos\/([^/]+)\/([^/]+)\/issues$/) + if (issuesMatch) { + const since = parsed.searchParams.get('since') + const state = parsed.searchParams.get('state') ?? 'all' + const page = Number.parseInt(parsed.searchParams.get('page') ?? '1', 10) + const pageSize = router.pageSize ?? 100 + let filtered = router.issues.slice() + if (since) + filtered = filtered.filter(i => i.updated_at > since) + if (state !== 'all') + filtered = filtered.filter(i => (i.state ?? 'open') === state) + const start = (page - 1) * pageSize + const slice = filtered.slice(start, start + pageSize) + const headers: Record = { 'Content-Type': 'application/json' } + if (start + pageSize < filtered.length) { + const nextUrl = new URL(url) + nextUrl.searchParams.set('page', String(page + 1)) + headers.link = `<${nextUrl.toString()}>; rel="next"` + } + return new Response(JSON.stringify(slice), { status: 200, headers }) + } + const commentsMatch = parsed.pathname.match(/^\/repos\/([^/]+)\/([^/]+)\/issues\/(\d+)\/comments$/) + if (commentsMatch) { + const num = Number.parseInt(commentsMatch[3]!, 10) + const comments = router.comments?.[num] ?? [] + return new Response(JSON.stringify(comments), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + return new Response('no route', { status: 404 }) + } +} + +interface ParsedFrontmatter { + number: number + title: string + state: string + author: string | null + labels: readonly string[] + createdAt: string + updatedAt: string + url: string +} + +function splitMarkdown(content: string): { fm: ParsedFrontmatter, rest: string } { + const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/) + if (!match) + throw new Error('frontmatter missing') + return { fm: parseYaml(match[1]!) as ParsedFrontmatter, rest: match[2]! } +} + +describe('GithubLoader', () => { + let dest: AbsolutePath + + beforeEach(async () => { + dest = await mkdtemp(join(tmpdir(), 'braid-github-loader-')) as AbsolutePath + }) + + afterEach(async () => { + await rm(dest, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('ingest writes one markdown file per issue with deterministic frontmatter', async () => { + const fetchFn = buildMockFetch({ + issues: [ + { + number: 1, + title: 'First issue', + state: 'open', + user: { login: 'alice' }, + labels: [{ name: 'bug' }, { name: 'p1' }], + body: 'Body of issue 1.', + html_url: 'https://github.com/o/r/issues/1', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-02-01T00:00:00Z', + comments: 0, + }, + { + number: 2, + title: 'Second issue', + state: 'closed', + user: { login: 'bob' }, + body: '', + html_url: 'https://github.com/o/r/issues/2', + created_at: '2026-01-02T00:00:00Z', + updated_at: '2026-03-01T00:00:00Z', + comments: 0, + }, + ], + }) + + const loader = new GithubLoader(fetchFn) + const report = await loader.ingest({ owner: 'o', repo: 'r' }, dest) + + expect(report.localPath).toBe(dest) + const files = (await readdir(join(dest, 'issues'))).sort() + expect(files).toEqual(['1.md', '2.md']) + + const issue1 = await readFile(join(dest, 'issues', '1.md'), 'utf-8') + const { fm, rest } = splitMarkdown(issue1) + expect(fm.number).toBe(1) + expect(fm.title).toBe('First issue') + expect(fm.author).toBe('alice') + expect(fm.labels).toEqual(['bug', 'p1']) + expect(rest.trim()).toBe('Body of issue 1.') + }) + + it('omits pull requests by default and includes them when opted in', async () => { + const router: MockRouter = { + issues: [ + { number: 1, title: 'Issue', created_at: 't', updated_at: 't' }, + { number: 2, title: 'PR', pull_request: { url: 'x' }, created_at: 't', updated_at: 't' }, + ], + } + + const loaderDefault = new GithubLoader(buildMockFetch(router)) + await loaderDefault.ingest({ owner: 'o', repo: 'r' }, dest) + expect((await readdir(join(dest, 'issues'))).sort()).toEqual(['1.md']) + + await rm(join(dest, 'issues'), { recursive: true, force: true }) + + const loaderWithPRs = new GithubLoader(buildMockFetch(router)) + await loaderWithPRs.ingest({ owner: 'o', repo: 'r', includePullRequests: true }, dest) + expect((await readdir(join(dest, 'issues'))).sort()).toEqual(['1.md', '2.md']) + }) + + it('appends sorted comments under ## Comments', async () => { + const fetchFn = buildMockFetch({ + issues: [{ + number: 7, + title: 'Discussion', + body: 'Original.', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-03T00:00:00Z', + comments: 2, + }], + comments: { + 7: [ + { user: { login: 'bob' }, body: 'second', created_at: '2026-01-03T00:00:00Z', updated_at: '2026-01-03T00:00:00Z' }, + { user: { login: 'alice' }, body: 'first', created_at: '2026-01-02T00:00:00Z', updated_at: '2026-01-02T00:00:00Z' }, + ], + }, + }) + + const loader = new GithubLoader(fetchFn) + await loader.ingest({ owner: 'o', repo: 'r' }, dest) + const content = await readFile(join(dest, 'issues', '7.md'), 'utf-8') + expect(content).toContain('## Comments') + const aliceIdx = content.indexOf('alice') + const bobIdx = content.indexOf('bob') + expect(aliceIdx).toBeGreaterThan(0) + expect(bobIdx).toBeGreaterThan(aliceIdx) + }) + + it('follows the Link: rel="next" header for pagination', async () => { + const issues = Array.from({ length: 3 }, (_, i) => ({ + number: i + 1, + title: `Issue ${i + 1}`, + created_at: '2026-01-01T00:00:00Z', + updated_at: `2026-01-0${i + 1}T00:00:00Z`, + })) + const fetchFn = buildMockFetch({ issues, pageSize: 1 }) + + const loader = new GithubLoader(fetchFn) + await loader.ingest({ owner: 'o', repo: 'r' }, dest) + expect((await readdir(join(dest, 'issues'))).sort()).toEqual(['1.md', '2.md', '3.md']) + }) + + it('sync with cursor only rewrites issues whose updated_at moved; untouched files stay byte-identical', async () => { + const router: MockRouter = { + issues: [ + { number: 1, title: 'One', body: 'one', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-02-01T00:00:00Z' }, + { number: 2, title: 'Two', body: 'two', created_at: '2026-01-02T00:00:00Z', updated_at: '2026-02-02T00:00:00Z' }, + ], + } + const loader = new GithubLoader(buildMockFetch(router)) + await loader.ingest({ owner: 'o', repo: 'r' }, dest) + + const beforeStat1 = await stat(join(dest, 'issues', '1.md')) + const beforeContent1 = await readFile(join(dest, 'issues', '1.md'), 'utf-8') + + // Bump issue 2 only; issue 1 stays as-is. + const router2: MockRouter = { + issues: [ + { number: 1, title: 'One', body: 'one', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-02-01T00:00:00Z' }, + { number: 2, title: 'Two (edited)', body: 'two edited', created_at: '2026-01-02T00:00:00Z', updated_at: '2026-03-01T00:00:00Z' }, + ], + } + const loader2 = new GithubLoader(buildMockFetch(router2)) + const report = await loader2.sync({ owner: 'o', repo: 'r' }, dest) + + expect(report.changed).toBe(true) + expect(report.added).toBe(0) + expect(report.updated).toBe(1) + + const afterStat1 = await stat(join(dest, 'issues', '1.md')) + const afterContent1 = await readFile(join(dest, 'issues', '1.md'), 'utf-8') + expect(afterContent1).toBe(beforeContent1) + expect(afterStat1.mtimeMs).toBe(beforeStat1.mtimeMs) + + const content2 = await readFile(join(dest, 'issues', '2.md'), 'utf-8') + expect(content2).toContain('Two (edited)') + }) + + // eslint-disable-next-line no-template-curly-in-string -- describing the literal `${VAR}` placeholder, NOT a template string + it('sets Authorization header from ${GH_TOKEN} env interpolation; omits it when unset', async () => { + const recorder = { calls: [] as string[], lastHeaders: null as Headers | null } + const fetchFn = buildMockFetch({ issues: [] }, recorder) + + vi.stubEnv('GH_TOKEN', 'ghp-abc-123') + const loader = new GithubLoader(fetchFn) + await loader.ingest({ owner: 'o', repo: 'r' }, dest) + expect(recorder.lastHeaders?.get('Authorization')).toBe('Bearer ghp-abc-123') + + vi.unstubAllEnvs() + const recorder2 = { calls: [] as string[], lastHeaders: null as Headers | null } + const loader2 = new GithubLoader(buildMockFetch({ issues: [] }, recorder2)) + await loader2.ingest({ owner: 'o', repo: 'r' }, dest) + expect(recorder2.lastHeaders?.get('Authorization')).toBeNull() + }) +}) diff --git a/packages/source-loader-github/tsconfig.json b/packages/source-loader-github/tsconfig.json new file mode 100644 index 0000000..058118a --- /dev/null +++ b/packages/source-loader-github/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": "./dist/.tsbuildinfo", + "lib": ["ES2022"], + "rootDir": "./src", + "types": ["node"], + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/source-loader-github/tsconfig.test.json b/packages/source-loader-github/tsconfig.test.json new file mode 100644 index 0000000..9a8b612 --- /dev/null +++ b/packages/source-loader-github/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.test.base.json", + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/source-loader-github/vitest.config.ts b/packages/source-loader-github/vitest.config.ts new file mode 100644 index 0000000..8bab993 --- /dev/null +++ b/packages/source-loader-github/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + environment: 'node', + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edb8390..c93a1d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,22 @@ importers: specifier: ^4.1.0 version: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + examples/redoc-extending-ddd: + dependencies: + '@braidhq/ontology-ddd': + specifier: workspace:* + version: link:../../packages/ontology-ddd + '@braidhq/schema': + specifier: workspace:* + version: link:../../packages/schema + '@braidhq/sdk': + specifier: workspace:* + version: link:../../packages/sdk + devDependencies: + typescript: + specifier: ^5.7.0 + version: 5.9.3 + packages/agent-claude-code: dependencies: '@braidhq/core': @@ -237,6 +253,9 @@ importers: '@braidhq/source-loader-git': specifier: workspace:* version: link:../source-loader-git + '@braidhq/source-loader-github': + specifier: workspace:* + version: link:../source-loader-github '@braidhq/storage-kuzu': specifier: workspace:* version: link:../storage-kuzu @@ -328,6 +347,31 @@ importers: specifier: ^4.1.0 version: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/source-loader-github: + dependencies: + '@braidhq/core': + specifier: workspace:* + version: link:../core + '@braidhq/schema': + specifier: workspace:* + version: link:../schema + yaml: + specifier: ^2.9.0 + version: 2.9.0 + zod: + specifier: ^3.24.0 + version: 3.25.76 + devDependencies: + '@braidhq/test-utils': + specifier: workspace:* + version: link:../test-utils + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.0 + version: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/storage-kuzu: dependencies: '@braidhq/core': @@ -4830,6 +4874,11 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -9680,6 +9729,8 @@ snapshots: type-fest@4.41.0: {} + typescript@5.9.3: {} + typescript@6.0.3: {} ufo@1.6.4: {}