Skip to content

Commit d35e065

Browse files
authored
Merge pull request #10 from sunilp/feat/issue-7-review-command
feat: add jam review command
2 parents 15a21e0 + eb8f5e9 commit d35e065

4 files changed

Lines changed: 512 additions & 1 deletion

File tree

src/commands/review.test.ts

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import type { MockInstance } from 'vitest';
3+
4+
// ── Module mocks ──────────────────────────────────────────────────────────────
5+
6+
vi.mock('node:child_process', () => ({
7+
execFile: vi.fn(),
8+
}));
9+
10+
vi.mock('../utils/workspace.js', () => ({
11+
getWorkspaceRoot: vi.fn(),
12+
}));
13+
14+
vi.mock('../config/loader.js', () => ({
15+
loadConfig: vi.fn(),
16+
getActiveProfile: vi.fn(),
17+
}));
18+
19+
vi.mock('../providers/factory.js', () => ({
20+
createProvider: vi.fn(),
21+
}));
22+
23+
vi.mock('../utils/stream.js', () => ({
24+
withRetry: vi.fn((fn: () => unknown) => fn()),
25+
collectStream: vi.fn(),
26+
}));
27+
28+
vi.mock('../ui/renderer.js', () => ({
29+
streamToStdout: vi.fn(),
30+
printJsonResult: vi.fn(),
31+
printError: vi.fn(),
32+
}));
33+
34+
// ── Imports after mocks ───────────────────────────────────────────────────────
35+
36+
import { execFile } from 'node:child_process';
37+
import { getWorkspaceRoot } from '../utils/workspace.js';
38+
import { loadConfig, getActiveProfile } from '../config/loader.js';
39+
import { createProvider } from '../providers/factory.js';
40+
import { collectStream } from '../utils/stream.js';
41+
import { streamToStdout, printJsonResult, printError } from '../ui/renderer.js';
42+
import {
43+
buildReviewPrompt,
44+
getBranchDiff,
45+
getMergeBase,
46+
getPrDiff,
47+
getCurrentBranch,
48+
runReview,
49+
} from './review.js';
50+
51+
const execFileMock = execFile as unknown as ReturnType<typeof vi.fn>;
52+
53+
// ── Helpers ───────────────────────────────────────────────────────────────────
54+
55+
const FAKE_DIFF = `diff --git a/src/foo.ts b/src/foo.ts\n+export const x = 1;`;
56+
const FAKE_MERGE_BASE = 'abc1234';
57+
58+
/**
59+
* Stubs execFile with a callback-style mock compatible with promisify.
60+
*/
61+
function mockExecSequence(
62+
responses: Array<{ match: string | ((args: string[]) => boolean); result: { stdout: string } | Error }>
63+
) {
64+
execFileMock.mockImplementation(
65+
(
66+
_cmd: string,
67+
args: string[],
68+
_opts: unknown,
69+
cb: (err: Error | null, res?: { stdout: string }) => void
70+
) => {
71+
const entry = responses.find((r) =>
72+
typeof r.match === 'function' ? r.match(args) : args.includes(r.match)
73+
);
74+
if (!entry) {
75+
cb(null, { stdout: '' });
76+
return;
77+
}
78+
if (entry.result instanceof Error) {
79+
cb(entry.result);
80+
} else {
81+
cb(null, entry.result);
82+
}
83+
}
84+
);
85+
}
86+
87+
function setupHappyPath() {
88+
(getWorkspaceRoot as ReturnType<typeof vi.fn>).mockResolvedValue('/repo');
89+
(loadConfig as ReturnType<typeof vi.fn>).mockResolvedValue({});
90+
(getActiveProfile as ReturnType<typeof vi.fn>).mockReturnValue({
91+
model: 'gpt-4o',
92+
temperature: 0.2,
93+
maxTokens: 1024,
94+
systemPrompt: undefined,
95+
});
96+
(createProvider as ReturnType<typeof vi.fn>).mockResolvedValue({
97+
streamCompletion: vi.fn(),
98+
});
99+
(streamToStdout as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
100+
(collectStream as ReturnType<typeof vi.fn>).mockResolvedValue({
101+
text: 'Review result',
102+
usage: { promptTokens: 10, completionTokens: 20 },
103+
});
104+
105+
mockExecSequence([
106+
{ match: 'merge-base', result: { stdout: FAKE_MERGE_BASE } },
107+
{ match: '--abbrev-ref', result: { stdout: 'feature/my-branch' } },
108+
{
109+
match: (args) => args.includes(FAKE_MERGE_BASE) && args.includes('HEAD'),
110+
result: { stdout: FAKE_DIFF },
111+
},
112+
]);
113+
}
114+
115+
// ── buildReviewPrompt ─────────────────────────────────────────────────────────
116+
117+
describe('buildReviewPrompt', () => {
118+
it('includes the diff in a fenced code block', () => {
119+
const prompt = buildReviewPrompt('diff content', 'context info');
120+
expect(prompt).toContain('```diff\ndiff content\n```');
121+
});
122+
123+
it('includes the context string', () => {
124+
const prompt = buildReviewPrompt('diff content', 'Reviewing branch "feat" vs "main".');
125+
expect(prompt).toContain('Reviewing branch "feat" vs "main".');
126+
});
127+
128+
it('asks for summary, issues, and suggestions', () => {
129+
const prompt = buildReviewPrompt('', '');
130+
expect(prompt).toContain('Summary');
131+
expect(prompt).toContain('Potential Issues');
132+
expect(prompt).toContain('Suggestions');
133+
});
134+
});
135+
136+
// ── getMergeBase ──────────────────────────────────────────────────────────────
137+
138+
describe('getMergeBase', () => {
139+
it('returns the merge-base SHA', async () => {
140+
execFileMock.mockImplementation(
141+
(_cmd: string, _args: string[], _opts: unknown, cb: (err: null, res: { stdout: string }) => void) => {
142+
cb(null, { stdout: ' abc1234 \n' });
143+
}
144+
);
145+
const result = await getMergeBase('/repo', 'main');
146+
expect(result).toBe('abc1234');
147+
});
148+
149+
it('throws JamError when git fails', async () => {
150+
execFileMock.mockImplementation(
151+
(_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) => {
152+
cb(new Error('not a git repo'));
153+
}
154+
);
155+
await expect(getMergeBase('/repo', 'main')).rejects.toThrow('merge-base');
156+
});
157+
});
158+
159+
// ── getBranchDiff ─────────────────────────────────────────────────────────────
160+
161+
describe('getBranchDiff', () => {
162+
it('returns trimmed diff output', async () => {
163+
mockExecSequence([
164+
{ match: 'merge-base', result: { stdout: FAKE_MERGE_BASE } },
165+
{
166+
match: (args) => args.includes(FAKE_MERGE_BASE) && args.includes('HEAD'),
167+
result: { stdout: ` ${FAKE_DIFF} ` },
168+
},
169+
]);
170+
const result = await getBranchDiff('/repo', 'main');
171+
expect(result).toBe(FAKE_DIFF);
172+
});
173+
174+
it('throws JamError when git diff fails', async () => {
175+
mockExecSequence([
176+
{ match: 'merge-base', result: { stdout: FAKE_MERGE_BASE } },
177+
{
178+
match: (args) => args.includes(FAKE_MERGE_BASE),
179+
result: new Error('git exploded'),
180+
},
181+
]);
182+
await expect(getBranchDiff('/repo', 'main')).rejects.toThrow('diff against');
183+
});
184+
});
185+
186+
// ── getPrDiff ─────────────────────────────────────────────────────────────────
187+
188+
describe('getPrDiff', () => {
189+
it('returns the PR diff from gh CLI', async () => {
190+
execFileMock.mockImplementation(
191+
(_cmd: string, _args: string[], _opts: unknown, cb: (err: null, res: { stdout: string }) => void) => {
192+
cb(null, { stdout: FAKE_DIFF });
193+
}
194+
);
195+
const result = await getPrDiff('/repo', 42);
196+
expect(result).toBe(FAKE_DIFF);
197+
});
198+
199+
it('throws JamError mentioning gh CLI when it fails', async () => {
200+
execFileMock.mockImplementation(
201+
(_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) => {
202+
cb(new Error('gh not found'));
203+
}
204+
);
205+
await expect(getPrDiff('/repo', 42)).rejects.toThrow('GitHub CLI');
206+
});
207+
});
208+
209+
// ── getCurrentBranch ──────────────────────────────────────────────────────────
210+
211+
describe('getCurrentBranch', () => {
212+
it('returns the current branch name', async () => {
213+
execFileMock.mockImplementation(
214+
(_cmd: string, _args: string[], _opts: unknown, cb: (err: null, res: { stdout: string }) => void) => {
215+
cb(null, { stdout: 'feature/my-branch\n' });
216+
}
217+
);
218+
const result = await getCurrentBranch('/repo');
219+
expect(result).toBe('feature/my-branch');
220+
});
221+
});
222+
223+
// ── runReview ─────────────────────────────────────────────────────────────────
224+
225+
describe('runReview', () => {
226+
let processExitSpy: MockInstance<[code?: string | number | null | undefined], never>;
227+
228+
beforeEach(() => {
229+
vi.clearAllMocks();
230+
processExitSpy = vi
231+
.spyOn(process, 'exit')
232+
.mockImplementation((_code) => { throw new Error('process.exit'); }) as typeof processExitSpy;
233+
});
234+
235+
afterEach(() => {
236+
processExitSpy.mockRestore();
237+
});
238+
239+
it('streams a branch review to stdout', async () => {
240+
setupHappyPath();
241+
await runReview({ base: 'main' });
242+
expect(streamToStdout).toHaveBeenCalledOnce();
243+
expect(printError).not.toHaveBeenCalled();
244+
});
245+
246+
it('uses "main" as the default base', async () => {
247+
setupHappyPath();
248+
await runReview({});
249+
// merge-base should have been called with 'main'
250+
const calls = execFileMock.mock.calls as Array<[string, string[], unknown, unknown]>;
251+
const mergeBaseCall = calls.find(([, args]) => args.includes('merge-base'));
252+
expect(mergeBaseCall).toBeDefined();
253+
expect(mergeBaseCall![1]).toContain('main');
254+
});
255+
256+
it('outputs JSON when --json is set', async () => {
257+
setupHappyPath();
258+
await runReview({ base: 'main', json: true });
259+
expect(printJsonResult).toHaveBeenCalledOnce();
260+
expect(streamToStdout).not.toHaveBeenCalled();
261+
});
262+
263+
it('prints nothing to review when the diff is empty', async () => {
264+
(getWorkspaceRoot as ReturnType<typeof vi.fn>).mockResolvedValue('/repo');
265+
mockExecSequence([
266+
{ match: 'merge-base', result: { stdout: FAKE_MERGE_BASE } },
267+
{ match: '--abbrev-ref', result: { stdout: 'feature/x' } },
268+
{
269+
match: (args) => args.includes(FAKE_MERGE_BASE) && args.includes('HEAD'),
270+
result: { stdout: '' },
271+
},
272+
]);
273+
274+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
275+
await runReview({ base: 'main' });
276+
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining('No differences'));
277+
expect(streamToStdout).not.toHaveBeenCalled();
278+
writeSpy.mockRestore();
279+
});
280+
281+
it('uses gh pr diff when --pr is provided', async () => {
282+
(getWorkspaceRoot as ReturnType<typeof vi.fn>).mockResolvedValue('/repo');
283+
(loadConfig as ReturnType<typeof vi.fn>).mockResolvedValue({});
284+
(getActiveProfile as ReturnType<typeof vi.fn>).mockReturnValue({
285+
model: 'gpt-4o',
286+
temperature: 0.2,
287+
maxTokens: 1024,
288+
systemPrompt: undefined,
289+
});
290+
(createProvider as ReturnType<typeof vi.fn>).mockResolvedValue({ streamCompletion: vi.fn() });
291+
(streamToStdout as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
292+
293+
execFileMock.mockImplementation(
294+
(_cmd: string, args: string[], _opts: unknown, cb: (err: null, res: { stdout: string }) => void) => {
295+
if (args.includes('diff') && args.includes('42')) {
296+
cb(null, { stdout: FAKE_DIFF });
297+
} else {
298+
cb(null, { stdout: '' });
299+
}
300+
}
301+
);
302+
303+
await runReview({ pr: 42 });
304+
expect(streamToStdout).toHaveBeenCalledOnce();
305+
const calls = execFileMock.mock.calls as Array<[string, string[], unknown, unknown]>;
306+
const ghCall = calls.find(([cmd]) => cmd === 'gh');
307+
expect(ghCall).toBeDefined();
308+
expect(ghCall![1]).toContain('42');
309+
});
310+
311+
it('exits with code 1 and prints error on failure', async () => {
312+
(getWorkspaceRoot as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('no workspace'));
313+
await expect(runReview({})).rejects.toThrow('process.exit');
314+
expect(printError).toHaveBeenCalled();
315+
expect(processExitSpy).toHaveBeenCalledWith(1);
316+
});
317+
});

0 commit comments

Comments
 (0)