Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -498,7 +499,7 @@ npx charter context-refresh --format json

#### Flags

- `--sources <csv>` — context sources to include. Supported: `git`, `github`.
- `--sources <csv>` — context sources to include. Supported: `git`, `github`, `repo-intel`.
- `--output <path>` — optionally mirror a markdown snapshot to a file (for session briefs/docs).
- `--ai-dir <dir>` — target ADF directory (default: `.ai`), output file is `<dir>/context.adf`.
- `--once` — skip refresh when an existing snapshot is newer than TTL.
Expand Down Expand Up @@ -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
Expand Down
256 changes: 256 additions & 0 deletions packages/cli/src/__tests__/context-refresh-repo-intel.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('node:child_process')>();
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<typeof actual.execFileSync>[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);
});
});
Loading
Loading