diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 53c20c4..3bf54e2 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -484,12 +484,13 @@ Seeds for hotspot analysis are chosen by resolved preset (from `.charter/config. Generates a live session snapshot and writes it to `.ai/context.adf` plus `.ai/context.snapshot.json`. -Phase 2 supports `git` and `github` sources with fail-closed behavior for missing GitHub credentials. +Supports `git`, `github`, and `repo-intel` sources with fail-closed behavior for missing GitHub credentials and graceful skip when the `gh` CLI is unavailable. ```bash npx charter context-refresh npx charter context-refresh --sources git npx charter context-refresh --sources git,github +npx charter context-refresh --sources repo-intel npx charter context-refresh --output CONTEXT.md npx charter context-refresh --ai-dir .ai npx charter context-refresh --once --ttl-minutes 30 @@ -498,7 +499,7 @@ npx charter context-refresh --format json #### Flags -- `--sources ` — context sources to include. Supported: `git`, `github`. +- `--sources ` — context sources to include. Supported: `git`, `github`, `repo-intel`. - `--output ` — optionally mirror a markdown snapshot to a file (for session briefs/docs). - `--ai-dir ` — target ADF directory (default: `.ai`), output file is `/context.adf`. - `--once` — skip refresh when an existing snapshot is newer than TTL. @@ -528,6 +529,16 @@ Optional config path: `.charter/context-sources.json` If `github` is enabled but `GITHUB_TOKEN` is missing, refresh continues without hard failure and records `sources.github.available = false` plus warnings. +If `repo-intel` is enabled but the `gh` CLI is not installed or has no GitHub remote, refresh continues without hard failure and records a warning. When available, `repo-intel` writes a full payload to `.charter/repo-intel/snapshot.json`. + +#### Sources reference + +| Source | Description | +|--------|-------------| +| `git` | Local git branch, working tree, and recent commit log. | +| `github` | Open issues from the GitHub API (requires `GITHUB_TOKEN`). | +| `repo-intel` | GitHub history via the `gh` CLI — open/closed issues, PRs, releases, and a computed summary (`openIssueCount`, `mergeVelocity`, `stalledIssues`, `recurringLabels`, `releaseCadence`). Writes `.charter/repo-intel/snapshot.json`. Skips gracefully when `gh` is unavailable. | + For active implementation status and next-session handoff details, see [Context Refresh Resume Guide](/context-refresh-resume). ### charter surface diff --git a/packages/cli/src/__tests__/context-refresh-repo-intel.test.ts b/packages/cli/src/__tests__/context-refresh-repo-intel.test.ts new file mode 100644 index 0000000..d5e8c5d --- /dev/null +++ b/packages/cli/src/__tests__/context-refresh-repo-intel.test.ts @@ -0,0 +1,256 @@ +/** + * Tests for the repo-intel source in context-refresh. + * + * Uses vi.mock at the top level (required for ESM) to intercept execFileSync. + * A module-level `ghResponder` variable is mutated per-test so the hoisted + * mock factory can dispatch different responses without re-mocking. + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CLIOptions } from '../index'; +import { contextRefreshCommand } from '../commands/context-refresh'; + +// --------------------------------------------------------------------------- +// Module-level gh responder — set this before each test, read by the mock +// --------------------------------------------------------------------------- +type GhResponder = ((args: string[]) => string) | null; +let ghResponder: GhResponder = null; + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFileSync: vi.fn( + (cmd: string, args: unknown, opts: unknown): string => { + if (cmd === 'gh') { + if (!ghResponder) throw new Error('ENOENT: gh not found'); + return ghResponder(args as string[]); + } + // Pass through to real execFileSync for git and everything else + return actual.execFileSync( + cmd, + args as string[], + opts as Parameters[2], + ) as string; + }, + ), + }; +}); + +// --------------------------------------------------------------------------- +// Test scaffolding +// --------------------------------------------------------------------------- +const options: CLIOptions = { + configPath: '.charter', + format: 'text', + ciMode: false, + yes: false, +}; + +const originalCwd = process.cwd(); +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'charter-repo-intel-test-')); + tempDirs.push(dir); + return dir; +} + +beforeEach(() => { + ghResponder = null; +}); + +afterEach(() => { + process.chdir(originalCwd); + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); + ghResponder = null; +}); + +// --------------------------------------------------------------------------- +// Fake data helpers +// --------------------------------------------------------------------------- +const fakeOpenIssues = [ + { + number: 1, + title: 'Fix bug in auth flow', + labels: [{ name: 'bug' }], + assignees: [], + createdAt: '2026-04-01T00:00:00Z', + updatedAt: '2026-04-10T00:00:00Z', + comments: 2, + }, + { + number: 2, + title: 'Improve onboarding docs', + labels: [{ name: 'docs' }], + assignees: [], + createdAt: '2026-03-01T00:00:00Z', + // Old updatedAt — should count as stalled (>30 days ago from 2026-05-23) + updatedAt: '2026-03-01T00:00:00Z', + comments: 0, + }, +]; + +const fakeClosedIssues = [ + { number: 3, title: 'Old bug 1', labels: [{ name: 'bug' }], closedAt: '2026-02-01T00:00:00Z' }, + { number: 4, title: 'Old bug 2', labels: [{ name: 'bug' }], closedAt: '2026-02-10T00:00:00Z' }, + { number: 5, title: 'Old bug 3', labels: [{ name: 'bug' }], closedAt: '2026-02-15T00:00:00Z' }, +]; + +const fakePRs = [ + { + number: 10, + title: 'feat: new feature', + state: 'MERGED', + author: { login: 'alice' }, + // Merged 5 days ago — should count toward mergeVelocity + mergedAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), + createdAt: '2026-05-10T00:00:00Z', + reviewDecision: 'APPROVED', + labels: [], + }, +]; + +const fakeReleases = [ + // 9 days apart → releaseCadence should be 9 + { tagName: 'v1.0.0', publishedAt: '2026-05-01T00:00:00Z', isLatest: false }, + { tagName: 'v1.1.0', publishedAt: '2026-05-10T00:00:00Z', isLatest: true }, +]; + +function makeFullGhResponder(): GhResponder { + return (args: string[]) => { + if (args[0] === '--version') return 'gh version 2.0.0 (2026-01-01)'; + if (args[0] === 'issue') { + // args: ['issue', 'list', '--limit', '50', '--state', 'open', '--json', '...'] + const stateIdx = args.indexOf('--state'); + const state = stateIdx >= 0 ? args[stateIdx + 1] : undefined; + if (state === 'open') return JSON.stringify(fakeOpenIssues); + if (state === 'closed') return JSON.stringify(fakeClosedIssues); + return '[]'; + } + if (args[0] === 'pr') return JSON.stringify(fakePRs); + if (args[0] === 'release') return JSON.stringify(fakeReleases); + return '[]'; + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe('context-refresh repo-intel source', () => { + it('writes .charter/repo-intel/snapshot.json and summary contains openIssueCount', async () => { + const tmp = makeTempDir(); + process.chdir(tmp); + + ghResponder = makeFullGhResponder(); + + vi.spyOn(console, 'log').mockImplementation(() => {}); + + const exitCode = await contextRefreshCommand( + { ...options, format: 'json' }, + ['--sources', 'repo-intel'], + ); + expect(exitCode).toBe(0); + + // Snapshot file must be written + const snapshotPath = path.join(tmp, '.charter', 'repo-intel', 'snapshot.json'); + expect(fs.existsSync(snapshotPath)).toBe(true); + + const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf8')) as { + available: boolean; + summary: { + openIssueCount: number; + stalledIssues: number; + recurringLabels: string[]; + mergeVelocity: number; + releaseCadence: number | null; + }; + openIssues: unknown[]; + closedIssues: unknown[]; + pullRequests: unknown[]; + releases: unknown[]; + }; + + expect(snapshot.available).toBe(true); + expect(snapshot.summary.openIssueCount).toBe(2); + // Issue 2 was last updated 2026-03-01, which is >30 days before 2026-05-23 + expect(snapshot.summary.stalledIssues).toBeGreaterThanOrEqual(1); + // "bug" label appears 3 times in closed issues + expect(snapshot.summary.recurringLabels).toContain('bug'); + // PR merged 5 days ago is within 30-day window + expect(snapshot.summary.mergeVelocity).toBeGreaterThanOrEqual(1); + // Two releases 9 days apart → cadence of 9 + expect(snapshot.summary.releaseCadence).toBe(9); + // Raw arrays are present + expect(Array.isArray(snapshot.openIssues)).toBe(true); + expect(snapshot.openIssues).toHaveLength(2); + expect(Array.isArray(snapshot.closedIssues)).toBe(true); + expect(snapshot.closedIssues).toHaveLength(3); + expect(Array.isArray(snapshot.pullRequests)).toBe(true); + expect(Array.isArray(snapshot.releases)).toBe(true); + }); + + it('source appears in sourcesUsed and produces repo-intel entries in context.adf', async () => { + const tmp = makeTempDir(); + process.chdir(tmp); + + ghResponder = makeFullGhResponder(); + + const logs: string[] = []; + vi.spyOn(console, 'log').mockImplementation((v?: unknown) => { logs.push(String(v ?? '')); }); + + const exitCode = await contextRefreshCommand( + { ...options, format: 'json' }, + ['--sources', 'repo-intel'], + ); + expect(exitCode).toBe(0); + + const payload = JSON.parse(logs[0]) as { sourcesUsed: string[]; warnings: string[] }; + expect(payload.sourcesUsed).toContain('repo-intel'); + expect(payload.warnings).toHaveLength(0); + + const adf = fs.readFileSync(path.join(tmp, '.ai', 'context.adf'), 'utf8'); + expect(adf).toContain('repo-intel'); + }); + + it('skips gracefully when gh CLI is not available — warning but no hard error', async () => { + const tmp = makeTempDir(); + process.chdir(tmp); + + // Leave ghResponder = null → mock throws ENOENT for any gh call + ghResponder = null; + + const logs: string[] = []; + vi.spyOn(console, 'log').mockImplementation((v?: unknown) => { logs.push(String(v ?? '')); }); + + const exitCode = await contextRefreshCommand( + { ...options, format: 'json' }, + ['--sources', 'repo-intel'], + ); + expect(exitCode).toBe(0); + + const payload = JSON.parse(logs[0]) as { + status: string; + sourcesUsed: string[]; + warnings: string[]; + errors: string[]; + }; + + // Graceful degradation: ok status, a warning, no errors + expect(payload.status).toBe('ok'); + expect(payload.sourcesUsed).not.toContain('repo-intel'); + expect(payload.warnings.some((w) => w.includes('repo-intel'))).toBe(true); + expect(payload.errors).toHaveLength(0); + + // Snapshot file must NOT be written when gh is unavailable + const snapshotPath = path.join(tmp, '.charter', 'repo-intel', 'snapshot.json'); + expect(fs.existsSync(snapshotPath)).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/context-refresh.ts b/packages/cli/src/commands/context-refresh.ts index 67b1bed..e9ddc00 100644 --- a/packages/cli/src/commands/context-refresh.ts +++ b/packages/cli/src/commands/context-refresh.ts @@ -15,7 +15,7 @@ import type { CLIOptions } from '../index'; import { CLIError, EXIT_CODE } from '../index'; import { getFlag } from '../flags'; -type ContextSource = 'git' | 'github'; +type ContextSource = 'git' | 'github' | 'repo-intel'; interface GitCommit { hash: string; @@ -50,6 +50,60 @@ interface GitHubSnapshot { error?: string; } +// repo-intel types +interface RepoIntelIssue { + number: number; + title: string; + labels: Array<{ name: string }>; + assignees: Array<{ login: string }>; + createdAt: string; + updatedAt: string; + comments: number; +} + +interface RepoIntelClosedIssue { + number: number; + title: string; + labels: Array<{ name: string }>; + closedAt: string; +} + +interface RepoIntelPR { + number: number; + title: string; + state: string; + author: { login: string }; + mergedAt: string | null; + createdAt: string; + reviewDecision: string | null; + labels: Array<{ name: string }>; +} + +interface RepoIntelRelease { + tagName: string; + publishedAt: string; + isLatest: boolean; +} + +interface RepoIntelSummary { + openIssueCount: number; + stalledIssues: number; + recurringLabels: string[]; + mergeVelocity: number; + releaseCadence: number | null; +} + +interface RepoIntelSnapshot { + available: boolean; + generatedAt: string; + openIssues: RepoIntelIssue[]; + closedIssues: RepoIntelClosedIssue[]; + pullRequests: RepoIntelPR[]; + releases: RepoIntelRelease[]; + summary: RepoIntelSummary; + error?: string; +} + interface DerivedItem { source: ContextSource; type: string; @@ -70,6 +124,7 @@ interface ContextSnapshot { sources: { git: GitSnapshot; github: GitHubSnapshot; + 'repo-intel': RepoIntelSnapshot; }; openWork: DerivedItem[]; recentActivity: DerivedItem[]; @@ -100,6 +155,9 @@ interface ContextConfig { includePullRequests: boolean; includeChecks: boolean; }; + 'repo-intel': { + enabled: boolean; + }; }; } @@ -117,7 +175,7 @@ interface ContextRefreshIO { log?: (message: string) => void; } -const SOURCE_SET = new Set(['git', 'github']); +const SOURCE_SET = new Set(['git', 'github', 'repo-intel']); const DEFAULT_CONFIG: ContextConfig = { version: 1, defaults: { @@ -140,6 +198,9 @@ const DEFAULT_CONFIG: ContextConfig = { includePullRequests: true, includeChecks: true, }, + 'repo-intel': { + enabled: true, + }, }, }; @@ -335,6 +396,11 @@ function loadContextConfig(configPath: string): ContextConfig { cfg.sources.github.includeChecks = github.includeChecks; } } + const repoIntelCfg = sources['repo-intel']; + if (repoIntelCfg && typeof repoIntelCfg === 'object') { + const ri = repoIntelCfg as Record; + if (typeof ri.enabled === 'boolean') cfg.sources['repo-intel'].enabled = ri.enabled; + } } return cfg; @@ -348,7 +414,7 @@ function parseRequestedSources(sourcesFlag: string | undefined, fallback: Contex .filter((entry) => entry.length > 0); const invalid = requested.filter((entry) => !SOURCE_SET.has(entry as ContextSource)); if (invalid.length > 0) { - throw new CLIError(`Unsupported --sources value(s): ${invalid.join(', ')}. Supported: git, github.`); + throw new CLIError(`Unsupported --sources value(s): ${invalid.join(', ')}. Supported: git, github, repo-intel.`); } return [...new Set(requested as ContextSource[])]; } @@ -410,7 +476,21 @@ async function buildSnapshot(cwd: string, resolved: RefreshOptionsResolved): Pro } } - const derived = deriveAggregates(git, github); + const repoIntelEnabled = resolved.sourcesRequested.includes('repo-intel') && resolved.config.sources['repo-intel'].enabled; + const repoIntel = repoIntelEnabled + ? collectRepoIntelSnapshot(cwd, generatedAt) + : { available: false, generatedAt, openIssues: [], closedIssues: [], pullRequests: [], releases: [], summary: { openIssueCount: 0, stalledIssues: 0, recurringLabels: [], mergeVelocity: 0, releaseCadence: null }, error: 'disabled' }; + if (repoIntel.available) { + sourcesUsed.push('repo-intel'); + // Persist full snapshot to .charter/repo-intel/snapshot.json + const repoIntelSnapshotPath = path.resolve(cwd, '.charter', 'repo-intel', 'snapshot.json'); + fs.mkdirSync(path.dirname(repoIntelSnapshotPath), { recursive: true }); + fs.writeFileSync(repoIntelSnapshotPath, JSON.stringify(repoIntel, null, 2), 'utf8'); + } else if (resolved.sourcesRequested.includes('repo-intel') && repoIntel.error && repoIntel.error !== 'disabled') { + warnings.push(`repo-intel source unavailable: ${repoIntel.error}`); + } + + const derived = deriveAggregates(git, github, repoIntel); return { version: 1, @@ -425,6 +505,7 @@ async function buildSnapshot(cwd: string, resolved: RefreshOptionsResolved): Pro sources: { git, github, + 'repo-intel': repoIntel, }, openWork: derived.openWork, recentActivity: derived.recentActivity, @@ -598,9 +679,154 @@ async function collectGitHubSnapshot(config: ContextConfig, issueLimit: number): }; } +function runGhCommand(args: string[], cwd?: string): string | null { + try { + const output = execFileSync('gh', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return output.trim(); + } catch { + return null; + } +} + +function collectRepoIntelSnapshot(cwd: string, generatedAt: string): RepoIntelSnapshot { + + const empty: RepoIntelSnapshot = { + available: false, + generatedAt, + openIssues: [], + closedIssues: [], + pullRequests: [], + releases: [], + summary: { openIssueCount: 0, stalledIssues: 0, recurringLabels: [], mergeVelocity: 0, releaseCadence: null }, + }; + + // Check if gh CLI is available + const ghVersion = runGhCommand(['--version'], cwd); + if (!ghVersion) { + return { ...empty, error: 'gh CLI not available' }; + } + + // Open issues (last 50, sorted by updated) + const openIssuesRaw = runGhCommand([ + 'issue', 'list', '--limit', '50', '--state', 'open', + '--json', 'number,title,labels,assignees,createdAt,updatedAt,comments', + ], cwd); + if (!openIssuesRaw) { + return { ...empty, error: 'no GitHub remote or gh auth required' }; + } + + let openIssues: RepoIntelIssue[]; + try { + openIssues = JSON.parse(openIssuesRaw) as RepoIntelIssue[]; + } catch { + return { ...empty, error: 'invalid_json: open issues response' }; + } + + // Recent closed issues (last 20) + const closedIssuesRaw = runGhCommand([ + 'issue', 'list', '--limit', '20', '--state', 'closed', + '--json', 'number,title,labels,closedAt', + ], cwd); + let closedIssues: RepoIntelClosedIssue[] = []; + if (closedIssuesRaw) { + try { + closedIssues = JSON.parse(closedIssuesRaw) as RepoIntelClosedIssue[]; + } catch { /* ignore parse failures for supplemental data */ } + } + + // Recent PRs (last 30, all states) + const prsRaw = runGhCommand([ + 'pr', 'list', '--limit', '30', '--state', 'all', + '--json', 'number,title,state,author,mergedAt,createdAt,reviewDecision,labels', + ], cwd); + let pullRequests: RepoIntelPR[] = []; + if (prsRaw) { + try { + pullRequests = JSON.parse(prsRaw) as RepoIntelPR[]; + } catch { /* ignore */ } + } + + // Release cadence (last 10 releases) + const releasesRaw = runGhCommand([ + 'release', 'list', '--limit', '10', + '--json', 'tagName,publishedAt,isLatest', + ], cwd); + let releases: RepoIntelRelease[] = []; + if (releasesRaw) { + try { + releases = JSON.parse(releasesRaw) as RepoIntelRelease[]; + } catch { /* ignore */ } + } + + // Compute summary + const now = Date.now(); + const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; + + const stalledIssues = openIssues.filter((issue) => { + const updatedMs = Date.parse(issue.updatedAt); + return Number.isFinite(updatedMs) && (now - updatedMs) > thirtyDaysMs; + }).length; + + // Count label occurrences in closed issues + const labelCounts = new Map(); + for (const issue of closedIssues) { + for (const label of issue.labels) { + const name = label.name; + labelCounts.set(name, (labelCounts.get(name) ?? 0) + 1); + } + } + const recurringLabels = [...labelCounts.entries()] + .filter(([, count]) => count >= 3) + .sort((a, b) => b[1] - a[1]) + .map(([name]) => name); + + const mergeVelocity = pullRequests.filter((pr) => { + if (!pr.mergedAt) return false; + const mergedMs = Date.parse(pr.mergedAt); + return Number.isFinite(mergedMs) && (now - mergedMs) <= thirtyDaysMs; + }).length; + + let releaseCadence: number | null = null; + const lastFiveReleases = releases + .slice(0, 5) + .map((r) => Date.parse(r.publishedAt)) + .filter((ms) => Number.isFinite(ms)) + .sort((a, b) => b - a); + if (lastFiveReleases.length >= 2) { + const gaps: number[] = []; + for (let i = 0; i < lastFiveReleases.length - 1; i++) { + gaps.push((lastFiveReleases[i]! - lastFiveReleases[i + 1]!) / (24 * 60 * 60 * 1000)); + } + releaseCadence = Math.round(gaps.reduce((a, b) => a + b, 0) / gaps.length); + } + + const summary: RepoIntelSummary = { + openIssueCount: openIssues.length, + stalledIssues, + recurringLabels, + mergeVelocity, + releaseCadence, + }; + + return { + available: true, + generatedAt, + openIssues, + closedIssues, + pullRequests, + releases, + summary, + }; +} + function deriveAggregates( git: GitSnapshot, github: GitHubSnapshot, + repoIntel: RepoIntelSnapshot, ): { openWork: DerivedItem[]; recentActivity: DerivedItem[]; @@ -663,6 +889,36 @@ function deriveAggregates( } } + if (repoIntel.available) { + const s = repoIntel.summary; + recentActivity.push({ + source: 'repo-intel', + type: 'summary', + summary: `repo-intel: ${s.openIssueCount} open issues, ${s.mergeVelocity} PRs merged in last 30d, ${s.stalledIssues} stalled`, + }); + if (s.stalledIssues > 0) { + openWork.push({ + source: 'repo-intel', + type: 'stalled-issues', + summary: `${s.stalledIssues} open issue(s) with no activity in 30+ days`, + }); + } + if (s.recurringLabels.length > 0) { + pendingDecisions.push({ + source: 'repo-intel', + type: 'recurring-labels', + summary: `Recurring closed-issue labels (≥3 times): ${s.recurringLabels.slice(0, 5).join(', ')}`, + }); + } + if (s.releaseCadence !== null) { + recentActivity.push({ + source: 'repo-intel', + type: 'release-cadence', + summary: `Avg release cadence: ~${s.releaseCadence} day(s) between last 5 releases`, + }); + } + } + return { openWork, recentActivity, pendingDecisions }; }