Coverage for src / mysingle / cli / core / auto_version.py: 40%
176 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-02 00:58 +0900
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-02 00:58 +0900
1"""Conventional Commits 기반 자동 버전 관리.
3Commit 메시지 분석:
4- feat: → minor 버전 증가
5- fix: → patch 버전 증가
6- feat!: 또는 BREAKING CHANGE: → major 버전 증가
7- chore:, docs:, style:, refactor:, test: → 버전 변경 없음
9Proto 변경 특수 처리:
10- proto: feat: → proto patch 버전 증가 (메인 버전은 유지)
11- protos/ 디렉토리 변경만 있는 경우 → proto patch만 증가
12"""
14from __future__ import annotations
16import re
17import subprocess
18from dataclasses import dataclass
19from pathlib import Path
21from .version import Version, find_pyproject, read_current_version, write_version
22from ..utils import console, print_error, print_info, print_success, print_warning
25@dataclass
26class CommitInfo:
27 """커밋 정보"""
29 sha: str
30 message: str
31 files: list[str]
33 @property
34 def is_breaking(self) -> bool:
35 """Breaking change 여부"""
36 return (
37 "BREAKING CHANGE:" in self.message
38 or "!" in self.message.split(":")[0]
39 or re.match(r"^[a-z]+!:", self.message) is not None
40 )
42 @property
43 def is_feat(self) -> bool:
44 """Feature 커밋 여부"""
45 return self.message.startswith("feat:")
47 @property
48 def is_fix(self) -> bool:
49 """Fix 커밋 여부"""
50 return self.message.startswith("fix:")
52 @property
53 def is_proto_only(self) -> bool:
54 """Proto 파일만 변경되었는지 확인"""
55 if not self.files: 55 ↛ 56line 55 didn't jump to line 56 because the condition on line 55 was never true
56 return False
57 return all(
58 f.startswith("protos/") or f.startswith("src/mysingle/protos/")
59 for f in self.files
60 )
62 @property
63 def is_proto_related(self) -> bool:
64 """Proto 관련 변경 포함 여부"""
65 return any(
66 f.startswith("protos/") or f.startswith("src/mysingle/protos/")
67 for f in self.files
68 )
70 @property
71 def type(self) -> str:
72 """커밋 타입 추출 (feat, fix, chore 등)"""
73 match = re.match(r"^([a-z]+)(?:\([^)]+\))?!?:", self.message)
74 return match.group(1) if match else "unknown"
77def get_commits_since_tag(tag: str | None = None) -> list[CommitInfo]:
78 """마지막 태그 이후의 커밋 목록 가져오기
80 Args:
81 tag: 시작 태그 (None이면 마지막 태그부터)
83 Returns:
84 커밋 정보 리스트
85 """
86 if tag is None: 86 ↛ 101line 86 didn't jump to line 101 because the condition on line 86 was always true
87 # Get latest tag
88 result = subprocess.run(
89 ["git", "describe", "--tags", "--abbrev=0"],
90 capture_output=True,
91 text=True,
92 check=False,
93 )
94 if result.returncode != 0: 94 ↛ 96line 94 didn't jump to line 96 because the condition on line 94 was never true
95 # No tags yet, get all commits
96 tag_ref = ""
97 else:
98 tag = result.stdout.strip()
99 tag_ref = f"{tag}.."
100 else:
101 tag_ref = f"{tag}.."
103 # Get commit list
104 result = subprocess.run(
105 ["git", "log", f"{tag_ref}HEAD", "--oneline", "--pretty=format:%H|||%s"],
106 capture_output=True,
107 text=True,
108 check=True,
109 )
111 commits = []
112 for line in result.stdout.strip().split("\n"):
113 if not line: 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true
114 continue
115 sha, message = line.split("|||", 1)
117 # Get files changed in this commit
118 files_result = subprocess.run(
119 ["git", "diff-tree", "--no-commit-id", "--name-only", "-r", sha],
120 capture_output=True,
121 text=True,
122 check=True,
123 )
124 files = (
125 files_result.stdout.strip().split("\n")
126 if files_result.stdout.strip()
127 else []
128 )
130 commits.append(CommitInfo(sha=sha, message=message, files=files))
132 return commits
135def analyze_commits(commits: list[CommitInfo]) -> dict:
136 """커밋 분석하여 버전 변경 제안
138 Args:
139 commits: 분석할 커밋 목록
141 Returns:
142 분석 결과 딕셔너리
143 {
144 'bump_type': 'major' | 'minor' | 'patch' | 'none',
145 'proto_bump': True | False,
146 'breaking_changes': [...],
147 'features': [...],
148 'fixes': [...],
149 'proto_changes': [...],
150 }
151 """
152 result = {
153 "bump_type": "none",
154 "proto_bump": False,
155 "breaking_changes": [],
156 "features": [],
157 "fixes": [],
158 "proto_changes": [],
159 "other_changes": [],
160 }
162 for commit in commits:
163 # Breaking changes
164 if commit.is_breaking:
165 result["breaking_changes"].append(commit)
166 if result["bump_type"] not in ["major"]: 166 ↛ 190line 166 didn't jump to line 190 because the condition on line 166 was always true
167 result["bump_type"] = "major"
169 # Features
170 elif commit.is_feat:
171 result["features"].append(commit)
172 if result["bump_type"] not in ["major", "minor"]: 172 ↛ 190line 172 didn't jump to line 190 because the condition on line 172 was always true
173 # Proto-only features don't bump main version
174 if not commit.is_proto_only:
175 result["bump_type"] = "minor"
176 else:
177 result["proto_bump"] = True
179 # Fixes
180 elif commit.is_fix:
181 result["fixes"].append(commit)
182 if result["bump_type"] == "none":
183 # Proto-only fixes don't bump main version
184 if not commit.is_proto_only:
185 result["bump_type"] = "patch"
186 else:
187 result["proto_bump"] = True
189 # Proto changes
190 if commit.is_proto_related:
191 result["proto_changes"].append(commit)
192 result["proto_bump"] = True
194 # Other changes
195 if commit.type in ["chore", "docs", "style", "refactor", "test", "build", "ci"]:
196 result["other_changes"].append(commit)
198 return result
201def generate_changelog(
202 analysis: dict, current_version: Version, new_version: Version
203) -> str:
204 """CHANGELOG 항목 생성
206 Args:
207 analysis: 커밋 분석 결과
208 current_version: 현재 버전
209 new_version: 새 버전
211 Returns:
212 CHANGELOG 마크다운 문자열
213 """
214 lines = [
215 f"## [{new_version}] - {import_datetime()}",
216 "",
217 ]
219 if analysis["breaking_changes"]:
220 lines.append("### ⚠️ BREAKING CHANGES")
221 lines.append("")
222 for commit in analysis["breaking_changes"]:
223 lines.append(f"- {commit.message} ({commit.sha[:7]})")
224 lines.append("")
226 if analysis["features"]:
227 lines.append("### ✨ Features")
228 lines.append("")
229 for commit in analysis["features"]:
230 lines.append(f"- {commit.message} ({commit.sha[:7]})")
231 lines.append("")
233 if analysis["fixes"]:
234 lines.append("### 🐛 Bug Fixes")
235 lines.append("")
236 for commit in analysis["fixes"]:
237 lines.append(f"- {commit.message} ({commit.sha[:7]})")
238 lines.append("")
240 if analysis["proto_changes"]:
241 lines.append("### 📦 Proto Changes")
242 lines.append("")
243 for commit in analysis["proto_changes"]:
244 if commit not in analysis["features"] + analysis["fixes"]:
245 lines.append(f"- {commit.message} ({commit.sha[:7]})")
246 lines.append("")
248 if analysis["other_changes"]:
249 lines.append("### 🔧 Other Changes")
250 lines.append("")
251 for commit in analysis["other_changes"]:
252 lines.append(f"- {commit.message} ({commit.sha[:7]})")
253 lines.append("")
255 return "\n".join(lines)
258def import_datetime() -> str:
259 """현재 날짜 반환 (YYYY-MM-DD)"""
260 from datetime import datetime
262 return datetime.now().strftime("%Y-%m-%d")
265def auto_bump(
266 dry_run: bool = False,
267 push: bool = False,
268 no_commit: bool = False,
269 no_tag: bool = False,
270) -> int:
271 """Conventional Commits 기반 자동 버전 업데이트
273 Args:
274 dry_run: 실제 변경 없이 분석만 수행
275 push: 변경사항을 origin에 푸시
276 no_commit: Git 커밋 생성하지 않음
277 no_tag: Git 태그 생성하지 않음
279 Returns:
280 종료 코드 (0: 성공, 1: 실패)
281 """
282 try:
283 pyproject_path = find_pyproject()
284 except FileNotFoundError as e:
285 print_error(str(e))
286 return 1
288 current_version = read_current_version(pyproject_path)
290 # Get commits since last tag
291 try:
292 commits = get_commits_since_tag()
293 except subprocess.CalledProcessError as e:
294 print_error(f"커밋 목록 가져오기 실패: {e.stderr}")
295 return 1
297 if not commits:
298 print_info("새로운 커밋이 없습니다.")
299 return 0
301 # Analyze commits
302 analysis = analyze_commits(commits)
304 # Display analysis
305 console.print(f"\n[bold]현재 버전:[/bold] [cyan]{current_version}[/cyan]")
306 console.print(f"[bold]분석된 커밋 수:[/bold] {len(commits)}\n")
308 if analysis["breaking_changes"]:
309 console.print(
310 f"[red]⚠️ Breaking Changes: {len(analysis['breaking_changes'])}개[/red]"
311 )
312 if analysis["features"]:
313 console.print(f"[green]✨ Features: {len(analysis['features'])}개[/green]")
314 if analysis["fixes"]:
315 console.print(f"[yellow]🐛 Bug Fixes: {len(analysis['fixes'])}개[/yellow]")
316 if analysis["proto_changes"]:
317 console.print(
318 f"[blue]📦 Proto Changes: {len(analysis['proto_changes'])}개[/blue]"
319 )
321 # Determine new version
322 if analysis["bump_type"] == "none":
323 if analysis["proto_bump"]:
324 print_info("\nProto 변경만 있습니다. 메인 버전은 유지됩니다.")
325 console.print("[dim]Note: Proto 버전은 별도 관리됩니다 (buf.yaml)[/dim]")
326 else:
327 print_info("\n버전 변경이 필요한 커밋이 없습니다.")
328 return 0
330 new_version = current_version.bump(analysis["bump_type"])
331 console.print(
332 f"\n[yellow]권장 버전:[/yellow] {current_version} → [green]{new_version}[/green] "
333 f"([bold]{analysis['bump_type']}[/bold])\n"
334 )
336 if dry_run:
337 print_info("Dry-run 모드: 실제 변경하지 않습니다.")
338 # Show what would be in changelog
339 changelog = generate_changelog(analysis, current_version, new_version)
340 console.print("\n[bold]생성될 CHANGELOG:[/bold]")
341 console.print(changelog)
342 return 0
344 # Write new version
345 write_version(pyproject_path, new_version)
346 print_success(f"{pyproject_path.name} 업데이트 완료")
348 # Git operations
349 if not no_commit:
350 try:
351 subprocess.run(
352 ["git", "rev-parse", "--git-dir"], check=True, capture_output=True
353 )
355 # Add pyproject.toml
356 subprocess.run(["git", "add", str(pyproject_path)], check=True)
358 # Create commit with conventional format
359 commit_msg = f"chore(release): bump version to {new_version}\n\n"
360 commit_msg += generate_changelog(analysis, current_version, new_version)
362 subprocess.run(["git", "commit", "-m", commit_msg], check=True)
363 print_success(f"커밋 생성 완료: v{new_version}")
365 # Create tag
366 if not no_tag:
367 subprocess.run(["git", "tag", f"v{new_version}"], check=True)
368 print_success(f"태그 생성 완료: v{new_version}")
370 # Push
371 if push:
372 subprocess.run(["git", "push", "origin", "HEAD"], check=True)
373 print_success("커밋 푸시 완료")
374 if not no_tag:
375 subprocess.run(
376 ["git", "push", "origin", f"v{new_version}"], check=True
377 )
378 print_success("태그 푸시 완료")
380 except subprocess.CalledProcessError as e:
381 print_error(f"Git 작업 실패: {e}")
382 return 1
384 return 0