qtcloud-devops-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: qtcloud-devops-cli
3
+ Version: 0.1.0
4
+ Summary: DevOps CLI — 发布管理
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: typer
7
+ Requires-Dist: pydantic-settings
@@ -0,0 +1,65 @@
1
+ # qtcloud-devops-cli
2
+
3
+ DevOps CLI — 发布管理、契约检查与工作流自动化。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ cd apps/qtcloud-devops
9
+ pip install -e src/cli
10
+ ```
11
+
12
+ 依赖:Python 3.12+,包管理器 uv。
13
+
14
+ ## 项目结构
15
+
16
+ ```
17
+ src/cli/
18
+ ├── app/
19
+ │ ├── __init__.py
20
+ │ ├── cli.py # Typer CLI 入口
21
+ │ ├── config.py # pydantic-settings 配置
22
+ │ ├── contract.py # 契约加载与查询
23
+ │ └── release.py # 发布 Release 逻辑
24
+ ├── tests/
25
+ │ ├── __init__.py
26
+ │ ├── conftest.py
27
+ │ ├── test_cli_commands.py
28
+ │ ├── test_release.py
29
+ │ └── test_contract.py
30
+ ├── pyproject.toml
31
+ ├── AGENTS.md
32
+ ├── CHANGELOG.md
33
+ ├── ROADMAP.md
34
+ └── .gitignore
35
+ ```
36
+
37
+ ## 用法
38
+
39
+ ```bash
40
+ # 标签 + GitHub Release
41
+ qtcloud-devops release --version v0.1.0
42
+
43
+ # 仅标签
44
+ qtcloud-devops release --version v0.1.0 --tag-only
45
+
46
+ # 仅为已有标签补 GitHub Release
47
+ qtcloud-devops release --version v0.1.0 --release-only
48
+
49
+ # 仅预检查
50
+ qtcloud-devops release --version v0.1.0 --dry-run
51
+ ```
52
+
53
+ ## 发布流程
54
+
55
+ 1. **更新版本号** → 改 `pyproject.toml`
56
+ 2. **写 CHANGELOG** → 更新 `CHANGELOG.md`
57
+ 3. **提交** → `chore: bump qtcloud-devops-cli to vX.Y.Z`
58
+ 4. **打 tag** → `cli/vX.Y.Z`
59
+ 5. **推送** → CI 自动发布
60
+
61
+ ## 命名约定
62
+
63
+ - 包名(PyPI): `qtcloud-devops-cli`
64
+ - 导入名: `app.*`
65
+ - 仓库 tag 前缀: `cli/`
File without changes
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python3
2
+ """qtcloud-devops-cli — DevOps 发布管理命令行工具。
3
+
4
+ 基于 devops-release 技能,提供预检查、发布前确认、执行发布、验证、回滚的全流程自动化。
5
+
6
+ 用法:
7
+ qtcloud-devops release --version v0.1.0 # 标签 + GitHub Release
8
+ qtcloud-devops release --version v0.1.0 --tag-only # 仅创建 Git 标签
9
+ qtcloud-devops release --version v0.1.0 --release-only # 仅创建 GitHub Release
10
+ qtcloud-devops release --version v0.1.0 --dry-run # 仅检查
11
+ qtcloud-devops release --version v0.1.0 -y # 跳过确认
12
+ """
13
+
14
+ import typer
15
+
16
+ app = typer.Typer()
17
+
18
+
19
+ @app.callback()
20
+ def main_callback() -> None: ...
21
+
22
+
23
+ @app.command()
24
+ def release(
25
+ version: str = typer.Option(..., "--version", "-V", help="版本号(如 v0.1.0)"),
26
+ changelog: str = typer.Option(
27
+ "CHANGELOG.md", "--changelog", help="CHANGELOG.md 路径"
28
+ ),
29
+ dry_run: bool = typer.Option(False, "--dry-run", help="仅检查,不执行"),
30
+ tag_only: bool = typer.Option(
31
+ False, "--tag-only", help="仅创建 Git 标签(默认行为,显式声明意图)"
32
+ ),
33
+ release_only: bool = typer.Option(
34
+ False, "--release-only", help="仅创建 GitHub Release,跳过 Git 标签"
35
+ ),
36
+ yes: bool = typer.Option(False, "--yes", "-y", help="跳过确认提示,直接发布"),
37
+ ):
38
+ """发布 Release。
39
+
40
+ 默认行为:创建 Git 标签并推送 + GitHub Release(仓库从 git remote 自动检测)。
41
+ --tag-only:仅打标签,跳过 GitHub Release。
42
+ --release-only:仅为已有标签创建 GitHub Release(跳过标签创建)。
43
+ """
44
+ from pathlib import Path
45
+
46
+ from app.release import run
47
+
48
+ code = run(version, Path(changelog), dry_run, tag_only, release_only, yes)
49
+ raise typer.Exit(code=code)
50
+
51
+
52
+ def main():
53
+ return app()
54
+
55
+
56
+ if __name__ == "__main__": # pragma: no cover
57
+ exit(main())
@@ -0,0 +1,8 @@
1
+ from pydantic_settings import BaseSettings
2
+
3
+
4
+ class Settings(BaseSettings):
5
+ model_config = {"env_prefix": "QTCLOUD_DEVOPS_"}
6
+
7
+
8
+ settings = Settings()
@@ -0,0 +1,253 @@
1
+ import re
2
+ import subprocess
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+
7
+ def get_remote_repo() -> Optional[str]:
8
+ """从 git remote 解析 owner/name。"""
9
+ result = subprocess.run(
10
+ ["git", "remote", "get-url", "origin"],
11
+ capture_output=True,
12
+ text=True,
13
+ )
14
+ if result.returncode != 0:
15
+ return None
16
+ url = result.stdout.strip()
17
+ m = re.search(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$", url)
18
+ if m:
19
+ return m.group(1)
20
+ return None
21
+
22
+
23
+ def precheck(version: str, changelog: Path, release_only: bool = False) -> list[str]:
24
+ errors = []
25
+
26
+ if not re.match(
27
+ r"^(v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?|[a-zA-Z0-9_.-]+/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?)$",
28
+ version,
29
+ ):
30
+ errors.append(f"版本号格式错误: {version}")
31
+
32
+ if changelog.exists():
33
+ content = changelog.read_text(encoding="utf-8")
34
+ ver = version.split("/v", 1)[1] if "/v" in version else version.lstrip("v")
35
+ if f"## [{ver}]" not in content:
36
+ errors.append(f"CHANGELOG.md 未找到 {ver} 版本记录")
37
+ else:
38
+ errors.append(f"CHANGELOG.md 不存在: {changelog}")
39
+
40
+ if release_only:
41
+ result = subprocess.run(
42
+ ["git", "tag", "-l"],
43
+ capture_output=True,
44
+ text=True,
45
+ )
46
+ existing_tags = result.stdout.strip().split("\n")
47
+ if version not in existing_tags:
48
+ errors.append(f"标签不存在: {version}(--release-only 需要标签已存在)")
49
+
50
+ result = subprocess.run(
51
+ ["git", "status", "--porcelain"],
52
+ capture_output=True,
53
+ text=True,
54
+ )
55
+ if result.stdout.strip():
56
+ errors.append("工作区有未提交的变更")
57
+
58
+ result = subprocess.run(
59
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
60
+ capture_output=True,
61
+ text=True,
62
+ )
63
+ branch = result.stdout.strip()
64
+ if not branch.startswith(("main", "master", "release/")):
65
+ errors.append(
66
+ f"不在可发布分支上 (当前: {branch}),请切换到 main/master/release/*"
67
+ )
68
+
69
+ return errors
70
+
71
+
72
+ def extract_notes(version: str, changelog: Path) -> Optional[str]:
73
+ ver = version.split("/v", 1)[1] if "/v" in version else version.lstrip("v")
74
+ content = changelog.read_text(encoding="utf-8")
75
+ lines = content.split("\n")
76
+ capture = False
77
+ notes: list[str] = []
78
+ for line in lines:
79
+ if line.startswith(f"## [{ver}]"):
80
+ capture = True
81
+ continue
82
+ if capture:
83
+ if line.startswith("## ["):
84
+ break
85
+ notes.append(line)
86
+ text = "\n".join(notes).strip()
87
+ return text if text else None
88
+
89
+
90
+ def confirm_release(version: str, notes: Optional[str], yes: bool = False) -> bool:
91
+ """🧠 AI 介入点:展示检查摘要并等待用户确认"""
92
+ print(f"\n发布版本: {version}")
93
+ print()
94
+ print("检查结果:")
95
+ print(" ✓ 预检查全部通过")
96
+ print()
97
+ print("Release Notes 预览:")
98
+ print(notes or "(空)")
99
+ print()
100
+
101
+ if yes:
102
+ return True
103
+
104
+ try:
105
+ response = input("确认发布? (y/N): ").strip().lower()
106
+ return response in ("y", "yes")
107
+ except (EOFError, KeyboardInterrupt):
108
+ return False
109
+
110
+
111
+ def create_tag(version: str) -> bool:
112
+ result = subprocess.run(
113
+ ["git", "tag", version],
114
+ capture_output=True,
115
+ text=True,
116
+ )
117
+ if result.returncode != 0:
118
+ print(f"创建标签失败: {result.stderr.strip()}")
119
+ return False
120
+ return True
121
+
122
+
123
+ def push_tag(version: str) -> bool:
124
+ result = subprocess.run(
125
+ ["git", "push", "origin", version],
126
+ capture_output=True,
127
+ text=True,
128
+ )
129
+ if result.returncode != 0:
130
+ print(f"推送标签失败: {result.stderr.strip()}")
131
+ return False
132
+ return True
133
+
134
+
135
+ def create_release(version: str, notes: str, repo: str) -> bool:
136
+ result = subprocess.run(
137
+ [
138
+ "gh",
139
+ "release",
140
+ "create",
141
+ version,
142
+ "--title",
143
+ version,
144
+ "--notes",
145
+ notes,
146
+ "--repo",
147
+ repo,
148
+ ],
149
+ capture_output=True,
150
+ text=True,
151
+ )
152
+ if result.returncode != 0:
153
+ print(f"创建 Release 失败: {result.stderr.strip()}")
154
+ return False
155
+ return True
156
+
157
+
158
+ def verify_release(version: str, repo: str) -> bool:
159
+ """🤖 规则:发布后验证"""
160
+ result = subprocess.run(
161
+ ["gh", "release", "view", version, "--repo", repo],
162
+ capture_output=True,
163
+ text=True,
164
+ )
165
+ if result.returncode != 0:
166
+ print(f"⚠ 验证 Release 失败: {result.stderr.strip()}")
167
+ return False
168
+ print(result.stdout.strip())
169
+ return True
170
+
171
+
172
+ def rollback_tag(version: str) -> None:
173
+ """🧠 AI 介入点:标签已推送但后续失败时回滚"""
174
+ subprocess.run(["git", "tag", "-d", version], capture_output=True)
175
+ subprocess.run(
176
+ ["git", "push", "origin", "--delete", version],
177
+ capture_output=True,
178
+ text=True,
179
+ )
180
+ print(f"↻ 标签 {version} 已回滚")
181
+
182
+
183
+ def run(
184
+ version: str,
185
+ changelog: Optional[Path] = None,
186
+ dry_run: bool = False,
187
+ tag_only: bool = False,
188
+ release_only: bool = False,
189
+ yes: bool = False,
190
+ ):
191
+ changelog = changelog or Path.cwd() / "CHANGELOG.md"
192
+
193
+ if tag_only and release_only:
194
+ print("错误: --tag-only 和 --release-only 不能同时使用")
195
+ return 1
196
+
197
+ # --- 预检查 ---
198
+ errors = precheck(version, changelog, release_only=release_only)
199
+ if errors:
200
+ print("预检查失败:")
201
+ for err in errors:
202
+ print(f" ✗ {err}")
203
+ return 1
204
+
205
+ notes = extract_notes(version, changelog)
206
+ print(f"\n=== Release Notes 预览 ===")
207
+ print(notes or "(空)")
208
+ print("=========================\n")
209
+
210
+ if dry_run:
211
+ print("✓ 预检查通过 (dry-run 模式,不执行)")
212
+ return 0
213
+
214
+ # --- 发布前确认 ---
215
+ if not confirm_release(version, notes, yes=yes):
216
+ print("已取消发布")
217
+ return 0
218
+
219
+ # --- 执行发布 ---
220
+ tag_created = False
221
+
222
+ if not release_only:
223
+ result = subprocess.run(
224
+ ["git", "tag", "-l"],
225
+ capture_output=True, text=True,
226
+ )
227
+ tag_exists = version in result.stdout.strip().split("\n")
228
+ if tag_exists:
229
+ print(f"→ 标签 {version} 已存在,跳过 tag 创建")
230
+ else:
231
+ if not create_tag(version):
232
+ return 1
233
+ if not push_tag(version):
234
+ rollback_tag(version)
235
+ return 1
236
+ tag_created = True
237
+ print(f"✓ 标签 {version} 已创建并推送")
238
+
239
+ if not tag_only:
240
+ repo = get_remote_repo()
241
+ if not repo:
242
+ print("错误: 无法从 git remote 解析 GitHub 仓库")
243
+ if tag_created:
244
+ rollback_tag(version)
245
+ return 1
246
+ if not create_release(version, notes or "", repo):
247
+ if tag_created:
248
+ rollback_tag(version)
249
+ return 1
250
+ print(f"✓ GitHub Release {version} 已创建")
251
+ print(f" https://github.com/{repo}/releases/tag/{version}")
252
+
253
+ return 0
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [tool.setuptools.packages.find]
6
+ include = ["app*"]
7
+ exclude = ["tests*"]
8
+
9
+ [project]
10
+ name = "qtcloud-devops-cli"
11
+ version = "0.1.0"
12
+ description = "DevOps CLI — 发布管理"
13
+ requires-python = ">=3.12"
14
+ dependencies = [
15
+ "typer",
16
+ "pydantic-settings",
17
+ ]
18
+
19
+ [project.scripts]
20
+ qtcloud-devops = "app.cli:main"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "pytest>=9.0.3",
25
+ "pytest-cov>=7.1.0",
26
+ ]
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: qtcloud-devops-cli
3
+ Version: 0.1.0
4
+ Summary: DevOps CLI — 发布管理
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: typer
7
+ Requires-Dist: pydantic-settings
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ app/__init__.py
4
+ app/cli.py
5
+ app/config.py
6
+ app/release.py
7
+ qtcloud_devops_cli.egg-info/PKG-INFO
8
+ qtcloud_devops_cli.egg-info/SOURCES.txt
9
+ qtcloud_devops_cli.egg-info/dependency_links.txt
10
+ qtcloud_devops_cli.egg-info/entry_points.txt
11
+ qtcloud_devops_cli.egg-info/requires.txt
12
+ qtcloud_devops_cli.egg-info/top_level.txt
13
+ tests/test_cli_commands.py
14
+ tests/test_config.py
15
+ tests/test_release.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ qtcloud-devops = app.cli:main
@@ -0,0 +1,2 @@
1
+ typer
2
+ pydantic-settings
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,62 @@
1
+ from pathlib import Path
2
+ from unittest.mock import MagicMock
3
+ from typer.testing import CliRunner
4
+ from app.cli import app
5
+
6
+ runner = CliRunner()
7
+
8
+
9
+ def test_help():
10
+ result = runner.invoke(app, ["--help"])
11
+ assert result.exit_code == 0
12
+ assert "release" in result.stdout
13
+
14
+
15
+ def test_release_help():
16
+ result = runner.invoke(app, ["release", "--help"])
17
+ assert result.exit_code == 0
18
+ assert "--version" in result.stdout
19
+
20
+
21
+ def test_release_invalid_version():
22
+ result = runner.invoke(app, ["release", "--version", "invalid"])
23
+ assert result.exit_code != 0
24
+
25
+
26
+ def test_release_dry_run(monkeypatch):
27
+ changelog = Path("/tmp/test_release_dry_run_CHANGELOG.md")
28
+ changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
29
+
30
+ def mock_run(cmd, **kw):
31
+ if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]:
32
+ return MagicMock(returncode=0, stdout="main\n")
33
+ return MagicMock(returncode=0, stdout="")
34
+ monkeypatch.setattr("app.release.subprocess.run", mock_run)
35
+ result = runner.invoke(app, [
36
+ "release", "--version", "v0.1.0",
37
+ "--changelog", str(changelog), "--dry-run",
38
+ ])
39
+ assert result.exit_code == 0
40
+ changelog.unlink()
41
+
42
+
43
+ def test_main_help(monkeypatch):
44
+ from app.cli import main
45
+ monkeypatch.setattr("sys.argv", ["app.cli", "--help"])
46
+ try:
47
+ main()
48
+ except SystemExit as e:
49
+ assert e.code == 0
50
+
51
+
52
+ def test_main_module_entry():
53
+ import subprocess
54
+ import sys
55
+ root = Path(__file__).resolve().parent.parent
56
+ result = subprocess.run(
57
+ [sys.executable, "-m", "app.cli", "--help"],
58
+ capture_output=True, text=True,
59
+ cwd=str(root),
60
+ )
61
+ assert result.returncode == 0
62
+ assert "release" in result.stdout
@@ -0,0 +1,12 @@
1
+ from pydantic_settings import BaseSettings
2
+ from app.config import Settings, settings
3
+
4
+
5
+ def test_settings_is_basemodel():
6
+ assert isinstance(settings, BaseSettings)
7
+
8
+
9
+ def test_settings_env_prefix(monkeypatch):
10
+ monkeypatch.setenv("QTCLOUD_DEVOPS_FOO", "bar")
11
+ s = Settings()
12
+ assert hasattr(s, "model_config")
@@ -0,0 +1,446 @@
1
+ from pathlib import Path
2
+ from unittest.mock import MagicMock
3
+
4
+ from app.release import (
5
+ precheck, extract_notes, confirm_release,
6
+ create_tag, push_tag, create_release, verify_release,
7
+ rollback_tag, run, get_remote_repo,
8
+ )
9
+
10
+
11
+ def mock_subprocess(commands, default=None):
12
+ if default is None:
13
+ default = MagicMock(returncode=0, stdout="")
14
+ def _mock(cmd, **kw):
15
+ key = tuple(cmd)
16
+ if key in commands:
17
+ return commands[key]
18
+ return default
19
+ return _mock
20
+
21
+
22
+ def git_precheck_ok():
23
+ return mock_subprocess({
24
+ ("git", "tag", "-l"): MagicMock(stdout=""),
25
+ ("git", "status", "--porcelain"): MagicMock(stdout=""),
26
+ ("git", "rev-parse", "--abbrev-ref", "HEAD"): MagicMock(stdout="main\n"),
27
+ })
28
+
29
+
30
+ def test_precheck_invalid_version():
31
+ errors = precheck("invalid", Path("CHANGELOG.md"))
32
+ assert any("版本号格式错误" in e for e in errors)
33
+
34
+
35
+ def test_precheck_changelog_not_found():
36
+ errors = precheck("v0.1.0", Path("/nonexistent/CHANGELOG.md"))
37
+ assert any("不存在" in e for e in errors)
38
+
39
+
40
+ def test_precheck_default_does_not_check_tag_exists(monkeypatch):
41
+ """默认模式不检查标签是否存在"""
42
+ monkeypatch.setattr("app.release.subprocess.run", mock_subprocess({
43
+ ("git", "status", "--porcelain"): MagicMock(stdout=""),
44
+ ("git", "rev-parse", "--abbrev-ref", "HEAD"): MagicMock(stdout="main\n"),
45
+ }, default=MagicMock(returncode=0, stdout="")))
46
+ errors = precheck("v0.1.0", Path("/tmp/CHANGELOG.md"))
47
+ assert all("标签" not in e for e in errors)
48
+
49
+
50
+ def test_precheck_dirty_workspace(monkeypatch):
51
+ monkeypatch.setattr("app.release.subprocess.run", mock_subprocess({
52
+ ("git", "tag", "-l"): MagicMock(stdout=""),
53
+ ("git", "status", "--porcelain"): MagicMock(stdout=" M CHANGELOG.md\n"),
54
+ ("git", "rev-parse", "--abbrev-ref", "HEAD"): MagicMock(stdout="main\n"),
55
+ }))
56
+ errors = precheck("v0.2.0", Path("/tmp/CHANGELOG.md"))
57
+ assert any("工作区有未提交的变更" in e for e in errors)
58
+
59
+
60
+ def test_precheck_wrong_branch(monkeypatch):
61
+ monkeypatch.setattr("app.release.subprocess.run", mock_subprocess({
62
+ ("git", "tag", "-l"): MagicMock(stdout=""),
63
+ ("git", "status", "--porcelain"): MagicMock(stdout=""),
64
+ ("git", "rev-parse", "--abbrev-ref", "HEAD"): MagicMock(stdout="feature/foo\n"),
65
+ }))
66
+ errors = precheck("v0.2.0", Path("/tmp/CHANGELOG.md"))
67
+ assert any("不在可发布分支上" in e for e in errors)
68
+
69
+
70
+ def test_precheck_no_changelog_content(monkeypatch):
71
+ monkeypatch.setattr("app.release.subprocess.run", git_precheck_ok())
72
+ changelog = Path("/tmp/test_precheck_no_content.md")
73
+ changelog.write_text("# CHANGELOG\n\n## [0.2.0]\n\n内容\n")
74
+ errors = precheck("v9.9.9", changelog)
75
+ assert any("未找到" in e for e in errors)
76
+ changelog.unlink()
77
+
78
+
79
+ def test_precheck_all_pass(monkeypatch):
80
+ monkeypatch.setattr("app.release.subprocess.run", git_precheck_ok())
81
+ changelog = Path("/tmp/test_precheck_pass.md")
82
+ changelog.write_text("# CHANGELOG\n\n## [0.2.0]\n\n内容\n")
83
+ errors = precheck("v0.2.0", changelog)
84
+ assert len(errors) == 0
85
+ changelog.unlink()
86
+
87
+
88
+ def test_extract_notes():
89
+ changelog = Path("/tmp/test_changelog.md")
90
+ changelog.write_text("# CHANGELOG\n\n## [0.1.0] - 2026-01-01\n\n初始版本。\n\n### Added\n\n- 功能 A\n- 功能 B\n")
91
+ notes = extract_notes("v0.1.0", changelog)
92
+ assert "功能 A" in notes
93
+ assert "功能 B" in notes
94
+ changelog.unlink()
95
+
96
+
97
+ def test_extract_notes_multiple_sections():
98
+ changelog = Path("/tmp/test_changelog_multi.md")
99
+ changelog.write_text("# CHANGELOG\n\n## [0.2.0] - 2026-02-01\n\n版本 0.2.0 内容。\n\n## [0.1.0] - 2026-01-01\n\n初始版本。\n\n### Added\n\n- 功能 A\n")
100
+ notes = extract_notes("v0.2.0", changelog)
101
+ assert "版本 0.2.0" in notes
102
+ assert "功能 A" not in notes
103
+ changelog.unlink()
104
+
105
+
106
+ def test_extract_notes_not_found():
107
+ changelog = Path("/tmp/test_changelog_empty.md")
108
+ changelog.write_text("# CHANGELOG\n\n## [0.2.0]\n\n其他版本\n")
109
+ notes = extract_notes("v9.9.9", changelog)
110
+ assert notes is None
111
+ changelog.unlink()
112
+
113
+
114
+ def test_confirm_release_yes_flag():
115
+ assert confirm_release("v0.1.0", "some notes", yes=True) is True
116
+
117
+
118
+ def test_confirm_release_no_input(monkeypatch):
119
+ monkeypatch.setattr("builtins.input", lambda _: "n")
120
+ assert confirm_release("v0.1.0", "some notes") is False
121
+
122
+
123
+ def test_confirm_release_yes_input(monkeypatch):
124
+ monkeypatch.setattr("builtins.input", lambda _: "y")
125
+ assert confirm_release("v0.1.0", "some notes") is True
126
+
127
+
128
+ def test_confirm_release_eof(monkeypatch):
129
+ monkeypatch.setattr("builtins.input", lambda _: (_ for _ in ()).throw(EOFError()))
130
+ assert confirm_release("v0.1.0", "some notes") is False
131
+
132
+
133
+ def test_confirm_release_keyboard_interrupt(monkeypatch):
134
+ monkeypatch.setattr("builtins.input", lambda _: (_ for _ in ()).throw(KeyboardInterrupt()))
135
+ assert confirm_release("v0.1.0", "some notes") is False
136
+
137
+
138
+ def test_create_tag_success(monkeypatch):
139
+ monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: MagicMock(returncode=0))
140
+ assert create_tag("v0.1.0") is True
141
+
142
+
143
+ def test_create_tag_failure(monkeypatch):
144
+ monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: MagicMock(returncode=1, stderr="错误"))
145
+ assert create_tag("v0.1.0") is False
146
+
147
+
148
+ def test_push_tag_success(monkeypatch):
149
+ monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: MagicMock(returncode=0))
150
+ assert push_tag("v0.1.0") is True
151
+
152
+
153
+ def test_push_tag_failure(monkeypatch):
154
+ monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: MagicMock(returncode=1, stderr="错误"))
155
+ assert push_tag("v0.1.0") is False
156
+
157
+
158
+ def test_create_release_success(monkeypatch):
159
+ monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: MagicMock(returncode=0))
160
+ assert create_release("v0.1.0", "notes", "quanttide/repo") is True
161
+
162
+
163
+ def test_create_release_failure(monkeypatch):
164
+ monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: MagicMock(returncode=1, stderr="错误"))
165
+ assert create_release("v0.1.0", "notes", "quanttide/repo") is False
166
+
167
+
168
+ def test_verify_release_success(monkeypatch):
169
+ mock = MagicMock(returncode=0, stdout="Release v0.1.0\nURL: ...")
170
+ monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: mock)
171
+ assert verify_release("v0.1.0", "quanttide/repo") is True
172
+
173
+
174
+ def test_verify_release_failure(monkeypatch):
175
+ mock = MagicMock(returncode=1, stderr="not found")
176
+ monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: mock)
177
+ assert verify_release("v0.1.0", "quanttide/repo") is False
178
+
179
+
180
+ def test_rollback_tag(monkeypatch):
181
+ calls = []
182
+ monkeypatch.setattr(
183
+ "app.release.subprocess.run",
184
+ lambda cmd, **kw: calls.append(cmd) or MagicMock(stdout=""),
185
+ )
186
+ rollback_tag("v0.1.0")
187
+ assert calls[0] == ["git", "tag", "-d", "v0.1.0"]
188
+ assert calls[1] == ["git", "push", "origin", "--delete", "v0.1.0"]
189
+
190
+
191
+ def test_get_remote_repo_ssh(monkeypatch):
192
+ monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: MagicMock(
193
+ returncode=0, stdout="git@github.com:quanttide/quanttide-platform.git\n"
194
+ ))
195
+ assert get_remote_repo() == "quanttide/quanttide-platform"
196
+
197
+
198
+ def test_get_remote_repo_https(monkeypatch):
199
+ monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: MagicMock(
200
+ returncode=0, stdout="https://github.com/quanttide/quanttide-platform\n"
201
+ ))
202
+ assert get_remote_repo() == "quanttide/quanttide-platform"
203
+
204
+
205
+ def test_get_remote_repo_failure(monkeypatch):
206
+ monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: MagicMock(returncode=1, stderr=""))
207
+ assert get_remote_repo() is None
208
+
209
+
210
+ def test_run_dry_run(monkeypatch):
211
+ monkeypatch.setattr("app.release.subprocess.run", git_precheck_ok())
212
+ changelog = Path("/tmp/test_run_dry.md")
213
+ changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
214
+ assert run("v0.1.0", changelog, dry_run=True) == 0
215
+ changelog.unlink()
216
+
217
+
218
+ def test_run_dry_run_with_errors():
219
+ assert run("invalid", Path("/nonexistent"), dry_run=True) == 1
220
+
221
+
222
+ def test_run_cancelled(monkeypatch):
223
+ monkeypatch.setattr("app.release.subprocess.run", git_precheck_ok())
224
+ monkeypatch.setattr("builtins.input", lambda _: "n")
225
+ changelog = Path("/tmp/test_run_cancel.md")
226
+ changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
227
+ assert run("v0.1.0", changelog) == 0
228
+ changelog.unlink()
229
+
230
+
231
+ def test_run_create_tag_failure(monkeypatch):
232
+ monkeypatch.setattr("app.release.subprocess.run", mock_subprocess({
233
+ ("git", "tag", "-l"): MagicMock(stdout=""),
234
+ ("git", "tag", "v0.1.0"): MagicMock(returncode=1, stderr="错误"),
235
+ ("git", "rev-parse", "--abbrev-ref", "HEAD"): MagicMock(returncode=0, stdout="main\n"),
236
+ }, default=MagicMock(returncode=0, stdout="")))
237
+ changelog = Path("/tmp/test_run_tag_fail.md")
238
+ changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
239
+ assert run("v0.1.0", changelog, yes=True) == 1
240
+ changelog.unlink()
241
+
242
+
243
+ def test_run_push_tag_failure_triggers_rollback(monkeypatch):
244
+ calls = []
245
+ def recorder(cmd, **kw):
246
+ calls.append(cmd)
247
+ if cmd == ["git", "tag", "-l"]:
248
+ return MagicMock(returncode=0, stdout="")
249
+ if cmd == ["git", "push", "origin", "v0.1.0"]:
250
+ return MagicMock(returncode=1, stderr="错误")
251
+ if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]:
252
+ return MagicMock(returncode=0, stdout="main\n")
253
+ return MagicMock(returncode=0, stdout="")
254
+
255
+ monkeypatch.setattr("app.release.subprocess.run", recorder)
256
+ changelog = Path("/tmp/test_run_push_fail.md")
257
+ changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
258
+ assert run("v0.1.0", changelog, yes=True) == 1
259
+ assert ["git", "tag", "-d", "v0.1.0"] in calls
260
+ assert ["git", "push", "origin", "--delete", "v0.1.0"] in calls
261
+ changelog.unlink()
262
+
263
+
264
+ def test_run_tag_only_explicit(monkeypatch):
265
+ """--tag-only 显式声明,只打标签不发 release"""
266
+ changelog = Path("/tmp/test_run_tag_only_explicit.md")
267
+ changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
268
+
269
+ calls = []
270
+ def recorder(cmd, **kw):
271
+ calls.append(cmd)
272
+ if cmd == ["git", "tag", "-l"]:
273
+ return MagicMock(returncode=0, stdout="")
274
+ if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]:
275
+ return MagicMock(returncode=0, stdout="main\n")
276
+ return MagicMock(returncode=0, stdout="")
277
+
278
+ monkeypatch.setattr("app.release.subprocess.run", recorder)
279
+ assert run("v0.1.0", changelog, tag_only=True, yes=True) == 0
280
+ assert ["git", "tag", "v0.1.0"] in calls
281
+ assert not any("create" in c for c in calls)
282
+ changelog.unlink()
283
+
284
+
285
+ def test_run_tag_and_release_only_mutually_exclusive():
286
+ assert run("v0.1.0", Path("/tmp"), tag_only=True, release_only=True) == 1
287
+
288
+
289
+ def test_run_default_creates_tag_and_release(monkeypatch):
290
+ """默认行为:标签 + GitHub Release"""
291
+ changelog = Path("/tmp/test_run_default.md")
292
+ changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
293
+
294
+ calls = []
295
+ def recorder(cmd, **kw):
296
+ calls.append(cmd)
297
+ if cmd == ["git", "tag", "-l"]:
298
+ return MagicMock(returncode=0, stdout="")
299
+ if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]:
300
+ return MagicMock(returncode=0, stdout="main\n")
301
+ if cmd == ["git", "remote", "get-url", "origin"]:
302
+ return MagicMock(returncode=0, stdout="git@github.com:quanttide/repo.git\n")
303
+ if "view" in str(cmd):
304
+ return MagicMock(returncode=0, stdout="Release v0.1.0\n")
305
+ return MagicMock(returncode=0, stdout="")
306
+
307
+ monkeypatch.setattr("app.release.subprocess.run", recorder)
308
+ assert run("v0.1.0", changelog, yes=True) == 0
309
+ assert ["git", "tag", "v0.1.0"] in calls
310
+ assert any("create" in c for c in calls)
311
+ changelog.unlink()
312
+
313
+
314
+ def test_run_default_skips_tag_if_exists(monkeypatch):
315
+ """默认模式:标签已存在时跳过 tag 创建,继续发 release"""
316
+ changelog = Path("/tmp/test_run_default_tag_exists.md")
317
+ changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
318
+
319
+ calls = []
320
+ def recorder(cmd, **kw):
321
+ calls.append(cmd)
322
+ if cmd == ["git", "tag", "-l"]:
323
+ return MagicMock(returncode=0, stdout="v0.1.0\n")
324
+ if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]:
325
+ return MagicMock(returncode=0, stdout="main\n")
326
+ if cmd == ["git", "remote", "get-url", "origin"]:
327
+ return MagicMock(returncode=0, stdout="git@github.com:quanttide/repo.git\n")
328
+ if "view" in str(cmd):
329
+ return MagicMock(returncode=0, stdout="Release v0.1.0\n")
330
+ return MagicMock(returncode=0, stdout="")
331
+
332
+ monkeypatch.setattr("app.release.subprocess.run", recorder)
333
+ assert run("v0.1.0", changelog, yes=True) == 0
334
+ assert ["git", "tag", "v0.1.0"] not in calls
335
+ assert any("create" in c for c in calls)
336
+ changelog.unlink()
337
+
338
+
339
+ def test_run_release_only(monkeypatch):
340
+ """--release-only 只创建 GitHub Release,不打标签"""
341
+ changelog = Path("/tmp/test_run_release_only.md")
342
+ changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
343
+
344
+ calls = []
345
+ def recorder(cmd, **kw):
346
+ calls.append(cmd)
347
+ if cmd == ["git", "tag", "-l"]:
348
+ return MagicMock(returncode=0, stdout="v0.1.0\n")
349
+ if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]:
350
+ return MagicMock(returncode=0, stdout="main\n")
351
+ if cmd == ["git", "remote", "get-url", "origin"]:
352
+ return MagicMock(returncode=0, stdout="git@github.com:quanttide/repo.git\n")
353
+ if "view" in str(cmd):
354
+ return MagicMock(returncode=0, stdout="Release v0.1.0\nURL: ...")
355
+ return MagicMock(returncode=0, stdout="")
356
+
357
+ monkeypatch.setattr("app.release.subprocess.run", recorder)
358
+ assert run("v0.1.0", changelog, release_only=True, yes=True) == 0
359
+ assert not any(cmd == ["git", "tag", "v0.1.0"] for cmd in calls)
360
+ assert any("create" in c for c in calls)
361
+ changelog.unlink()
362
+
363
+
364
+ def test_run_release_only_fails_without_remote(monkeypatch):
365
+ """--release-only 无法解析 remote 时报错"""
366
+ changelog = Path("/tmp/test_run_rel_no_remote.md")
367
+ changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
368
+
369
+ monkeypatch.setattr("app.release.subprocess.run", mock_subprocess({
370
+ ("git", "rev-parse", "--abbrev-ref", "HEAD"): MagicMock(returncode=0, stdout="main\n"),
371
+ ("git", "remote", "get-url", "origin"): MagicMock(returncode=1, stderr=""),
372
+ }, default=MagicMock(returncode=0, stdout="")))
373
+
374
+ assert run("v0.1.0", changelog, release_only=True, yes=True) == 1
375
+ changelog.unlink()
376
+
377
+
378
+ def test_run_release_only_requires_existing_tag(monkeypatch):
379
+ """--release-only 要求标签已存在(预检查通过)"""
380
+ changelog = Path("/tmp/test_run_release_only_tag_exists.md")
381
+ changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
382
+
383
+ calls = []
384
+ def recorder(cmd, **kw):
385
+ calls.append(cmd)
386
+ if cmd == ["git", "tag", "-l"]:
387
+ return MagicMock(returncode=0, stdout="v0.1.0\n")
388
+ if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]:
389
+ return MagicMock(returncode=0, stdout="main\n")
390
+ if cmd == ["git", "remote", "get-url", "origin"]:
391
+ return MagicMock(returncode=0, stdout="git@github.com:quanttide/repo.git\n")
392
+ if "view" in str(cmd):
393
+ return MagicMock(returncode=0, stdout="Release v0.1.0\n")
394
+ return MagicMock(returncode=0, stdout="")
395
+
396
+ monkeypatch.setattr("app.release.subprocess.run", recorder)
397
+ assert run("v0.1.0", changelog, release_only=True, yes=True) == 0
398
+ changelog.unlink()
399
+
400
+
401
+ def test_run_release_only_fails_if_tag_missing(monkeypatch):
402
+ """--release-only 标签不存在时预检查拒绝"""
403
+ changelog = Path("/tmp/test_run_release_only_tag_missing.md")
404
+ changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
405
+
406
+ monkeypatch.setattr("app.release.subprocess.run", mock_subprocess({
407
+ ("git", "tag", "-l"): MagicMock(stdout="v0.2.0\n"),
408
+ ("git", "status", "--porcelain"): MagicMock(stdout=""),
409
+ ("git", "rev-parse", "--abbrev-ref", "HEAD"): MagicMock(stdout="main\n"),
410
+ }, default=MagicMock(returncode=0, stdout="")))
411
+
412
+ assert run("v0.1.0", changelog, release_only=True, yes=True) == 1
413
+ changelog.unlink()
414
+
415
+
416
+ def test_precheck_release_only_requires_tag(monkeypatch):
417
+ """--release-only 时要求标签已存在"""
418
+ monkeypatch.setattr("app.release.subprocess.run", mock_subprocess({
419
+ ("git", "tag", "-l"): MagicMock(stdout="v0.1.0\n"),
420
+ ("git", "status", "--porcelain"): MagicMock(stdout=""),
421
+ ("git", "rev-parse", "--abbrev-ref", "HEAD"): MagicMock(stdout="main\n"),
422
+ }))
423
+ changelog = Path("/tmp/test_precheck_release_only_tag_ok.md")
424
+ changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
425
+ errors = precheck("v0.1.0", changelog, release_only=True)
426
+ assert len(errors) == 0
427
+ changelog.unlink()
428
+
429
+
430
+ def test_precheck_release_only_fails_if_tag_missing(monkeypatch):
431
+ """--release-only 时标签不存在应报错"""
432
+ monkeypatch.setattr("app.release.subprocess.run", mock_subprocess({
433
+ ("git", "tag", "-l"): MagicMock(stdout="v0.2.0\n"),
434
+ ("git", "status", "--porcelain"): MagicMock(stdout=""),
435
+ ("git", "rev-parse", "--abbrev-ref", "HEAD"): MagicMock(stdout="main\n"),
436
+ }))
437
+ changelog = Path("/tmp/test_precheck_release_only_tag_missing.md")
438
+ changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
439
+ errors = precheck("v0.1.0", changelog, release_only=True)
440
+ assert any("标签不存在" in e for e in errors)
441
+ changelog.unlink()
442
+
443
+
444
+ def test_run_uses_default_changelog(monkeypatch):
445
+ monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: MagicMock(returncode=0, stdout="main\n"))
446
+ assert run("v0.1.0", dry_run=True) == 1