diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e75b1d..4f50b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to Kernel will be documented in this file. This project follows semantic versioning once public releases begin. +## 0.2.0 + +### Added + +- Repo intelligence (0.4): v2 map schemas, CODEOWNERS, monorepo workspaces, Makefile/justfile command detection, config-aware risk maps. +- Policy engine (0.5): `policy-gate.yaml`, `kernel policy check`, verification escalation, CI policy validation. +- GitHub context (0.6): `kernel context pr` and `kernel context issue` read-only providers with optional `.agent/context` cache. +- Optional `context.github` config block for owner/repo resolution. +- Public sync workflow now verifies `kernel policy check --ci` on exported tree. + +### Notes + +- Private `kernel-skills` remains `"private": true`; npm publication uses the public `mattbaconz/kernel` repository. + ## 0.1.0 - Unreleased ### Added @@ -32,9 +46,6 @@ This project follows semantic versioning once public releases begin. - Adapter compile deduplication for shared output paths across ADEs. - Expanded eval fixtures for context-router, risk-map, diff-surgeon, and repo-cartographer. - Updated CLI Command Spec for implemented commands and flags. -- Repo intelligence (0.4): v2 map schemas, CODEOWNERS, monorepo workspaces, Makefile/justfile command detection, config-aware risk maps. -- Policy engine (0.5): `policy-gate.yaml`, `kernel policy check`, verification escalation, CI policy validation. -- `kernel init` seeds `.agent/policies/policy-gate.yaml`. ### Notes diff --git a/package.json b/package.json index a551410..a331e46 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mattbaconz/kernel", - "version": "0.1.0", + "version": "0.2.0", "description": "Kernel CLI: a repo-local quality system and portable operating layer for coding agents.", "type": "module", "private": true, diff --git a/src/cli/index.ts b/src/cli/index.ts index 2bdc691..288a066 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -36,6 +36,12 @@ import { formatPolicyCheckJsonResult, formatPolicyCheckResult } from '../core/policy/check.js'; +import { + fetchContext, + formatContextJsonResult, + formatContextResult +} from '../core/context/fetch.js'; +import { KernelContextError } from '../core/context/types.js'; import { createCliJsonErrorEnvelope, formatCliJsonErrorEnvelope } from './json-errors.js'; export function createKernelProgram(): Command { @@ -44,7 +50,7 @@ export function createKernelProgram(): Command { program .name('kernel') .description('Repo-local quality system and portable operating layer for coding agents.') - .version('0.1.0'); + .version('0.2.0'); program .command('init') @@ -175,6 +181,85 @@ export function createKernelProgram(): Command { } }); + const context = program.command('context').description('Fetch read-only external context for agents.'); + context + .command('pr') + .description('Fetch GitHub pull request context.') + .option('--number ', 'pull request number', (value) => Number.parseInt(value, 10)) + .option('--current', 'infer pull request from current branch or gh pr view') + .option('--dry-run', 'fetch context without writing .agent/context cache') + .option('--json', 'print machine-readable JSON') + .action(async (options: { number?: number; current?: boolean; dryRun?: boolean; json?: boolean }) => { + try { + const result = await fetchContext({ + kind: 'pr', + number: options.number, + current: Boolean(options.current), + dryRun: Boolean(options.dryRun) + }); + + if (options.json) { + process.stdout.write(formatContextJsonResult(result)); + } else { + process.stdout.write(`${formatContextResult(result)}\n`); + } + + if (result.status === 'error') { + process.exitCode = 1; + } + } catch (error) { + if (error instanceof KernelContextError && !options.json) { + console.error(error.message); + process.exitCode = 1; + return; + } + + if (writeJsonErrorEnvelope('context pr', Boolean(options.json), error)) { + return; + } + + throw error; + } + }); + + context + .command('issue') + .description('Fetch GitHub issue context.') + .option('--number ', 'issue number', (value) => Number.parseInt(value, 10)) + .option('--dry-run', 'fetch context without writing .agent/context cache') + .option('--json', 'print machine-readable JSON') + .action(async (options: { number?: number; dryRun?: boolean; json?: boolean }) => { + try { + const result = await fetchContext({ + kind: 'issue', + number: options.number, + dryRun: Boolean(options.dryRun) + }); + + if (options.json) { + process.stdout.write(formatContextJsonResult(result)); + } else { + process.stdout.write(`${formatContextResult(result)}\n`); + } + + if (result.status === 'error') { + process.exitCode = 1; + } + } catch (error) { + if (error instanceof KernelContextError && !options.json) { + console.error(error.message); + process.exitCode = 1; + return; + } + + if (writeJsonErrorEnvelope('context issue', Boolean(options.json), error)) { + return; + } + + throw error; + } + }); + program .command('compile') .description('Compile Kernel canonical source into ADE-specific adapter files.') diff --git a/src/cli/json-errors.ts b/src/cli/json-errors.ts index 1bf1377..fc02717 100644 --- a/src/cli/json-errors.ts +++ b/src/cli/json-errors.ts @@ -1,4 +1,5 @@ import { KernelConfigError } from '../core/config.js'; +import { KernelContextError } from '../core/context/types.js'; import { KernelEvalRunnerError } from '../core/eval.js'; import { KernelFileExistsError } from '../core/fs.js'; import { formatKernelJsonResult } from '../core/json-output.js'; @@ -30,6 +31,17 @@ export function createCliJsonErrorEnvelope(command: string, error: unknown): Cli }; } + if (error instanceof KernelContextError) { + return { + status: 'error', + error: { + code: error.code, + command, + message: error.message + } + }; + } + if (error instanceof KernelConfigError) { return { status: 'error', diff --git a/src/core/config.ts b/src/core/config.ts index 8bec9cd..7d7c5c0 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -50,6 +50,9 @@ const defaultConfigValues = { }, maps: { include_codeowners: true + }, + context: { + github: {} } } as const; @@ -116,7 +119,17 @@ export const kernelConfigSchema = z .object({ include_codeowners: z.boolean().default(defaultConfigValues.maps.include_codeowners) }) - .default(defaultConfigValues.maps) + .default(defaultConfigValues.maps), + context: z + .object({ + github: z + .object({ + owner: z.string().min(1).optional(), + repo: z.string().min(1).optional() + }) + .default(defaultConfigValues.context.github) + }) + .default(defaultConfigValues.context) }) .strict(); diff --git a/src/core/context/fetch.ts b/src/core/context/fetch.ts new file mode 100644 index 0000000..35b3d23 --- /dev/null +++ b/src/core/context/fetch.ts @@ -0,0 +1,113 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { loadKernelConfig } from '../config.js'; +import { formatKernelJsonResult } from '../json-output.js'; +import { + createGitHubApiClient, + resolveCurrentPullRequestNumber, + resolveGitHubRepo, + type GitHubApiClient +} from './github.js'; +import { fetchIssueContext } from './issue.js'; +import { fetchPrContext } from './pr.js'; +import type { ContextResult, IssueContextData, PrContextData } from './types.js'; +import { KernelContextError } from './types.js'; + +export interface ContextFetchOptions { + kind: 'pr' | 'issue'; + number?: number; + current?: boolean; + rootDir?: string; + dryRun?: boolean; + apiClient?: GitHubApiClient; +} + +export async function fetchContext(options: ContextFetchOptions): Promise> { + const rootDir = options.rootDir ?? process.cwd(); + const config = await loadKernelConfig(rootDir); + const repo = await resolveGitHubRepo({ rootDir, config }); + const apiClient = createGitHubApiClient({ apiClient: options.apiClient }); + + let number = options.number; + if (options.current || number === undefined) { + if (options.kind !== 'pr') { + throw new KernelContextError('Issue context requires --number.', 'invalid_context_request'); + } + + number = await resolveCurrentPullRequestNumber(rootDir, repo, apiClient); + } + + if (number === undefined) { + throw new KernelContextError(`${options.kind} context requires --number or --current.`, 'invalid_context_request'); + } + + const result = + options.kind === 'pr' + ? await fetchPrContext({ repo, number, apiClient }) + : await fetchIssueContext({ repo, number, apiClient }); + + if (result.status === 'ok' && result.data && !options.dryRun) { + result.cachedPath = await writeContextCache(rootDir, options.kind, number, result.data, config.canonical.agent_dir); + } + + return result; +} + +export function formatContextResult(result: ContextResult): string { + if (result.status === 'error') { + return `Context error (${result.error?.code}): ${result.error?.message}`; + } + + const data = result.data; + if (!data) { + return 'Context loaded.'; + } + + if ('changedFiles' in data) { + const lines = [ + `PR #${data.number}: ${data.title}`, + `State: ${data.state}`, + `Labels: ${data.labels.join(', ') || '(none)'}`, + `Checks: ${data.checks.state} (${data.checks.passing}/${data.checks.total} passing)`, + `Changed files: ${data.changedFiles.length}`, + data.url + ]; + if (result.cachedPath) { + lines.push(`Cached: ${result.cachedPath}`); + } + return lines.join('\n'); + } + + const lines = [ + `Issue #${data.number}: ${data.title}`, + `State: ${data.state}`, + `Labels: ${data.labels.join(', ') || '(none)'}`, + `Assignees: ${data.assignees.join(', ') || '(none)'}`, + `Linked references: ${data.linkedReferences.join(', ') || '(none)'}`, + data.url + ]; + if (result.cachedPath) { + lines.push(`Cached: ${result.cachedPath}`); + } + return lines.join('\n'); +} + +export function formatContextJsonResult(result: ContextResult): string { + return formatKernelJsonResult(result); +} + +async function writeContextCache( + rootDir: string, + kind: 'pr' | 'issue', + number: number, + data: PrContextData | IssueContextData, + agentDir: string +): Promise { + const contextDir = join(rootDir, agentDir, 'context'); + await mkdir(contextDir, { recursive: true }); + const relativePath = join(agentDir, 'context', `${kind}-${number}.json`); + const absolutePath = join(rootDir, relativePath); + await writeFile(absolutePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); + return relativePath.replace(/\\/g, '/'); +} diff --git a/src/core/context/github.ts b/src/core/context/github.ts new file mode 100644 index 0000000..2b249aa --- /dev/null +++ b/src/core/context/github.ts @@ -0,0 +1,165 @@ +import { execFile } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +import type { KernelConfig } from '../config.js'; +import { KernelContextError } from './types.js'; +import type { GitHubRepoRef } from './types.js'; + +const execFileAsync = promisify(execFile); + +export type GitHubApiClient = (endpoint: string) => Promise; + +export interface ResolveGitHubRepoOptions { + rootDir?: string; + config?: KernelConfig; +} + +export async function resolveGitHubRepo(options: ResolveGitHubRepoOptions = {}): Promise { + const rootDir = options.rootDir ?? process.cwd(); + const configOwner = options.config?.context?.github?.owner; + const configRepo = options.config?.context?.github?.repo; + + if (configOwner && configRepo) { + return { owner: configOwner, repo: configRepo }; + } + + const remote = await resolveOriginRemote(rootDir); + if (!remote) { + throw new KernelContextError( + 'Could not resolve GitHub owner/repo from kernel.yaml context.github or git remote origin.', + 'missing_github_repo' + ); + } + + return { + owner: configOwner ?? remote.owner, + repo: configRepo ?? remote.repo + }; +} + +export function createGitHubApiClient(options: { apiClient?: GitHubApiClient } = {}): GitHubApiClient { + if (options.apiClient) { + return options.apiClient; + } + + return async (endpoint: string) => { + try { + return await ghApi(endpoint); + } catch (ghError) { + const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + if (!token) { + throw ghError; + } + + return githubFetch(endpoint, token); + } + }; +} + +export async function resolveCurrentPullRequestNumber( + rootDir: string, + repo: GitHubRepoRef, + apiClient: GitHubApiClient +): Promise { + try { + const { stdout } = await execFileAsync('gh', ['pr', 'view', '--json', 'number'], { + cwd: rootDir, + encoding: 'utf8' + }); + const parsed = JSON.parse(stdout) as { number?: number }; + if (typeof parsed.number === 'number') { + return parsed.number; + } + } catch { + // Fall through to branch-based lookup. + } + + const branch = await readCurrentBranch(rootDir); + if (!branch) { + throw new KernelContextError('Could not resolve the current pull request from gh or git branch.', 'current_pr_not_found'); + } + + const response = (await apiClient( + `/repos/${repo.owner}/${repo.repo}/pulls?head=${repo.owner}:${branch}&state=open` + )) as Array<{ number: number }>; + + const pullRequest = response[0]; + if (!pullRequest) { + throw new KernelContextError(`No open pull request found for branch ${branch}.`, 'current_pr_not_found'); + } + + return pullRequest.number; +} + +async function ghApi(endpoint: string): Promise { + const path = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint; + const { stdout } = await execFileAsync('gh', ['api', path], { encoding: 'utf8' }); + return JSON.parse(stdout) as unknown; +} + +async function githubFetch(endpoint: string, token: string): Promise { + const url = endpoint.startsWith('http') ? endpoint : `https://api.github.com${endpoint}`; + const response = await fetch(url, { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28' + } + }); + + if (!response.ok) { + throw new KernelContextError( + `GitHub API request failed (${response.status} ${response.statusText}).`, + 'github_api_error' + ); + } + + return response.json() as Promise; +} + +async function resolveOriginRemote(rootDir: string): Promise { + try { + const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], { + cwd: rootDir, + encoding: 'utf8' + }); + return parseGitHubRemote(stdout.trim()); + } catch { + const config = await readFile(join(rootDir, '.git', 'config'), 'utf8').catch(() => null); + if (!config) { + return null; + } + + const match = config.match(/\[remote "origin"\][\s\S]*?url\s*=\s*(.+)/); + return match ? parseGitHubRemote(match[1].trim()) : null; + } +} + +export function parseGitHubRemote(remoteUrl: string): GitHubRepoRef | null { + const sshMatch = remoteUrl.match(/git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/); + if (sshMatch) { + return { owner: sshMatch[1], repo: sshMatch[2] }; + } + + const httpsMatch = remoteUrl.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/); + if (httpsMatch) { + return { owner: httpsMatch[1], repo: httpsMatch[2] }; + } + + return null; +} + +async function readCurrentBranch(rootDir: string): Promise { + try { + const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: rootDir, + encoding: 'utf8' + }); + const branch = stdout.trim(); + return branch === 'HEAD' ? null : branch; + } catch { + return null; + } +} diff --git a/src/core/context/issue.ts b/src/core/context/issue.ts new file mode 100644 index 0000000..e762252 --- /dev/null +++ b/src/core/context/issue.ts @@ -0,0 +1,89 @@ +import type { GitHubApiClient } from './github.js'; +import type { ContextResult, IssueContextData } from './types.js'; +import { KernelContextError } from './types.js'; +import type { GitHubRepoRef } from './types.js'; + +interface GitHubIssue { + number: number; + title: string; + body: string | null; + state: string; + html_url: string; + labels: Array<{ name: string }>; + assignees: Array<{ login: string }>; +} + +interface GitHubTimelineEvent { + event: string; + source?: { + issue?: { + number: number; + }; + }; +} + +export interface FetchIssueContextOptions { + repo: GitHubRepoRef; + number: number; + apiClient: GitHubApiClient; +} + +export async function fetchIssueContext(options: FetchIssueContextOptions): Promise> { + const { repo, number, apiClient } = options; + const basePath = `/repos/${repo.owner}/${repo.repo}`; + + try { + const issue = (await apiClient(`${basePath}/issues/${number}`)) as GitHubIssue; + const timeline = (await apiClient(`${basePath}/issues/${number}/timeline`)) as GitHubTimelineEvent[]; + + return { + provider: 'github-issue', + status: 'ok', + data: { + number: issue.number, + title: issue.title, + body: issue.body ?? '', + state: issue.state, + labels: issue.labels.map((label) => label.name), + assignees: issue.assignees.map((assignee) => assignee.login), + linkedReferences: extractLinkedReferences(issue.body ?? '', timeline), + url: issue.html_url + } + }; + } catch (error) { + if (error instanceof KernelContextError) { + return errorResult(error); + } + + return errorResult( + new KernelContextError(`Issue #${number} could not be loaded.`, 'issue_not_found', { cause: error }) + ); + } +} + +function extractLinkedReferences(body: string, timeline: GitHubTimelineEvent[]): string[] { + const references = new Set(); + + for (const match of body.matchAll(/#(\d+)/g)) { + references.add(`#${match[1]}`); + } + + for (const event of timeline) { + if (event.event === 'cross-referenced' && event.source?.issue?.number) { + references.add(`#${event.source.issue.number}`); + } + } + + return [...references].sort((left, right) => Number(left.slice(1)) - Number(right.slice(1))); +} + +function errorResult(error: KernelContextError): ContextResult { + return { + provider: 'github-issue', + status: 'error', + error: { + code: error.code, + message: error.message + } + }; +} diff --git a/src/core/context/pr.ts b/src/core/context/pr.ts new file mode 100644 index 0000000..df3c4b8 --- /dev/null +++ b/src/core/context/pr.ts @@ -0,0 +1,126 @@ +import type { GitHubApiClient } from './github.js'; +import type { ContextResult, PrCheckSummary, PrContextData } from './types.js'; +import { KernelContextError } from './types.js'; +import type { GitHubRepoRef } from './types.js'; + +interface GitHubPullRequest { + number: number; + title: string; + body: string | null; + state: string; + html_url: string; + labels: Array<{ name: string }>; + head: { sha: string }; +} + +interface GitHubPullFile { + filename: string; + additions: number; + deletions: number; + status: string; +} + +interface GitHubCheckRun { + status: string; + conclusion: string | null; +} + +export interface FetchPrContextOptions { + repo: GitHubRepoRef; + number: number; + apiClient: GitHubApiClient; +} + +export async function fetchPrContext(options: FetchPrContextOptions): Promise> { + const { repo, number, apiClient } = options; + const basePath = `/repos/${repo.owner}/${repo.repo}`; + + try { + const pullRequest = (await apiClient(`${basePath}/pulls/${number}`)) as GitHubPullRequest; + const files = (await apiClient(`${basePath}/pulls/${number}/files`)) as GitHubPullFile[]; + const checks = await fetchCheckSummary(apiClient, basePath, pullRequest.head.sha); + + return { + provider: 'github-pr', + status: 'ok', + data: { + number: pullRequest.number, + title: pullRequest.title, + body: pullRequest.body ?? '', + state: pullRequest.state, + labels: pullRequest.labels.map((label) => label.name), + changedFiles: files.map((file) => ({ + path: file.filename, + additions: file.additions, + deletions: file.deletions, + status: file.status + })), + checks, + url: pullRequest.html_url + } + }; + } catch (error) { + if (error instanceof KernelContextError) { + return errorResult(error); + } + + return errorResult( + new KernelContextError(`Pull request #${number} could not be loaded.`, 'pr_not_found', { cause: error }) + ); + } +} + +async function fetchCheckSummary( + apiClient: GitHubApiClient, + basePath: string, + headSha: string +): Promise { + try { + const checkRuns = (await apiClient(`${basePath}/commits/${headSha}/check-runs`)) as { + check_runs: GitHubCheckRun[]; + }; + + const runs = checkRuns.check_runs ?? []; + const passing = runs.filter((run) => run.conclusion === 'success').length; + const failing = runs.filter((run) => run.conclusion === 'failure' || run.conclusion === 'cancelled').length; + const pending = runs.filter((run) => run.status !== 'completed').length; + + let state: PrCheckSummary['state'] = 'unknown'; + if (runs.length === 0) { + state = 'unknown'; + } else if (failing > 0) { + state = 'failure'; + } else if (pending > 0) { + state = 'pending'; + } else if (passing === runs.length) { + state = 'success'; + } + + return { + state, + total: runs.length, + passing, + failing, + pending + }; + } catch { + return { + state: 'unknown', + total: 0, + passing: 0, + failing: 0, + pending: 0 + }; + } +} + +function errorResult(error: KernelContextError): ContextResult { + return { + provider: 'github-pr', + status: 'error', + error: { + code: error.code, + message: error.message + } + }; +} diff --git a/src/core/context/types.ts b/src/core/context/types.ts new file mode 100644 index 0000000..fe92c4b --- /dev/null +++ b/src/core/context/types.ts @@ -0,0 +1,80 @@ +export const CONTEXT_PROVIDER_IDS = ['github-pr', 'github-issue'] as const; + +export type ContextProviderId = (typeof CONTEXT_PROVIDER_IDS)[number]; + +export const CONTEXT_ERROR_CODES = [ + 'missing_github_repo', + 'github_api_error', + 'pr_not_found', + 'issue_not_found', + 'current_pr_not_found', + 'invalid_context_request' +] as const; + +export type ContextErrorCode = (typeof CONTEXT_ERROR_CODES)[number]; + +export interface ContextError { + code: ContextErrorCode; + message: string; +} + +export interface ContextResult { + provider: ContextProviderId; + status: 'ok' | 'error'; + data?: T; + error?: ContextError; + cachedPath?: string; +} + +export interface GitHubRepoRef { + owner: string; + repo: string; +} + +export interface PrChangedFile { + path: string; + additions: number; + deletions: number; + status: string; +} + +export interface PrCheckSummary { + state: 'success' | 'failure' | 'pending' | 'unknown'; + total: number; + passing: number; + failing: number; + pending: number; +} + +export interface PrContextData { + number: number; + title: string; + body: string; + state: string; + labels: string[]; + changedFiles: PrChangedFile[]; + checks: PrCheckSummary; + url: string; +} + +export interface IssueContextData { + number: number; + title: string; + body: string; + state: string; + labels: string[]; + assignees: string[]; + linkedReferences: string[]; + url: string; +} + +export class KernelContextError extends Error { + constructor( + message: string, + readonly code: ContextErrorCode, + options?: ErrorOptions + ) { + super(message, options); + this.name = 'KernelContextError'; + } +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts index a57d4c9..dfdae32 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -743,4 +743,19 @@ describe('Kernel CLI JSON error envelopes', () => { expect(parsed.status).toBe('warn'); expect(parsed.violations.some((violation) => violation.code === 'policy_path_review')).toBe(true); }); + + test('supports kernel context pr --help', () => { + const output = helpFor(['context', 'pr', '--help']); + + expect(output).toContain('Usage: kernel context pr'); + expect(output).toContain('--number'); + expect(output).toContain('--current'); + }); + + test('supports kernel context issue --help', () => { + const output = helpFor(['context', 'issue', '--help']); + + expect(output).toContain('Usage: kernel context issue'); + expect(output).toContain('--number'); + }); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index 79c27e6..40b6983 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -66,6 +66,9 @@ describe('loadKernelConfig', () => { }, maps: { include_codeowners: true + }, + context: { + github: {} } }); }); @@ -151,6 +154,21 @@ describe('loadKernelConfig', () => { await expect(loadKernelConfig(rootDir)).rejects.toBeInstanceOf(KernelConfigError); }); + test('loads optional context.github config', async () => { + const rootDir = await createTempRepo(); + await mkdir(join(rootDir, '.agent'), { recursive: true }); + await writeFile( + join(rootDir, '.agent', 'kernel.yaml'), + ['version: 1', 'context:', ' github:', ' owner: mattbaconz', ' repo: kernel', ''].join('\n'), + 'utf8' + ); + + const config = await loadKernelConfig(rootDir); + + expect(config.context.github.owner).toBe('mattbaconz'); + expect(config.context.github.repo).toBe('kernel'); + }); + test('rejects invalid config', async () => { const rootDir = await createTempRepo(); await mkdir(join(rootDir, '.agent'), { recursive: true }); diff --git a/tests/context-github.test.ts b/tests/context-github.test.ts new file mode 100644 index 0000000..c8260f3 --- /dev/null +++ b/tests/context-github.test.ts @@ -0,0 +1,69 @@ +import { readFile } from 'node:fs/promises'; +import { cp, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, test } from 'vitest'; + +import { loadKernelConfig } from '../src/core/config.js'; +import { parseGitHubRemote, resolveGitHubRepo } from '../src/core/context/github.js'; +import { KernelContextError } from '../src/core/context/types.js'; + +const tempDirs: string[] = []; + +async function copyFixture(name: string): Promise { + const dir = await mkdtemp(join(tmpdir(), `kernel-context-${name}-`)); + tempDirs.push(dir); + await cp(join(process.cwd(), 'tests', 'fixtures', name), dir, { recursive: true }); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('parseGitHubRemote', () => { + test('parses SSH remotes', () => { + expect(parseGitHubRemote('git@github.com:mattbaconz/kernel.git')).toEqual({ + owner: 'mattbaconz', + repo: 'kernel' + }); + }); + + test('parses HTTPS remotes', () => { + expect(parseGitHubRemote('https://github.com/mattbaconz/kernel-skills.git')).toEqual({ + owner: 'mattbaconz', + repo: 'kernel-skills' + }); + }); +}); + +describe('resolveGitHubRepo', () => { + test('uses context.github config when present', async () => { + const rootDir = await copyFixture('context-pr'); + const config = await loadKernelConfig(rootDir); + + await expect(resolveGitHubRepo({ rootDir, config })).resolves.toEqual({ + owner: 'mattbaconz', + repo: 'kernel' + }); + }); + + test('throws when owner/repo cannot be resolved', async () => { + const rootDir = await mkdtemp(join(tmpdir(), 'kernel-context-missing-')); + tempDirs.push(rootDir); + + await expect(resolveGitHubRepo({ rootDir })).rejects.toBeInstanceOf(KernelContextError); + }); +}); + +describe('fixture payloads', () => { + test('loads PR fixture JSON', async () => { + const pull = JSON.parse(await readFile(join(process.cwd(), 'tests', 'fixtures', 'context-pr', 'pull.json'), 'utf8')) as { + number: number; + title: string; + }; + + expect(pull.number).toBe(42); + expect(pull.title).toContain('GitHub context'); + }); +}); diff --git a/tests/context.test.ts b/tests/context.test.ts new file mode 100644 index 0000000..b799de2 --- /dev/null +++ b/tests/context.test.ts @@ -0,0 +1,125 @@ +import { readFile } from 'node:fs/promises'; +import { cp, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, test } from 'vitest'; + +import { fetchContext } from '../src/core/context/fetch.js'; +import type { GitHubApiClient } from '../src/core/context/github.js'; +import { fetchIssueContext } from '../src/core/context/issue.js'; +import { fetchPrContext } from '../src/core/context/pr.js'; + +const tempDirs: string[] = []; + +async function copyFixture(name: string): Promise { + const dir = await mkdtemp(join(tmpdir(), `kernel-context-${name}-`)); + tempDirs.push(dir); + await cp(join(process.cwd(), 'tests', 'fixtures', name), dir, { recursive: true }); + return dir; +} + +async function readFixtureJson(fixture: string, fileName: string): Promise { + const raw = await readFile(join(process.cwd(), 'tests', 'fixtures', fixture, fileName), 'utf8'); + return JSON.parse(raw) as T; +} + +function createFixtureApiClient(fixture: 'context-pr' | 'context-issue'): GitHubApiClient { + return async (endpoint: string) => { + if (fixture === 'context-pr') { + if (endpoint.endsWith('/pulls/42')) { + return readFixtureJson('context-pr', 'pull.json'); + } + if (endpoint.endsWith('/pulls/42/files')) { + return readFixtureJson('context-pr', 'files.json'); + } + if (endpoint.endsWith('/check-runs')) { + return readFixtureJson('context-pr', 'checks.json'); + } + } + + if (fixture === 'context-issue') { + if (endpoint.endsWith('/issues/15')) { + return readFixtureJson('context-issue', 'issue.json'); + } + if (endpoint.endsWith('/issues/15/timeline')) { + return readFixtureJson('context-issue', 'timeline.json'); + } + } + + throw new Error(`Unexpected endpoint: ${endpoint}`); + }; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('fetchPrContext', () => { + test('returns structured PR context from mocked GitHub API', async () => { + const result = await fetchPrContext({ + repo: { owner: 'mattbaconz', repo: 'kernel' }, + number: 42, + apiClient: createFixtureApiClient('context-pr') + }); + + expect(result.status).toBe('ok'); + expect(result.data?.number).toBe(42); + expect(result.data?.labels).toEqual(['enhancement']); + expect(result.data?.changedFiles).toHaveLength(2); + expect(result.data?.checks).toEqual({ + state: 'failure', + total: 3, + passing: 2, + failing: 1, + pending: 0 + }); + }); +}); + +describe('fetchIssueContext', () => { + test('returns structured issue context with linked references', async () => { + const result = await fetchIssueContext({ + repo: { owner: 'mattbaconz', repo: 'kernel' }, + number: 15, + apiClient: createFixtureApiClient('context-issue') + }); + + expect(result.status).toBe('ok'); + expect(result.data?.assignees).toEqual(['mattbaconz']); + expect(result.data?.linkedReferences).toEqual(['#12', '#18']); + }); +}); + +describe('fetchContext', () => { + test('writes PR cache under .agent/context when not dry-run', async () => { + const rootDir = await copyFixture('context-pr'); + + const result = await fetchContext({ + kind: 'pr', + number: 42, + rootDir, + apiClient: createFixtureApiClient('context-pr') + }); + + expect(result.cachedPath).toBe('.agent/context/pr-42.json'); + const cached = JSON.parse(await readFile(join(rootDir, '.agent', 'context', 'pr-42.json'), 'utf8')) as { + title: string; + }; + expect(cached.title).toContain('GitHub context'); + }); + + test('skips cache write on dry-run', async () => { + const rootDir = await copyFixture('context-issue'); + + const result = await fetchContext({ + kind: 'issue', + number: 15, + rootDir, + dryRun: true, + apiClient: createFixtureApiClient('context-issue') + }); + + expect(result.cachedPath).toBeUndefined(); + await expect(readFile(join(rootDir, '.agent', 'context', 'issue-15.json'), 'utf8')).rejects.toThrow(); + }); +}); diff --git a/tests/fixtures/context-issue/.agent/kernel.yaml b/tests/fixtures/context-issue/.agent/kernel.yaml new file mode 100644 index 0000000..be9bb6d --- /dev/null +++ b/tests/fixtures/context-issue/.agent/kernel.yaml @@ -0,0 +1,5 @@ +version: 1 +context: + github: + owner: mattbaconz + repo: kernel diff --git a/tests/fixtures/context-issue/issue.json b/tests/fixtures/context-issue/issue.json new file mode 100644 index 0000000..faf995f --- /dev/null +++ b/tests/fixtures/context-issue/issue.json @@ -0,0 +1,9 @@ +{ + "number": 15, + "title": "Track release checklist", + "body": "Follow up on #12 and coordinate with #18.", + "state": "open", + "html_url": "https://github.com/mattbaconz/kernel/issues/15", + "labels": [{ "name": "release" }], + "assignees": [{ "login": "mattbaconz" }] +} diff --git a/tests/fixtures/context-issue/timeline.json b/tests/fixtures/context-issue/timeline.json new file mode 100644 index 0000000..ac080d3 --- /dev/null +++ b/tests/fixtures/context-issue/timeline.json @@ -0,0 +1,10 @@ +[ + { + "event": "cross-referenced", + "source": { + "issue": { + "number": 12 + } + } + } +] diff --git a/tests/fixtures/context-pr/.agent/kernel.yaml b/tests/fixtures/context-pr/.agent/kernel.yaml new file mode 100644 index 0000000..be9bb6d --- /dev/null +++ b/tests/fixtures/context-pr/.agent/kernel.yaml @@ -0,0 +1,5 @@ +version: 1 +context: + github: + owner: mattbaconz + repo: kernel diff --git a/tests/fixtures/context-pr/checks.json b/tests/fixtures/context-pr/checks.json new file mode 100644 index 0000000..2a93385 --- /dev/null +++ b/tests/fixtures/context-pr/checks.json @@ -0,0 +1,7 @@ +{ + "check_runs": [ + { "status": "completed", "conclusion": "success" }, + { "status": "completed", "conclusion": "success" }, + { "status": "completed", "conclusion": "failure" } + ] +} diff --git a/tests/fixtures/context-pr/files.json b/tests/fixtures/context-pr/files.json new file mode 100644 index 0000000..307002e --- /dev/null +++ b/tests/fixtures/context-pr/files.json @@ -0,0 +1,14 @@ +[ + { + "filename": "src/core/context/pr.ts", + "additions": 120, + "deletions": 0, + "status": "added" + }, + { + "filename": "src/cli/index.ts", + "additions": 45, + "deletions": 2, + "status": "modified" + } +] diff --git a/tests/fixtures/context-pr/pull.json b/tests/fixtures/context-pr/pull.json new file mode 100644 index 0000000..78e6905 --- /dev/null +++ b/tests/fixtures/context-pr/pull.json @@ -0,0 +1,9 @@ +{ + "number": 42, + "title": "Add GitHub context providers", + "body": "Implements PR and issue context for agents.", + "state": "open", + "html_url": "https://github.com/mattbaconz/kernel/pull/42", + "labels": [{ "name": "enhancement" }], + "head": { "sha": "abc123def456" } +}