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

1"""Conventional Commits 기반 자동 버전 관리. 

2 

3Commit 메시지 분석: 

4- feat: → minor 버전 증가 

5- fix: → patch 버전 증가 

6- feat!: 또는 BREAKING CHANGE: → major 버전 증가 

7- chore:, docs:, style:, refactor:, test: → 버전 변경 없음 

8 

9Proto 변경 특수 처리: 

10- proto: feat: → proto patch 버전 증가 (메인 버전은 유지) 

11- protos/ 디렉토리 변경만 있는 경우 → proto patch만 증가 

12""" 

13 

14from __future__ import annotations 

15 

16import re 

17import subprocess 

18from dataclasses import dataclass 

19from pathlib import Path 

20 

21from .version import Version, find_pyproject, read_current_version, write_version 

22from ..utils import console, print_error, print_info, print_success, print_warning 

23 

24 

25@dataclass 

26class CommitInfo: 

27 """커밋 정보""" 

28 

29 sha: str 

30 message: str 

31 files: list[str] 

32 

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 ) 

41 

42 @property 

43 def is_feat(self) -> bool: 

44 """Feature 커밋 여부""" 

45 return self.message.startswith("feat:") 

46 

47 @property 

48 def is_fix(self) -> bool: 

49 """Fix 커밋 여부""" 

50 return self.message.startswith("fix:") 

51 

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 ) 

61 

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 ) 

69 

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" 

75 

76 

77def get_commits_since_tag(tag: str | None = None) -> list[CommitInfo]: 

78 """마지막 태그 이후의 커밋 목록 가져오기 

79 

80 Args: 

81 tag: 시작 태그 (None이면 마지막 태그부터) 

82 

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}.." 

102 

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 ) 

110 

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) 

116 

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 ) 

129 

130 commits.append(CommitInfo(sha=sha, message=message, files=files)) 

131 

132 return commits 

133 

134 

135def analyze_commits(commits: list[CommitInfo]) -> dict: 

136 """커밋 분석하여 버전 변경 제안 

137 

138 Args: 

139 commits: 분석할 커밋 목록 

140 

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 } 

161 

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" 

168 

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 

178 

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 

188 

189 # Proto changes 

190 if commit.is_proto_related: 

191 result["proto_changes"].append(commit) 

192 result["proto_bump"] = True 

193 

194 # Other changes 

195 if commit.type in ["chore", "docs", "style", "refactor", "test", "build", "ci"]: 

196 result["other_changes"].append(commit) 

197 

198 return result 

199 

200 

201def generate_changelog( 

202 analysis: dict, current_version: Version, new_version: Version 

203) -> str: 

204 """CHANGELOG 항목 생성 

205 

206 Args: 

207 analysis: 커밋 분석 결과 

208 current_version: 현재 버전 

209 new_version: 새 버전 

210 

211 Returns: 

212 CHANGELOG 마크다운 문자열 

213 """ 

214 lines = [ 

215 f"## [{new_version}] - {import_datetime()}", 

216 "", 

217 ] 

218 

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("") 

225 

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("") 

232 

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("") 

239 

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("") 

247 

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("") 

254 

255 return "\n".join(lines) 

256 

257 

258def import_datetime() -> str: 

259 """현재 날짜 반환 (YYYY-MM-DD)""" 

260 from datetime import datetime 

261 

262 return datetime.now().strftime("%Y-%m-%d") 

263 

264 

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 기반 자동 버전 업데이트 

272 

273 Args: 

274 dry_run: 실제 변경 없이 분석만 수행 

275 push: 변경사항을 origin에 푸시 

276 no_commit: Git 커밋 생성하지 않음 

277 no_tag: Git 태그 생성하지 않음 

278 

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 

287 

288 current_version = read_current_version(pyproject_path) 

289 

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 

296 

297 if not commits: 

298 print_info("새로운 커밋이 없습니다.") 

299 return 0 

300 

301 # Analyze commits 

302 analysis = analyze_commits(commits) 

303 

304 # Display analysis 

305 console.print(f"\n[bold]현재 버전:[/bold] [cyan]{current_version}[/cyan]") 

306 console.print(f"[bold]분석된 커밋 수:[/bold] {len(commits)}\n") 

307 

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 ) 

320 

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 

329 

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 ) 

335 

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 

343 

344 # Write new version 

345 write_version(pyproject_path, new_version) 

346 print_success(f"{pyproject_path.name} 업데이트 완료") 

347 

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 ) 

354 

355 # Add pyproject.toml 

356 subprocess.run(["git", "add", str(pyproject_path)], check=True) 

357 

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) 

361 

362 subprocess.run(["git", "commit", "-m", commit_msg], check=True) 

363 print_success(f"커밋 생성 완료: v{new_version}") 

364 

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}") 

369 

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("태그 푸시 완료") 

379 

380 except subprocess.CalledProcessError as e: 

381 print_error(f"Git 작업 실패: {e}") 

382 return 1 

383 

384 return 0