diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db286bd8..b7b60391 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,3 +48,6 @@ jobs: - name: Test run: npm test + + - name: Test Scripts + run: npm run test:scripts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..b8ec99cb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + name: Create Release & Update Changelog + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release create "${{ github.ref_name }}" --generate-notes + + - name: Generate Changelog + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/generate-changelog.js + + - name: Commit Changelog + run: | + if git diff --quiet CHANGELOG.md; then + echo "CHANGELOG.md is already up to date, skipping commit." + else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md + git commit -m "docs: update CHANGELOG.md for ${{ github.ref_name }}" + git push + fi diff --git a/.husky/pre-commit b/.husky/pre-commit index 2312dc58..cd40e916 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,9 @@ -npx lint-staged +npm run pre-commit || { + echo '' + echo '====================================================' + echo 'pre-commit checks failed. in case of emergency, run:' + echo '' + echo 'git commit --no-verify' + echo '====================================================' + exit 1 +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..8d21b294 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,261 @@ +# Changelog + +All notable changes to [Deep Code](https://github.com/lessweb/deepcode-cli) are +documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and the project follows +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). Only stable releases +are listed; pre-releases are intentionally omitted. + +> **This file is generated automatically** from +> [GitHub Releases](https://github.com/lessweb/deepcode-cli/releases). Do not +> edit it by hand — run `npm run changelog` to regenerate. + +## [0.1.31](https://github.com/lessweb/deepcode-cli/releases/tag/v0.1.31) + +`2026-06-16` + +### Other + +- chore(deps-dev): bump esbuild and tsx ([#174](https://github.com/lessweb/deepcode-cli/pull/174)) +- chore: 优化deepcode-self-refer skill +- chore: 优化skill-digester skill + +## [0.1.30](https://github.com/lessweb/deepcode-cli/releases/tag/v0.1.30) + +`2026-06-15` + +### Added + +- enhance Windows MCP command quoting and add tests for cmd metacharacters +- add plan mode skill and enhance shell init command tests +- add support for implicit invocation control in skills +- update default skill loading so enabledSkills can skip the built-in skills + +### Fixed + +- mcp: fix Windows MCP spawn double-quoting that breaks all MCP servers ([#164](https://github.com/lessweb/deepcode-cli/pull/164)) + +## [0.1.29](https://github.com/lessweb/deepcode-cli/releases/tag/v0.1.29) + +`2026-06-09` + +### Added + +- implement bundled built-in skills +- add docs/session-persistence.md +- implement `enabledSkills` support in settings.json +- add raw mode shortcut `ctrl+r` + +### Other + +- 修复提示输入的换行、光标定位与 busy 状态显示 ([#171](https://github.com/lessweb/deepcode-cli/pull/171)) +- @qorzj made their first contribution in https://github.com/lessweb/deepcode-cli/pull/171 + +## [0.1.28](https://github.com/lessweb/deepcode-cli/releases/tag/v0.1.28) + +`2026-06-05` + +### Added + +- session name can be edited now ([#159](https://github.com/lessweb/deepcode-cli/pull/159)) +- ui: 优化 PromptInput 组件的光标显示与布局 ([#161](https://github.com/lessweb/deepcode-cli/pull/161)) + +### Changed + +- extract OpenAI message converter from SessionManager ([#140](https://github.com/lessweb/deepcode-cli/pull/140)) + +### Other + +- 升级bash tool,支持后台运行(`run_in_background`),可解决使用playwright测试场景下,启动server导致流程阻塞的问题 +- 修复bash tool多行参数的渲染问题,现在一定会显示LLM提供的description信息 +- Enhance cursor handling in PromptInput component to fix IME composition anchoring +- 更新karpathy-guidelines.md,增加提示:`Apply these guidelines silently. Do not cite this document, its title, or guideline names in user-facing responses.` +- Improve the Markdown underscore rendering +- Add MCP tool name handling with API-safe names +- Implement temperature support for settings.json +- Agent Skills相关优化: +- @dependabot[bot] made their first contribution in https://github.com/lessweb/deepcode-cli/pull/157 +- @Feiry-zZ made their first contribution in https://github.com/lessweb/deepcode-cli/pull/159 + +## [0.1.27](https://github.com/lessweb/deepcode-cli/releases/tag/v0.1.27) + +`2026-06-01` + +### Other + +- chore(deps): update ink-gradient to 4.0.1 ([#135](https://github.com/lessweb/deepcode-cli/pull/135)) +- chore: 更新API Key not found时的文本显示 ([#137](https://github.com/lessweb/deepcode-cli/pull/137)) +- 优化edit tool,取消返回findClosestMatch,直接用inferOldStringNotFoundReasonWithLLM生成提示信息 +- 改进系统提示词,引入[karpathy-guidelines](https://github.com/multica-ai/andrej-karpathy-skills),经验证对于复杂任务可显著提升成功率 +- @fym998 made their first contribution in https://github.com/lessweb/deepcode-cli/pull/135 +- @iamhmx made their first contribution in https://github.com/lessweb/deepcode-cli/pull/137 + +## [0.1.26](https://github.com/lessweb/deepcode-cli/releases/tag/v0.1.26) + +`2026-05-29` + +### Changed + +- ui: 重构代码结构,调整文件路径和导入引用 ([#122](https://github.com/lessweb/deepcode-cli/pull/122)) +- extract telemetry into separate module with enable/disable toggle ([#130](https://github.com/lessweb/deepcode-cli/pull/130)) + +### Fixed + +- session: 修复会话清理内存泄漏并补充回归测试 ([#123](https://github.com/lessweb/deepcode-cli/pull/123)) +- prompt-buffer: 修正 getCurrentSlashToken 函数逻辑 ([#129](https://github.com/lessweb/deepcode-cli/pull/129)) + +### Other + +- chore(deps): 更新 ink 依赖到 7.0.4 版本 ([#125](https://github.com/lessweb/deepcode-cli/pull/125)) +- Edit 工具增强 — 支持空 old_string 的文件编辑,snippet 处理增强(full-file 支持 + 强制 snippet_id) +- 快照机制增强 - 修复`/undo`恢复快照场景的已知问题 +- Agent+手动混合修改场景优化 - 基于快照机制检测到手动修改时,自动增加system prompt,可有效防止LLM无脑覆盖手动修改。 + +## [0.1.25](https://github.com/lessweb/deepcode-cli/releases/tag/v0.1.25) + +`2026-05-25` + +### Added + +- ui: add bracketed paste with large-paste marker collapsing ([#102](https://github.com/lessweb/deepcode-cli/pull/102)) +- ui: 会话列表支持 Delete 键删除会话 ([#114](https://github.com/lessweb/deepcode-cli/pull/114)) +- ui: 增加会话删除及相关UI重置功能 ([#119](https://github.com/lessweb/deepcode-cli/pull/119)) +- markdown 表格闭合边框渲染 + CJK/emoji 宽度适配 ([#115](https://github.com/lessweb/deepcode-cli/pull/115)) +- implement checkpoints store only explicit Write/Edit file paths + +### Fixed + +- permission: 处理权限拒绝状态与界面更新 ([#120](https://github.com/lessweb/deepcode-cli/pull/120)) + +### Performance + +- reuse OpenAI client and add undici keep-alive Agent with connection warmup ([#100](https://github.com/lessweb/deepcode-cli/pull/100)) + +### Documentation + +- 更新扩展命令菜单说明和帮助文档 ([#109](https://github.com/lessweb/deepcode-cli/pull/109)) + +### Other + +- 实现权限机制,详见 [docs/permission.md](docs/permission.md) +- @jeoor made their first contribution in https://github.com/lessweb/deepcode-cli/pull/102 +- @xinggitxing made their first contribution in https://github.com/lessweb/deepcode-cli/pull/114 + +## [0.1.24](https://github.com/lessweb/deepcode-cli/releases/tag/v0.1.24) + +`2026-05-21` + +### Added + +- MCP 服务器手动重连功能 ([#84](https://github.com/lessweb/deepcode-cli/pull/84)) + +### Changed + +- Extract dropdown components ([#97](https://github.com/lessweb/deepcode-cli/pull/97)) +- ui: 使用 resetPromptInput 简化撤销和回绕处理 ([#101](https://github.com/lessweb/deepcode-cli/pull/101)) + +### Fixed + +- ui: 修正组件路径拼写错误 ([#93](https://github.com/lessweb/deepcode-cli/pull/93)) +- resolve CJK composition bug on iOS terminals (backspace packet splitting) ([#94](https://github.com/lessweb/deepcode-cli/pull/94)) + +### Other + +- 新增`/undo`斜杠命令,同时实现了基于git的快照回滚机制 +- 适配七牛/火山引擎等deepseek模型时可能出现的tool call字段相关问题 +- @liante0904 made their first contribution in https://github.com/lessweb/deepcode-cli/pull/94 + +## [0.1.23](https://github.com/lessweb/deepcode-cli/releases/tag/v0.1.23) + +`2026-05-19` + +### Added + +- 新增 `/raw` 命令交互与终端消息直出 ([#89](https://github.com/lessweb/deepcode-cli/pull/89)) +- notify: pass STATUS, FAIL_REASON, BODY, TITLE as env vars to notify hook ([#90](https://github.com/lessweb/deepcode-cli/pull/90)) + +### Other + +- 修复Windows系统下Bash tool⽆法杀死进程组的问题 +- 实现Bash tool的超时机制,默认10分钟,可在ctrl+o界面调整 + +## [0.1.22](https://github.com/lessweb/deepcode-cli/releases/tag/v0.1.22) + +`2026-05-18` + +### Added + +- add -p/--prompt flag to auto-submit prompt on launch ([#86](https://github.com/lessweb/deepcode-cli/pull/86)) +- Add Ctrl+O live process stdout viewer ([#75](https://github.com/lessweb/deepcode-cli/pull/75)) + +### Other + +- 改进Edit tool在LLM出现参数`\`转义错误时的表现 +- 新增UpdatePlan tool和自带的plan-and-execute-skill提示词 +- 实现输入框中用`@`唤起file mention的功能 +- 实现`/command`斜杠命令 +- @whzp015258712145-hub made their first contribution in https://github.com/lessweb/deepcode-cli/pull/86 + +## [0.1.21](https://github.com/lessweb/deepcode-cli/releases/tag/v0.1.21) + +`2026-05-16` + +### Added + +- 添加 GitHub CI 工作流,支持多平台多版本自动验证 ([#76](https://github.com/lessweb/deepcode-cli/pull/76)) + +### Changed + +- MCP 新增状态管理增强与 UI 可视化 ([#72](https://github.com/lessweb/deepcode-cli/pull/72)) + +### Fixed + +- resolve Windows CI failures (CRLF, MCP spawn, cross-platform test runner) ([#77](https://github.com/lessweb/deepcode-cli/pull/77)) + +### Documentation + +- add English translations of configuration.md and mcp.md ([#56](https://github.com/lessweb/deepcode-cli/pull/56)) + +### Other + +- style(DropdownMenu): 调整内边距优化下拉菜单布局 ([#73](https://github.com/lessweb/deepcode-cli/pull/73)) +- 为每个模型添加使用跟踪,并修复退出摘要(exit summary)表格 +- 改进Edit/Write tool,在一读多写失败的情况下提示LLM文件或片段已过时 +- 后端重构: move debug and error logging to common directory +- @rock-solid-sites made their first contribution in https://github.com/lessweb/deepcode-cli/pull/56 + +## [0.1.20](https://github.com/lessweb/deepcode-cli/releases/tag/v0.1.20) + +`2026-05-14` + +### Added + +- add MCP (Model Context Protocol) support with /mcp command ([#48](https://github.com/lessweb/deepcode-cli/pull/48)) +- Handle Shift+Enter as prompt newline ([#52](https://github.com/lessweb/deepcode-cli/pull/52)) +- 新增 DropdownMenu 组件 ([#58](https://github.com/lessweb/deepcode-cli/pull/58)) +- Add prompt undo and redo shortcuts ([#59](https://github.com/lessweb/deepcode-cli/pull/59)) +- ui: 优化消息视图的布局和宽度自适应 ([#66](https://github.com/lessweb/deepcode-cli/pull/66)) + +### Changed + +- session: 简化并统一会话系统消息的处理逻辑 ([#62](https://github.com/lessweb/deepcode-cli/pull/62)) + +### Fixed + +- improve Windows Git Bash detection ([#55](https://github.com/lessweb/deepcode-cli/pull/55)) +- ui: 修正 reasoningEffort 显示逻辑 ([#63](https://github.com/lessweb/deepcode-cli/pull/63)) +- session: 修复系统消息可见性设置错误 ([#64](https://github.com/lessweb/deepcode-cli/pull/64)) +- filter image_url content from API messages for DeepSeek compatibility ([#51](https://github.com/lessweb/deepcode-cli/pull/51)) + +### Other + +- 重构docs目录,分拆为普通文档目录(docs)和提示词模板目录(templates) +- 重构后端代码,分拆出 `src/mcp` 和 `src/common` 目录 +- 优化系统提示词:删除DeepSeek不擅长的`ast-grep`相关内容;注入今天日期;给AI Agent一个名字(Deep Code),防止DeepSeek给自己脑补一个名字。 +- 实现完善的配置方案,参见:[docs/configuration.md](https://github.com/lessweb/deepcode-cli/blob/main/docs/configuration.md) +- 优化`/model`的交互UI +- 更新README文档 +- @dengmik-commits made their first contribution in https://github.com/lessweb/deepcode-cli/pull/48 +- @yuefengw made their first contribution in https://github.com/lessweb/deepcode-cli/pull/55 diff --git a/package.json b/package.json index d4f502b4..4ef575e2 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,13 @@ "start": "node scripts/start.js", "build-and-start": "npm run build && npm run start", "test": "npm run test --workspaces --if-present", + "test:scripts": "node --test scripts/__tests__/*.test.js", "release:version": "node scripts/version.js", "prepare:package": "node scripts/prepare-package.js", "prepare:vscode": "node scripts/prepare-vscode.js", - "prepare": "husky && npm run build && npm run bundle" + "changelog": "node scripts/generate-changelog.js", + "prepare": "husky && npm run build && npm run bundle", + "pre-commit": "node scripts/pre-commit.js" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index 456030c3..24788502 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -48,6 +48,7 @@ import type { } from "@vegamo/deepcode-core"; import { SessionManager } from "@vegamo/deepcode-core"; import { getCompactPromptTokenThreshold } from "@vegamo/deepcode-core"; +import { writeStdoutLine } from "../../utils/stdio-helpers"; type View = "chat" | "session-list" | "undo" | "mcp-status"; @@ -145,8 +146,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp onAssistantMessage: (message: SessionMessage) => { setMessages((prev) => [...prev, message]); if (rawModeRef.current === RawMode.Raw) { - process.stdout.write("\n"); - process.stdout.write(renderMessageToStdout(message, rawModeRef.current) + "\n\n"); + writeStdoutLine("\n"); + writeStdoutLine(renderMessageToStdout(message, rawModeRef.current) + "\n\n"); } }, onSessionEntryUpdated: (entry) => { @@ -196,7 +197,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp const resetStaticView = useCallback( (loadedMessages: SessionMessage[], options?: { clearScreen?: boolean }): Promise => { if (options?.clearScreen) { - process.stdout.write(ANSI_CLEAR_SCREEN); + writeStdoutLine(ANSI_CLEAR_SCREEN); } setMessages([]); setWelcomeNonce((n) => n + 1); @@ -298,19 +299,19 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null; const resumeHint = buildResumeHintText(activeSessionId ?? undefined); - process.stdout.write("\n"); + writeStdoutLine("\n"); if (showCommand) { - process.stdout.write(chalk.rgb(34, 154, 195)("> /exit ")); - process.stdout.write("\n\n"); + writeStdoutLine(chalk.rgb(34, 154, 195)(" > /exit ")); + writeStdoutLine("\n"); } if (showSummary) { const summary = buildExitSummaryText({ session, sessionId: activeSessionId ?? undefined }); - process.stdout.write(summary); - process.stdout.write("\n\n"); + writeStdoutLine(summary); + writeStdoutLine("\n"); } if (resumeHint) { - process.stdout.write(resumeHint); - process.stdout.write("\n"); + writeStdoutLine(resumeHint); + writeStdoutLine("\n"); } sessionManager.dispose(); @@ -628,7 +629,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp setShowWelcome(false); setMessages([]); // Clear screen to remove stale formatted text. - process.stdout.write(ANSI_CLEAR_SCREEN); + writeStdoutLine(ANSI_CLEAR_SCREEN); setTimeout(() => { if (nextMode === RawMode.Raw) { @@ -667,7 +668,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp if (mode === RawMode.Raw) { // In raw mode, re-render all messages directly to stdout at the new width. // Use process.stdout.write instead of writeRef to avoid Ink interference. - process.stdout.write(ANSI_CLEAR_SCREEN); + writeStdoutLine(ANSI_CLEAR_SCREEN); const activeSessionId = sessionManager.getActiveSessionId(); const allMessages = activeSessionId ? loadVisibleMessages(sessionManager, activeSessionId) : []; renderRawModeMessages(allMessages, mode); @@ -898,7 +899,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp ); }} - {busy || statusLine ? : null} + {(busy || statusLine) && !isExiting ? : null} {errorLine ? ( Error: {errorLine} diff --git a/scripts/__tests__/generate-changelog.test.js b/scripts/__tests__/generate-changelog.test.js new file mode 100644 index 00000000..98d9ba9e --- /dev/null +++ b/scripts/__tests__/generate-changelog.test.js @@ -0,0 +1,241 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + categorize, + isNoiseEntry, + parseReleaseEntries, + formatEntry, + formatRelease, + buildChangelog, + toReleaseModel, + selectStableReleases, + parseJsonl, +} from "../generate-changelog.js"; + +// ── categorize ─────────────────────────────────────────────────────────────── + +test("categorize extracts type, scope, and description", () => { + const result = categorize("feat(core): add new feature"); + assert.deepEqual(result, { + type: "feat", + scope: "core", + description: "add new feature", + breaking: false, + }); +}); + +test("categorize handles no scope", () => { + const result = categorize("fix: resolve crash on startup"); + assert.deepEqual(result, { + type: "fix", + scope: null, + description: "resolve crash on startup", + breaking: false, + }); +}); + +test("categorize detects breaking change marker", () => { + const result = categorize("feat(api)!: change response format"); + assert.deepEqual(result, { + type: "feat", + scope: "api", + description: "change response format", + breaking: true, + }); +}); + +test("categorize returns raw description for non-conventional titles", () => { + const result = categorize("update readme"); + assert.deepEqual(result, { + type: null, + scope: null, + description: "update readme", + breaking: false, + }); +}); + +// ── isNoiseEntry ───────────────────────────────────────────────────────────── + +test("isNoiseEntry identifies release commits", () => { + assert.equal(isNoiseEntry({ type: "chore", scope: "release" }), true); +}); + +test("isNoiseEntry ignores non-release chores", () => { + assert.equal(isNoiseEntry({ type: "chore", scope: "deps" }), false); +}); + +test("isNoiseEntry ignores other types", () => { + assert.equal(isNoiseEntry({ type: "feat", scope: null }), false); +}); + +// ── parseReleaseEntries ────────────────────────────────────────────────────── + +test("parseReleaseEntries extracts entries from GitHub body", () => { + const body = [ + "* feat(ui): add dark mode by @alice in https://github.com/o/r/pull/42", + "* fix(core): resolve crash by @bob in https://github.com/o/r/pull/43", + "", + "**Full Changelog**: https://github.com/o/r/compare/v1.0.0...v1.1.0", + ].join("\n"); + + const entries = parseReleaseEntries(body); + assert.equal(entries.length, 2); + assert.equal(entries[0].title, "feat(ui): add dark mode"); + assert.equal(entries[0].author, "alice"); + assert.equal(entries[0].prNumber, "42"); + assert.equal(entries[1].title, "fix(core): resolve crash"); + assert.equal(entries[1].author, "bob"); +}); + +test("parseReleaseEntries handles entries without PR links (manual release notes)", () => { + const body = [ + "* chore(deps-dev): bump esbuild and tsx by @dependabot[bot] in https://github.com/o/r/pull/174", + "* chore: 优化deepcode-self-refer skill", + "* chore: 优化skill-digester skill", + ].join("\n"); + + const entries = parseReleaseEntries(body); + assert.equal(entries.length, 3); + // entry with PR link + assert.equal(entries[0].title, "chore(deps-dev): bump esbuild and tsx"); + assert.equal(entries[0].author, "dependabot[bot]"); + assert.equal(entries[0].prNumber, "174"); + // entries without PR link + assert.equal(entries[1].title, "chore: 优化deepcode-self-refer skill"); + assert.equal(entries[1].author, null); + assert.equal(entries[1].prUrl, null); + assert.equal(entries[1].prNumber, null); + assert.equal(entries[2].title, "chore: 优化skill-digester skill"); + assert.equal(entries[2].author, null); +}); + +test("parseReleaseEntries handles empty body", () => { + assert.deepEqual(parseReleaseEntries(""), []); + assert.deepEqual(parseReleaseEntries(null), []); +}); + +test("parseReleaseEntries handles bot authors", () => { + const body = "* chore(deps): bump x by @dependabot[bot] in https://github.com/o/r/pull/1"; + const entries = parseReleaseEntries(body); + assert.equal(entries.length, 1); + assert.equal(entries[0].author, "dependabot[bot]"); +}); + +// ── formatEntry ────────────────────────────────────────────────────────────── + +test("formatEntry renders known type with scope", () => { + const entry = { title: "feat(ui): add dark mode", prNumber: "42", prUrl: "https://github.com/o/r/pull/42" }; + assert.equal(formatEntry(entry), "- ui: add dark mode ([#42](https://github.com/o/r/pull/42))"); +}); + +test("formatEntry renders known type without scope", () => { + const entry = { title: "fix: resolve crash", prNumber: "10", prUrl: "https://github.com/o/r/pull/10" }; + assert.equal(formatEntry(entry), "- resolve crash ([#10](https://github.com/o/r/pull/10))"); +}); + +test("formatEntry renders unknown type verbatim", () => { + const entry = { title: "update readme", prNumber: "5", prUrl: "https://github.com/o/r/pull/5" }; + assert.equal(formatEntry(entry), "- update readme ([#5](https://github.com/o/r/pull/5))"); +}); + +test("formatEntry marks breaking changes", () => { + const entry = { title: "feat(api)!: change format", prNumber: "99", prUrl: "https://github.com/o/r/pull/99" }; + assert.match(formatEntry(entry), /\*\*BREAKING\*\*/); +}); + +test("formatEntry renders entry without PR link", () => { + const entry = { title: "chore: optimize skill", prNumber: null, prUrl: null }; + assert.equal(formatEntry(entry), "- chore: optimize skill"); +}); + +// ── formatRelease ──────────────────────────────────────────────────────────── + +test("formatRelease groups entries by section", () => { + const release = { + version: "1.1.0", + date: "2026-01-15", + htmlUrl: "https://github.com/o/r/releases/tag/v1.1.0", + entries: [ + { title: "feat(ui): add dark mode", author: "alice", prUrl: "https://github.com/o/r/pull/1", prNumber: "1" }, + { title: "fix: crash on start", author: "bob", prUrl: "https://github.com/o/r/pull/2", prNumber: "2" }, + { title: "chore(release): v1.1.0", author: "bot", prUrl: "https://github.com/o/r/pull/3", prNumber: "3" }, + ], + }; + + const md = formatRelease(release); + assert.match(md, /## \[1\.1\.0\]/); + assert.match(md, /### Added/); + assert.match(md, /### Fixed/); + assert.doesNotMatch(md, /chore\(release\)/); +}); + +// ── buildChangelog ─────────────────────────────────────────────────────────── + +test("buildChangelog produces valid markdown with header", () => { + const releases = [ + { + version: "1.0.0", + date: "2026-01-01", + htmlUrl: "https://github.com/o/r/releases/tag/v1.0.0", + entries: [ + { title: "feat: initial release", author: "dev", prUrl: "https://github.com/o/r/pull/1", prNumber: "1" }, + ], + }, + ]; + + const changelog = buildChangelog(releases); + assert.match(changelog, /# Changelog/); + assert.match(changelog, /## \[1\.0\.0\]/); + assert.match(changelog, /### Added/); + assert.ok(changelog.endsWith("\n")); +}); + +// ── toReleaseModel ─────────────────────────────────────────────────────────── + +test("toReleaseModel parses stable tag", () => { + const raw = { + tag: "v1.2.3", + date: "2026-03-15T10:00:00Z", + url: "https://github.com/o/r/releases/tag/v1.2.3", + body: "* feat: new thing by @user in https://github.com/o/r/pull/1", + }; + const model = toReleaseModel(raw); + assert.equal(model.version, "1.2.3"); + assert.equal(model.date, "2026-03-15"); + assert.equal(model.entries.length, 1); +}); + +test("toReleaseModel returns null version for unstable tag", () => { + const raw = { tag: "v1.0.0-beta.1", date: "2026-01-01", url: "", body: "" }; + const model = toReleaseModel(raw); + assert.equal(model.version, null); +}); + +// ── selectStableReleases ───────────────────────────────────────────────────── + +test("selectStableReleases filters out pre-releases and drafts", () => { + const raw = [ + { tag: "v1.2.0", date: "2026-03-01", prerelease: false, draft: false, url: "", body: "" }, + { tag: "v1.1.0-beta.1", date: "2026-02-01", prerelease: true, draft: false, url: "", body: "" }, + { tag: "v1.0.0", date: "2026-01-01", prerelease: false, draft: false, url: "", body: "" }, + { tag: "v2.0.0-draft", date: "2026-04-01", prerelease: false, draft: true, url: "", body: "" }, + ]; + const stable = selectStableReleases(raw); + assert.equal(stable.length, 2); + assert.equal(stable[0].version, "1.2.0"); + assert.equal(stable[1].version, "1.0.0"); +}); + +// ── parseJsonl ─────────────────────────────────────────────────────────────── + +test("parseJsonl parses newline-delimited JSON", () => { + const jsonl = '{"a":1}\n{"b":2}\n'; + const result = parseJsonl(jsonl); + assert.deepEqual(result, [{ a: 1 }, { b: 2 }]); +}); + +test("parseJsonl skips empty lines", () => { + const jsonl = '{"a":1}\n\n \n{"b":2}\n'; + const result = parseJsonl(jsonl); + assert.equal(result.length, 2); +}); diff --git a/scripts/generate-changelog.js b/scripts/generate-changelog.js new file mode 100644 index 00000000..29433eb1 --- /dev/null +++ b/scripts/generate-changelog.js @@ -0,0 +1,335 @@ +#!/usr/bin/env node + +/** + * Generate `CHANGELOG.md` from the project's GitHub Releases. + * + * The changelog only lists *stable* releases (`vX.Y.Z`); nightly and preview + * pre-releases are intentionally omitted. Each release's auto-generated + * "What's Changed" list is re-grouped into Keep a Changelog sections + * (Added / Changed / Fixed / ...) by the conventional-commit prefix used + * in PR titles. + * + * The file is fully derived from the GitHub Releases API, so it is safe to + * regenerate at any time and should not be edited by hand. + * + * Usage: + * node scripts/generate-changelog.js # write ./CHANGELOG.md + * node scripts/generate-changelog.js --dry-run # print to stdout instead + * node scripts/generate-changelog.js --repo=owner/name --output=path.md + * + * Requires the GitHub CLI (`gh`) to be installed and authenticated. + */ + +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, ".."); + +// ── Keep a Changelog sections ──────────────────────────────────────────────── + +const SECTIONS = [ + { name: "Added", types: ["feat"] }, + { name: "Changed", types: ["refactor", "revert"] }, + { name: "Fixed", types: ["fix"] }, + { name: "Performance", types: ["perf"] }, + { name: "Documentation", types: ["docs"] }, + { name: "Other", types: [] }, +]; + +const TYPE_TO_SECTION = Object.fromEntries( + SECTIONS.flatMap((section) => section.types.map((type) => [type, section.name])) +); + +const SECTION_ORDER = SECTIONS.map((section) => section.name); + +// ── Regex patterns ─────────────────────────────────────────────────────────── + +/** Matches a stable `vX.Y.Z` tag (no `-preview` / `-nightly` suffix). */ +const STABLE_TAG_RE = /^v?(\d+)\.(\d+)\.(\d+)$/; + +/** + * Matches a GitHub "What's Changed" bullet with a PR link, e.g. + * + * fix(core): do a thing by @octocat in https://github.com/o/r/pull/42 + */ +const ENTRY_RE = /^[*-]\s+(.+)\s+by\s+@([A-Za-z0-9-]+(?:\[bot\])?)\s+in\s+(https?:\/\/\S+\/pull\/(\d+))\s*$/; + +/** + * Matches a bullet without a PR link (manually added release entries), e.g. + * + * chore: optimize skill + */ +const SIMPLE_ENTRY_RE = /^[*-]\s+(.+?)\s*$/; + +// ── Pure helpers (exported for testing) ────────────────────────────────────── + +/** + * Split a conventional-commit subject into + * `{ type, scope, description, breaking }`. + */ +export function categorize(title) { + const match = /^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/.exec(title.trim()); + if (!match) { + return { type: null, scope: null, description: title.trim(), breaking: false }; + } + return { + type: match[1].toLowerCase(), + scope: match[2] || null, + description: match[4], + breaking: Boolean(match[3]), + }; +} + +/** Version-bump commits (`chore(release): …`) are noise in a user-facing changelog. */ +export function isNoiseEntry({ type, scope }) { + return type === "chore" && scope === "release"; +} + +/** Parse the "What's Changed" bullets out of a release body. */ +export function parseReleaseEntries(body) { + const entries = []; + for (const line of (body || "").split(/\r?\n/)) { + const match = ENTRY_RE.exec(line); + if (match) { + entries.push({ + title: match[1].trim(), + author: match[2], + prUrl: match[3], + prNumber: match[4], + }); + continue; + } + const simpleMatch = SIMPLE_ENTRY_RE.exec(line); + if (simpleMatch) { + entries.push({ + title: simpleMatch[1].trim(), + author: null, + prUrl: null, + prNumber: null, + }); + } + } + return entries; +} + +/** Render a single entry as a changelog list item. */ +export function formatEntry(entry, cat = categorize(entry.title)) { + const { type, scope, description, breaking } = cat; + let text; + if (TYPE_TO_SECTION[type]) { + text = scope ? `${scope}: ${description}` : description; + } else { + text = entry.title; + } + if (breaking) { + text = `**BREAKING** ${text}`; + } + if (entry.prNumber && entry.prUrl) { + return `- ${text} ([#${entry.prNumber}](${entry.prUrl}))`; + } + return `- ${text}`; +} + +/** Render one release as a Markdown block. */ +export function formatRelease(release) { + const lines = []; + const heading = release.htmlUrl ? `## [${release.version}](${release.htmlUrl})` : `## [${release.version}]`; + lines.push(heading, "", "`" + release.date + "`", ""); + + const buckets = new Map(); + for (const entry of release.entries) { + const cat = categorize(entry.title); + if (isNoiseEntry(cat)) continue; + const section = TYPE_TO_SECTION[cat.type] || "Other"; + if (!buckets.has(section)) buckets.set(section, []); + buckets.get(section).push(formatEntry(entry, cat)); + } + + let rendered = false; + for (const section of SECTION_ORDER) { + const items = buckets.get(section); + if (!items || items.length === 0) continue; + rendered = true; + lines.push(`### ${section}`, "", ...items, ""); + } + + if (!rendered) { + const link = release.htmlUrl ? `[GitHub release](${release.htmlUrl})` : "the GitHub release"; + lines.push(`_See ${link} for details._`, ""); + } + + return lines.join("\n"); +} + +// ── Changelog builder ──────────────────────────────────────────────────────── + +const HEADER = `# Changelog + +All notable changes to [Deep Code](https://github.com/lessweb/deepcode-cli) are +documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and the project follows +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). Only stable releases +are listed; pre-releases are intentionally omitted. + +> **This file is generated automatically** from +> [GitHub Releases](https://github.com/lessweb/deepcode-cli/releases). Do not +> edit it by hand — run \`npm run changelog\` to regenerate. +`; + +/** Build the full CHANGELOG.md contents from an ordered list of releases. */ +export function buildChangelog(releases) { + const blocks = releases.map((release) => formatRelease(release)); + const body = `${HEADER}\n${blocks.join("\n")}`; + return `${body.replace(/\n{3,}/g, "\n\n").replace(/\s+$/, "")}\n`; +} + +// ── GitHub Releases fetching ───────────────────────────────────────────────── + +/** Convert a raw GitHub Releases API object into our release model. */ +export function toReleaseModel(raw) { + const match = STABLE_TAG_RE.exec(raw.tag || ""); + return { + tag: raw.tag, + version: match ? `${match[1]}.${match[2]}.${match[3]}` : null, + date: (raw.date || "").slice(0, 10), + htmlUrl: raw.url || "", + entries: parseReleaseEntries(raw.body), + }; +} + +/** Keep only stable releases, newest first. */ +export function selectStableReleases(rawReleases) { + return rawReleases + .filter((raw) => !raw.prerelease && !raw.draft) + .map(toReleaseModel) + .filter((release) => release.version) + .sort((a, b) => { + const x = a.version.split(".").map(Number); + const y = b.version.split(".").map(Number); + return y[0] - x[0] || y[1] - x[1] || y[2] - x[2]; + }); +} + +/** Fetch every release (paginated) as newline-delimited JSON via the gh CLI. */ +function fetchReleasesJsonl(repo) { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) { + throw new Error(`Invalid repository "${repo}"; expected "owner/name".`); + } + return execFileSync( + "gh", + [ + "api", + `repos/${repo}/releases?per_page=100`, + "--paginate", + "--jq", + ".[] | {tag: .tag_name, date: .published_at, prerelease: .prerelease, draft: .draft, url: .html_url, body: .body}", + ], + { encoding: "utf-8", maxBuffer: 256 * 1024 * 1024 } + ); +} + +/** Parse newline-delimited JSON (one release object per line). */ +export function parseJsonl(jsonl) { + return jsonl + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +// ── CLI ────────────────────────────────────────────────────────────────────── + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf-8")); +} + +/** Resolve the default `owner/repo` from $GITHUB_REPOSITORY or package.json. */ +function getDefaultRepo() { + if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY; + const url = readJson(path.join(REPO_ROOT, "package.json"))?.repository?.url; + const match = /github\.com[/:]([^/]+\/[^/.]+)/.exec(url || ""); + return match ? match[1] : "lessweb/deepcode-cli"; +} + +/** Minimal arg parser — no external dependencies. */ +function parseArgs(argv) { + const result = { values: {}, positional: [] }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "-h" || arg === "--help") { + result.help = true; + } else if (arg === "--dry-run") { + result.values["dry-run"] = true; + } else if (arg.startsWith("--")) { + const eqIdx = arg.indexOf("="); + if (eqIdx !== -1) { + result.values[arg.slice(2, eqIdx)] = arg.slice(eqIdx + 1); + } else { + result.values[arg.slice(2)] = true; + } + } else { + result.positional.push(arg); + } + } + return result; +} + +const HELP = `Generate CHANGELOG.md from GitHub Releases. + +Usage: + node scripts/generate-changelog.js [options] + +Options: + --repo= Source repository (default: $GITHUB_REPOSITORY or package.json). + --output= Output file (default: ./CHANGELOG.md). + --dry-run Print to stdout instead of writing the file. + -h, --help Show this help. +`; + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + process.stdout.write(HELP); + return; + } + + const repo = args.values.repo || getDefaultRepo(); + const output = args.values.output || path.join(REPO_ROOT, "CHANGELOG.md"); + + let rawReleases; + try { + rawReleases = parseJsonl(fetchReleasesJsonl(repo)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("ENOENT") || message.includes("gh: not found")) { + console.error("ERROR: GitHub CLI (`gh`) is not installed. Install it from https://cli.github.com/"); + } else { + console.error(`ERROR: Failed to fetch releases: ${message}`); + } + process.exit(1); + } + const releases = selectStableReleases(rawReleases); + if (releases.length === 0) { + console.error(`ERROR: no stable releases found for ${repo}; refusing to overwrite ${path.basename(output)}.`); + process.exit(1); + } + const changelog = buildChangelog(releases); + + if (args.values["dry-run"]) { + process.stdout.write(changelog); + return; + } + + writeFileSync(output, changelog); + console.error(`Wrote ${releases.length} stable releases to ${path.relative(process.cwd(), output)}`); +} + +const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); +if (isMainModule) { + main(); +} diff --git a/scripts/pre-commit.js b/scripts/pre-commit.js new file mode 100644 index 00000000..4c0b37a7 --- /dev/null +++ b/scripts/pre-commit.js @@ -0,0 +1,25 @@ +/** + * pre-commit hook 脚本 + * + * 在 git commit 前自动执行 lint-staged,对暂存区的文件进行: + * - ESLint 检查并自动修复(*.{ts,tsx,js,mjs,cjs,jsx}) + * - Prettier 格式化(*.{ts,tsx,js,mjs,cjs,jsx,json} 及 .prettierrc) + * + * 由 .husky/pre-commit 调用,lint-staged 失败时会阻止提交。 + */ + +import { execSync } from "node:child_process"; +import lintStaged from "lint-staged"; + +try { + // 获取 git 仓库根目录,作为 lint-staged 的工作目录 + const root = execSync("git rev-parse --show-toplevel").toString().trim(); + + // 通过 lint-staged API 直接运行,仅处理暂存区文件 + const passed = await lintStaged({ cwd: root }); + + // lint-staged 全部通过则正常退出,否则以失败码退出阻止提交 + process.exit(passed ? 0 : 1); +} catch { + process.exit(1); +}