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
30 changes: 30 additions & 0 deletions docs/releases.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
# Release Notes

## v3.5.1

> **Release Date**: 2026-08-27
> **Focus**: Sync Stats Hotfix — credential loading + API schema fix

### 🐛 Bug Fixes

#### `leetcode sync` always showed "Stats unavailable"

Two root causes were identified and fixed via live API probe:

- **Missing credential loading**: `syncCommand()` called the LeetCode submission API without loading stored session credentials onto the HTTP client. Git authentication (SSH / PAT) is independent of the LeetCode session, so the command could push to GitHub while still being unauthenticated against the LeetCode API. Fixed by calling `setupClientIfLoggedIn()` at the start of `syncCommand()`.

- **Schema type mismatch**: `SubmissionDetailsSchema` declared `runtime` and `memory` as `string`, but the LeetCode API returns them as `number` (raw milliseconds / bytes). Zod rejected every response, causing the `catch` block to fire for every user on every problem, regardless of auth status. Fixed by accepting `number | string` for both fields and making `lang` nullable to handle old submissions.

#### Files changed
- `src/commands/sync.ts` — `setupClientIfLoggedIn()` called at entry
- `src/schemas/api.ts` — `SubmissionDetailsSchema` corrected field types
- `src/types.ts` — `SubmissionDetails` interface updated to match

### 🧪 Testing

- Added `credential loading` test suite (5 tests) to `sync.test.ts`:
- Verifies `setupClientIfLoggedIn` is called before any API call
- Verifies auth runs even when there are no changes or work directory is missing
- Regression test using the exact problem slugs (`valid-parentheses`, `valid-palindrome`) from the original bug report — asserts commit body contains real stats, not "Stats unavailable"
- Total sync tests: 20 (was 15)

---

## v3.5.0

> **Release Date**: 2026-08-14
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@night-slayer18/leetcode-cli",
"version": "3.5.0",
"version": "3.5.1",
"description": "A modern LeetCode CLI built with TypeScript",
"type": "module",
"main": "dist/index.js",
Expand All @@ -21,7 +21,8 @@
"lint:fix": "eslint src --ext .ts --fix",
"typecheck": "tsc --noEmit",
"format": "prettier --write \"**/*.{ts,json,md,yml,yaml}\"",
"format:check": "prettier --check \"**/*.{ts,json,md,yml,yaml}\""
"format:check": "prettier --check \"**/*.{ts,json,md,yml,yaml}\"",
"release": "node release.mjs"
},
"keywords": [
"leetcode",
Expand Down
111 changes: 111 additions & 0 deletions release.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#!/usr/bin/env node
/**
* release.mjs — prepares a release:
*
* 1. Bumps version in package.json and src/index.ts
* 2. Runs npm install (updates package-lock.json)
* 3. Typecheck → lint → build → tests
*
* Usage:
* npm run release patch # 3.5.1 → 3.5.2
* npm run release minor # 3.5.1 → 3.6.0
* npm run release major # 3.5.1 → 4.0.0
* npm run release 3.6.0 # explicit version
*/

import { execSync } from 'child_process';
import { readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';

const ROOT = new URL('.', import.meta.url).pathname;

// ── helpers ───────────────────────────────────────────────────────────────────

function run(cmd) {
console.log(` $ ${cmd}`);
execSync(cmd, { stdio: 'inherit', cwd: ROOT });
}

function bump(current, type) {
const [major, minor, patch] = current.split('.').map(Number);
if (type === 'major') return `${major + 1}.0.0`;
if (type === 'minor') return `${major}.${minor + 1}.0`;
if (type === 'patch') return `${major}.${minor}.${patch + 1}`;
if (/^\d+\.\d+\.\d+$/.test(type)) return type;
throw new Error(`Unknown bump type: "${type}". Use patch / minor / major / x.y.z`);
}

function readJson(file) {
return JSON.parse(readFileSync(resolve(ROOT, file), 'utf-8'));
}

function writeJson(file, data) {
writeFileSync(resolve(ROOT, file), JSON.stringify(data, null, 2) + '\n', 'utf-8');
}

function readText(file) {
return readFileSync(resolve(ROOT, file), 'utf-8');
}

function writeText(file, content) {
writeFileSync(resolve(ROOT, file), content, 'utf-8');
}

// ── main ──────────────────────────────────────────────────────────────────────

const bumpType = process.argv[2];
if (!bumpType) {
console.error('Usage: npm run release <patch|minor|major|x.y.z>');
process.exit(1);
}

// 1. Determine new version
const pkg = readJson('package.json');
const currentVersion = pkg.version;
const newVersion = bump(currentVersion, bumpType);

if (newVersion === currentVersion) {
console.error(`❌ New version (${newVersion}) is the same as current (${currentVersion})`);
process.exit(1);
}

console.log(`\n🔖 ${currentVersion} → ${newVersion}\n`);

// 1a. package.json
pkg.version = newVersion;
writeJson('package.json', pkg);
console.log(' ✓ package.json');

// 1b. src/index.ts
const indexPath = 'src/index.ts';
const indexContent = readText(indexPath);
const updatedIndex = indexContent.replace(/\.version\('[\d.]+',/, `.version('${newVersion}',`);
if (updatedIndex === indexContent) {
console.error('❌ Could not find .version(...) in src/index.ts — update it manually.');
process.exit(1);
}
writeText(indexPath, updatedIndex);
console.log(' ✓ src/index.ts');

// 2. npm install → refreshes package-lock.json
console.log('\n📦 npm install…');
run('npm install');

// 3. Quality gates
console.log('\n🔎 typecheck…');
run('npm run typecheck');

console.log('\n🔍 lint…');
run('npm run lint');

console.log('\n🏗️ build…');
run('npm run build');

console.log('\n🧪 tests…');
run('npm test');

console.log(`\n✅ v${newVersion} is ready to release!\n`);
console.log('Remaining steps:');
console.log(` 1. Update docs/releases.md with the v${newVersion} changelog`);
console.log(` 2. git add -A && git commit -s -m "chore(release): bump version to ${newVersion}"`);
console.log(` 3. git tag v${newVersion} && git push && git push --tags`);
109 changes: 109 additions & 0 deletions src/__tests__/commands/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ vi.mock('../../storage/credentials.js', () => ({
},
}));

vi.mock('../../utils/auth.js', () => ({
setupClientIfLoggedIn: vi.fn().mockResolvedValue(true),
configureLeetCodeClientSite: vi.fn(),
}));

vi.mock('../../storage/config.js', () => ({
config: {
getConfig: vi.fn(() => ({
Expand Down Expand Up @@ -74,6 +79,7 @@ vi.mock('inquirer', () => ({
import { syncCommand } from '../../commands/sync.js';
import { config } from '../../storage/config.js';
import { leetcodeClient } from '../../api/client.js';
import { setupClientIfLoggedIn } from '../../utils/auth.js';
import { execSync, execFileSync } from 'child_process';
import { existsSync } from 'fs';

Expand All @@ -85,6 +91,7 @@ describe('Sync Command', () => {
vi.mocked(config.getWorkDir).mockReturnValue('/tmp/leetcode');
vi.mocked(config.getRepo).mockReturnValue('https://github.com/user/repo.git');
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
vi.mocked(setupClientIfLoggedIn).mockResolvedValue(true);
vi.mocked(leetcodeClient.getSubmissionList).mockResolvedValue([
{ id: '12345', statusDisplay: 'Accepted', lang: 'typescript', runtime: '56ms', timestamp: '1720000000', memory: '42.1 MB' },
]);
Expand All @@ -98,6 +105,108 @@ describe('Sync Command', () => {
});
});

// ─── Credential loading (the "Stats unavailable" root-cause fix) ──────────

describe('credential loading', () => {
it('should call setupClientIfLoggedIn before any API call', async () => {
// Track call order
const order: string[] = [];
vi.mocked(setupClientIfLoggedIn).mockImplementation(async () => {
order.push('auth');
return true;
});
vi.mocked(leetcodeClient.getSubmissionList).mockImplementation(async () => {
order.push('api');
return [{ id: '1', statusDisplay: 'Accepted', lang: 'typescript', runtime: '0ms', timestamp: '0', memory: '0 MB' }];
});
vi.mocked(execSync).mockImplementation((cmd) => {
if (typeof cmd === 'string' && cmd === 'git status --porcelain') {
return ' M Easy/Array/1.two-sum.ts\n';
}
return Buffer.from('');
});

await syncCommand();

expect(order[0]).toBe('auth');
expect(order[1]).toBe('api');
});

it('should call setupClientIfLoggedIn even when there are no changes to sync', async () => {
vi.mocked(execSync).mockImplementation((cmd) => {
if (typeof cmd === 'string' && cmd === 'git status --porcelain') {
return '';
}
return Buffer.from('');
});

await syncCommand();

expect(setupClientIfLoggedIn).toHaveBeenCalledOnce();
});

it('should call setupClientIfLoggedIn even when work directory does not exist', async () => {
vi.mocked(existsSync).mockReturnValue(false);

await syncCommand();

expect(setupClientIfLoggedIn).toHaveBeenCalledOnce();
});

it('should still fetch stats and commit when setupClientIfLoggedIn returns false (not logged in)', async () => {
// Even if credentials aren't found, sync should proceed — the API will
// fall back to "Stats unavailable" rather than crashing the whole command.
vi.mocked(setupClientIfLoggedIn).mockResolvedValue(false);
vi.mocked(leetcodeClient.getSubmissionList).mockRejectedValue(new Error('Unauthorized'));
vi.mocked(execSync).mockImplementation((cmd) => {
if (typeof cmd === 'string' && cmd === 'git status --porcelain') {
return ' M Easy/Array/1.two-sum.ts\n';
}
return Buffer.from('');
});

await syncCommand();

// Should still commit, just with "Stats unavailable" body
expect(execFileSync).toHaveBeenCalledWith(
'git',
expect.arrayContaining(['commit', '-m', expect.stringContaining('Sync:'), '-m', expect.stringContaining('Stats unavailable')]),
expect.any(Object)
);
});

it('should produce correct stats in commit body when credentials are loaded', async () => {
// This is the regression test for the original bug:
// before the fix, stats were always "Stats unavailable" because credentials
// were never loaded. With the fix, the commit body should contain real stats.
vi.mocked(execSync).mockImplementation((cmd) => {
if (typeof cmd === 'string' && cmd === 'git status --porcelain') {
return ' M Easy/Array/20.valid-parentheses.ts\n M Easy/String/125.valid-palindrome.ts\n';
}
return Buffer.from('');
});

await syncCommand();

expect(setupClientIfLoggedIn).toHaveBeenCalledOnce();
expect(leetcodeClient.getSubmissionList).toHaveBeenCalledTimes(2);

const commitCall = vi.mocked(execFileSync).mock.calls.find(
(call) => call[0] === 'git' && Array.isArray(call[1]) && (call[1] as string[]).includes('commit')
);
const args = commitCall?.[1] as string[];
const bodyIdx = args.lastIndexOf('-m');
const body = args[bodyIdx + 1];

// Both problems should have real stats, NOT "Stats unavailable"
expect(body).toContain('valid-parentheses');
expect(body).toContain('valid-palindrome');
expect(body).not.toContain('Stats unavailable');
expect(body).toContain('Runtime: 56ms (beats 84.50%)');
expect(body).toContain('Memory: 42.1MB (beats 76.20%)');
});
});

// ─── Early exit guards ────────────────────────────────────────────────────

describe('early exit guards', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/submissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export async function submissionsCommand(
await mkdir(targetDir, { recursive: true });
}

const langSlug = details.lang.name.toLowerCase();
const langSlug = (details.lang?.name ?? lastAC.lang).toLowerCase();
const supportedLang = LANG_SLUG_MAP[langSlug] ?? 'txt';
const ext = LANGUAGE_EXTENSIONS[supportedLang as SupportedLanguage] ?? langSlug;

Expand Down
9 changes: 6 additions & 3 deletions src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import inquirer from 'inquirer';
import ora from 'ora';
import { config } from '../storage/config.js';
import { leetcodeClient } from '../api/client.js';
import { setupClientIfLoggedIn } from '../utils/auth.js';
import path from 'path';

function sanitizeRepoName(name: string): string {
Expand Down Expand Up @@ -167,6 +168,8 @@ async function setupRemote(workDir: string): Promise<string> {
}

export async function syncCommand(): Promise<void> {
await setupClientIfLoggedIn();

const workDir = config.getWorkDir();

if (!existsSync(workDir)) {
Expand Down Expand Up @@ -209,7 +212,7 @@ export async function syncCommand(): Promise<void> {
const lines = status.trim().split('\n');
const count = lines.length;
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);

// Fetch stats for all changed solution files.
// To prevent duplicate queries and rate limiting, we track processed title slugs.
const solutionsList: string[] = [];
Expand All @@ -227,7 +230,7 @@ export async function syncCommand(): Promise<void> {
const [, problemId, titleSlug] = match;
if (!processedTitleSlugs.has(titleSlug)) {
processedTitleSlugs.add(titleSlug);

try {
const submissions = await leetcodeClient.getSubmissionList(titleSlug, 5);
const lastAC = submissions.find((s) => s.statusDisplay === 'Accepted');
Expand All @@ -237,7 +240,7 @@ export async function syncCommand(): Promise<void> {
const memoryStr = details.memoryDisplay || details.memory || 'N/A';
const runtimeBeats = details.runtimePercentile ? ` (beats ${details.runtimePercentile.toFixed(2)}%)` : '';
const memoryBeats = details.memoryPercentile ? ` (beats ${details.memoryPercentile.toFixed(2)}%)` : '';

solutionsList.push(`- [${problemId}. ${titleSlug}] Runtime: ${runtimeStr}${runtimeBeats}, Memory: ${memoryStr}${memoryBeats}`);
} else {
solutionsList.push(`- [${problemId}. ${titleSlug}] No accepted submission stats found`);
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ program
.name('leetcode')
.usage('[command] [options]')
.description(chalk.bold.cyan('🔥 A modern LeetCode CLI built with TypeScript'))
.version('3.5.0', '-v, --version', 'Output the version number')
.version('3.5.1', '-v, --version', 'Output the version number')
.helpOption('-h, --help', 'Display help for command')
.addHelpText(
'after',
Expand Down
8 changes: 3 additions & 5 deletions src/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,16 +213,14 @@ export const SubmissionSchema = z.object({

export const SubmissionDetailsSchema = z.object({
code: z.string(),
runtime: z.string().optional().nullable(),
runtime: z.union([z.number(), z.string()]).optional().nullable(),
runtimeDisplay: z.string().optional().nullable(),
runtimePercentile: z.number().optional().nullable(),
memory: z.string().optional().nullable(),
memory: z.union([z.number(), z.string()]).optional().nullable(),
memoryDisplay: z.string().optional().nullable(),
memoryPercentile: z.number().optional().nullable(),
statusDisplay: z.string().optional().nullable(),
lang: z.object({
name: z.string(),
}),
lang: z.object({ name: z.string() }).nullable().optional(),
});

export const TestResultSchema = z.object({
Expand Down
Loading
Loading