project-auto-wizard 0.1.5
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.
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/bin/project-auto-wizard.js +19 -0
- package/package.json +36 -0
- package/payload/coderabbit.yaml +19 -0
- package/payload/config/breaking-changes.json +1 -0
- package/payload/config/wizard-prompts.yml +62 -0
- package/payload/scripts/changelog_manager.py +726 -0
- package/payload/scripts/version_manager.py +617 -0
- package/payload/version.yml.template +48 -0
- package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +303 -0
- package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +293 -0
- package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +289 -0
- package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +192 -0
- package/payload/workflows/common/secret-backup/PROJECT-COMMON-SECRET-FILE-UPLOAD.yaml +209 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +591 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +700 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +308 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +992 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +689 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +987 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +471 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-SUH-LAB-APP-BUILD-TRIGGER.yaml +500 -0
- package/payload/workflows/next/PROJECT-NEXT-CI.yaml +185 -0
- package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +271 -0
- package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +81 -0
- package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +2191 -0
- package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +386 -0
- package/payload/workflows/react/PROJECT-REACT-CI.yaml +194 -0
- package/payload/workflows/react/PROJECT-REACT-CICD.yaml +255 -0
- package/payload/workflows/spring/PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH.yml +84 -0
- package/payload/workflows/spring/nexus/PROJECT-SPRING-NEXUS-CI.yml +316 -0
- package/payload/workflows/spring/nexus/PROJECT-SPRING-NEXUS-PUBLISH.yml +58 -0
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +535 -0
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +437 -0
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +2277 -0
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +425 -0
- package/src/cli/args.js +95 -0
- package/src/cli/help.js +27 -0
- package/src/commands/full.js +53 -0
- package/src/commands/interactive.js +276 -0
- package/src/commands/revert.js +60 -0
- package/src/commands/version.js +31 -0
- package/src/commands/workflows.js +49 -0
- package/src/context.js +26 -0
- package/src/core/assets.js +47 -0
- package/src/core/branches.js +60 -0
- package/src/core/branding.js +15 -0
- package/src/core/breaking-check.js +65 -0
- package/src/core/breaking.js +26 -0
- package/src/core/copy/coderabbit.js +26 -0
- package/src/core/copy/gitignore.js +55 -0
- package/src/core/copy/readme.js +30 -0
- package/src/core/copy/simple.js +23 -0
- package/src/core/copy/workflows.js +233 -0
- package/src/core/detect-fs.js +106 -0
- package/src/core/detect.js +62 -0
- package/src/core/fsutil.js +46 -0
- package/src/core/options-ask.js +99 -0
- package/src/core/paths-resolve.js +261 -0
- package/src/core/paths.js +16 -0
- package/src/core/version-yml.js +190 -0
- package/src/core/wizard-env.js +101 -0
- package/src/core/wizard-labels.js +107 -0
- package/src/index.js +162 -0
- package/src/ui/ansi.js +26 -0
- package/src/ui/banner.js +29 -0
- package/src/ui/env-plan.js +207 -0
- package/src/ui/prompts.js +99 -0
- package/src/ui/readline-engine.js +257 -0
- package/src/ui/status-cards.js +56 -0
- package/src/ui/summary.js +127 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
changelog_manager.py
|
|
4
|
+
|
|
5
|
+
통합 체인지로그 매니저 스크립트.
|
|
6
|
+
|
|
7
|
+
서브커맨드:
|
|
8
|
+
- update-from-summary: CodeRabbit Summary Markdown을 파싱하여 CHANGELOG.json 갱신
|
|
9
|
+
- generate-md : CHANGELOG.json을 기반으로 CHANGELOG.md 재생성
|
|
10
|
+
- export : 특정 버전의 릴리즈 노트를 생성하여 stdout 또는 파일로 저장
|
|
11
|
+
- ai-summary : 커밋 목록으로부터 AI(또는 규칙 기반 폴백) 릴리즈 요약 생성
|
|
12
|
+
|
|
13
|
+
사용 예:
|
|
14
|
+
python3 changelog_manager.py update-from-summary
|
|
15
|
+
python3 changelog_manager.py generate-md
|
|
16
|
+
python3 changelog_manager.py export --version 0.0.2 --output release_notes.txt
|
|
17
|
+
python3 changelog_manager.py ai-summary --commits-file commits.txt --version 1.2.3 --output summary.md
|
|
18
|
+
|
|
19
|
+
입력 파일:
|
|
20
|
+
- pr_body.md: GitHub PR body (Markdown 형식)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import html
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
import traceback
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.request
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ----------------------------- 공통 유틸 -----------------------------
|
|
37
|
+
|
|
38
|
+
def _normalize_text(text: str) -> str:
|
|
39
|
+
"""텍스트 정규화: HTML 엔티티 디코딩 및 공백 정리."""
|
|
40
|
+
return html.unescape(text).strip()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _clean_summary_noise(text: str) -> str:
|
|
44
|
+
"""
|
|
45
|
+
Summary 텍스트에서 불필요한 노이즈 제거.
|
|
46
|
+
|
|
47
|
+
제거 대상:
|
|
48
|
+
1. HTML 주석 (<!-- ... -->)
|
|
49
|
+
2. CodeRabbit Tip 메시지
|
|
50
|
+
3. 남은 HTML 태그
|
|
51
|
+
4. 연속된 빈 줄
|
|
52
|
+
"""
|
|
53
|
+
if not text:
|
|
54
|
+
return text
|
|
55
|
+
|
|
56
|
+
# 1. HTML 주석 제거
|
|
57
|
+
text = re.sub(r'<!--.*?-->', '', text, flags=re.DOTALL)
|
|
58
|
+
|
|
59
|
+
# 2. CodeRabbit Tip 줄 제거
|
|
60
|
+
text = re.sub(r'^.*?✏️\s*Tip:.*$', '', text, flags=re.MULTILINE)
|
|
61
|
+
text = re.sub(r'<sub>.*?Tip:.*?</sub>', '', text, flags=re.IGNORECASE | re.DOTALL)
|
|
62
|
+
text = re.sub(r'^\s*Tip:.*$', '', text, flags=re.MULTILINE | re.IGNORECASE)
|
|
63
|
+
|
|
64
|
+
# 3. 남은 HTML 태그 제거
|
|
65
|
+
text = re.sub(r'<[^>]+>', '', text)
|
|
66
|
+
|
|
67
|
+
# 4. 연속된 빈 줄 정리 (3개 이상 → 2개)
|
|
68
|
+
text = re.sub(r'\n{3,}', '\n\n', text)
|
|
69
|
+
|
|
70
|
+
return text.strip()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _make_safe_key(title: str, idx: int) -> str:
|
|
74
|
+
"""카테고리 제목을 안전한 키로 변환."""
|
|
75
|
+
safe_key = re.sub(r'[^a-zA-Z0-9가-힣]', '_', title.lower()).strip('_')
|
|
76
|
+
return safe_key if safe_key else f"category_{idx}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ----------------------- Markdown 파서 (통합) -----------------------
|
|
80
|
+
|
|
81
|
+
def _parse_summary_markdown(md_content: str) -> dict:
|
|
82
|
+
"""
|
|
83
|
+
Markdown 형식의 CodeRabbit Summary 파싱.
|
|
84
|
+
|
|
85
|
+
3단계 폴백 전략:
|
|
86
|
+
1. 정밀 파싱 (현재 CodeRabbit 형식)
|
|
87
|
+
2. 관대한 파싱 (형식 변형 대응)
|
|
88
|
+
3. 휴리스틱 파싱 (최후 수단)
|
|
89
|
+
|
|
90
|
+
예상 형식:
|
|
91
|
+
## Summary by CodeRabbit
|
|
92
|
+
|
|
93
|
+
* **버그 수정**
|
|
94
|
+
* OCR 입력 처리 개선
|
|
95
|
+
* 빈 콘텐츠 응답 오류 감지 강화
|
|
96
|
+
|
|
97
|
+
* **Chores**
|
|
98
|
+
* 버전 0.1.39로 업그레이드
|
|
99
|
+
"""
|
|
100
|
+
# 1단계: 정밀 파싱
|
|
101
|
+
detected = _parse_markdown_precise(md_content)
|
|
102
|
+
if detected:
|
|
103
|
+
print(" → 정밀 파서 성공")
|
|
104
|
+
return detected
|
|
105
|
+
|
|
106
|
+
# 2단계: 관대한 파싱
|
|
107
|
+
detected = _parse_markdown_lenient(md_content)
|
|
108
|
+
if detected:
|
|
109
|
+
print(" → 관대한 파서 성공")
|
|
110
|
+
return detected
|
|
111
|
+
|
|
112
|
+
# 3단계: 휴리스틱 파싱
|
|
113
|
+
detected = _parse_markdown_heuristic(md_content)
|
|
114
|
+
if detected:
|
|
115
|
+
print(" → 휴리스틱 파서 성공")
|
|
116
|
+
return detected
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _parse_markdown_precise(md_content: str) -> dict:
|
|
120
|
+
"""
|
|
121
|
+
정밀 파서: 현재 CodeRabbit 형식에 최적화.
|
|
122
|
+
|
|
123
|
+
형식: * **카테고리**\n * 항목
|
|
124
|
+
"""
|
|
125
|
+
detected: dict[str, dict] = {}
|
|
126
|
+
|
|
127
|
+
# 패턴: * **카테고리** (bold, 들여쓰기 2칸)
|
|
128
|
+
pattern = r'\*\s*\*\*(.+?)\*\*\s*\n((?:\s{2}\*\s+.+(?:\n|$))*)'
|
|
129
|
+
matches = re.findall(pattern, md_content, re.MULTILINE)
|
|
130
|
+
|
|
131
|
+
for idx, (category_title, items_text) in enumerate(matches):
|
|
132
|
+
category_title = category_title.strip()
|
|
133
|
+
|
|
134
|
+
# 항목 추출: " * 항목 내용"
|
|
135
|
+
items = re.findall(r'\s{2}\*\s+(.+)', items_text)
|
|
136
|
+
items = [item.strip() for item in items if item.strip()]
|
|
137
|
+
|
|
138
|
+
if not category_title and not items:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
safe_key = _make_safe_key(category_title, idx)
|
|
142
|
+
detected[safe_key] = {
|
|
143
|
+
'title': category_title,
|
|
144
|
+
'items': items,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return detected
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _parse_markdown_lenient(md_content: str) -> dict:
|
|
151
|
+
"""
|
|
152
|
+
관대한 파서: 형식 변형에 대응.
|
|
153
|
+
|
|
154
|
+
지원:
|
|
155
|
+
- 들여쓰기 1~8칸 (탭 포함)
|
|
156
|
+
- bold 선택적 (**제목** 또는 제목)
|
|
157
|
+
- 다양한 리스트 마커 (*, -, +)
|
|
158
|
+
"""
|
|
159
|
+
content = md_content.replace('\t', ' ')
|
|
160
|
+
detected: dict[str, dict] = {}
|
|
161
|
+
|
|
162
|
+
# 패턴: 카테고리 + 중첩 항목
|
|
163
|
+
pattern = r'(?:^|\n)([\*\-\+])\s*(\*\*)?([^\*\n]+?)(\*\*)?\s*\n((?:(?:^|\n)\s{1,8}[\*\-\+]\s+.+)*)'
|
|
164
|
+
matches = re.findall(pattern, content, re.MULTILINE)
|
|
165
|
+
|
|
166
|
+
for idx, (marker, bold_start, category_title, bold_end, items_text) in enumerate(matches):
|
|
167
|
+
category_title = category_title.strip()
|
|
168
|
+
|
|
169
|
+
# 항목 추출
|
|
170
|
+
items = re.findall(r'(?:^|\n)\s{1,8}[\*\-\+]\s+(.+)', items_text, re.MULTILINE)
|
|
171
|
+
items = [item.strip() for item in items if item.strip()]
|
|
172
|
+
|
|
173
|
+
if not category_title and not items:
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
# 너무 긴 제목은 카테고리가 아님
|
|
177
|
+
if len(category_title) > 100:
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
safe_key = _make_safe_key(category_title, idx)
|
|
181
|
+
detected[safe_key] = {
|
|
182
|
+
'title': category_title,
|
|
183
|
+
'items': items,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return detected
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _parse_markdown_heuristic(md_content: str) -> dict:
|
|
190
|
+
"""
|
|
191
|
+
휴리스틱 파서: 줄 단위로 카테고리/항목 추론.
|
|
192
|
+
|
|
193
|
+
규칙:
|
|
194
|
+
1. Bold 텍스트(**...**) → 카테고리
|
|
195
|
+
2. 들여쓰기 있는 줄 → 항목
|
|
196
|
+
"""
|
|
197
|
+
lines = md_content.split('\n')
|
|
198
|
+
detected: dict[str, dict] = {}
|
|
199
|
+
current_key = None
|
|
200
|
+
|
|
201
|
+
for line in lines:
|
|
202
|
+
stripped = line.strip()
|
|
203
|
+
|
|
204
|
+
if not stripped or stripped.startswith('<!--') or stripped.startswith('##'):
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
# Bold 텍스트 → 카테고리
|
|
208
|
+
bold_match = re.search(r'\*\*([^\*]+)\*\*', stripped)
|
|
209
|
+
if bold_match:
|
|
210
|
+
title = bold_match.group(1).strip()
|
|
211
|
+
title = re.sub(r'^[\*\-\+\d\.]+\s*', '', title).strip()
|
|
212
|
+
|
|
213
|
+
if title and len(title) < 100:
|
|
214
|
+
current_key = _make_safe_key(title, len(detected))
|
|
215
|
+
detected[current_key] = {'title': title, 'items': []}
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
# 들여쓰기 있는 줄 → 항목
|
|
219
|
+
if line.startswith((' ', '\t')) and stripped:
|
|
220
|
+
item = re.sub(r'^[\*\-\+\d\.]+\s*', '', stripped).strip()
|
|
221
|
+
item = re.sub(r'<[^>]+>', '', item).strip()
|
|
222
|
+
|
|
223
|
+
if current_key and item and len(item) > 3:
|
|
224
|
+
detected[current_key]['items'].append(item)
|
|
225
|
+
|
|
226
|
+
# 빈 카테고리 제거
|
|
227
|
+
return {k: v for k, v in detected.items() if v.get('items')}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ------------------------ 3단계 규칙 기반 폴백 파서 ------------------------
|
|
231
|
+
|
|
232
|
+
# 1단계 패턴은 제목도 같은 정규식에서 캡처한다 — " : type : " 마커(타입 앞 콜론에
|
|
233
|
+
# 반드시 공백 선행)가 유일한 구분자이므로, 제목 안의 맨몸 콜론("v1:2" 등)에서
|
|
234
|
+
# 잘리지 않는다. 별도 split 재수행 금지.
|
|
235
|
+
_TIER1_RE = re.compile(r'^(.+?)\s:\s*(feat|fix|chore|docs|refactor|test)\s*:\s*(.+)$')
|
|
236
|
+
_TRAILING_URL_RE = re.compile(r'\s*https?://\S+$')
|
|
237
|
+
_TIER2_RE = re.compile(
|
|
238
|
+
r'^(feat|fix|chore|docs|refactor|test|perf|style|build|ci)(\([^)]*\))?!?:\s*(.+)$'
|
|
239
|
+
)
|
|
240
|
+
_TIER2_BUCKET_MAP = {
|
|
241
|
+
'feat': 'feat',
|
|
242
|
+
'fix': 'fix',
|
|
243
|
+
'chore': 'chore',
|
|
244
|
+
'docs': 'docs',
|
|
245
|
+
'refactor': 'refactor',
|
|
246
|
+
'test': 'test',
|
|
247
|
+
'perf': 'chore',
|
|
248
|
+
'style': 'chore',
|
|
249
|
+
'build': 'chore',
|
|
250
|
+
'ci': 'chore',
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
_FALLBACK_BUCKET_KEYS = ('feat', 'fix', 'chore', 'docs', 'refactor', 'test', 'changes')
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def classify_commits(lines: list[str]) -> dict:
|
|
257
|
+
"""
|
|
258
|
+
커밋 제목 목록을 3단계 규칙으로 분류.
|
|
259
|
+
|
|
260
|
+
1단계: projectops 컨벤션 — "제목 : type : 내용 [URL]"
|
|
261
|
+
2단계: Conventional Commits — "type(scope)!: 내용"
|
|
262
|
+
(perf/style/build/ci → chore 버킷으로 매핑)
|
|
263
|
+
3단계: 위 두 형식에 매칭되지 않으면 "changes" 버킷 (자유 형식)
|
|
264
|
+
|
|
265
|
+
제외 대상 (매칭 전에 걸러냄): [skip ci] 포함 줄, "Merge "로 시작하는 줄, 빈 줄.
|
|
266
|
+
"""
|
|
267
|
+
classified: dict[str, list[str]] = {key: [] for key in _FALLBACK_BUCKET_KEYS}
|
|
268
|
+
|
|
269
|
+
for raw_line in lines:
|
|
270
|
+
line = raw_line.strip()
|
|
271
|
+
if not line:
|
|
272
|
+
continue
|
|
273
|
+
if '[skip ci]' in line:
|
|
274
|
+
continue
|
|
275
|
+
if line.startswith('Merge '):
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
# 1단계가 2단계보다 먼저다 — 트레이드오프: "제목 : feat : 내용" 형식은
|
|
279
|
+
# "feat: ..." Conventional Commits와 겹칠 수 없지만(타입 앞에 제목 필수),
|
|
280
|
+
# 제목이 있는 줄에 " : type : "가 우연히 들어가면 tier-2 해석 기회 없이
|
|
281
|
+
# tier-1로 확정된다. projectops 컨벤션 레포에서는 이것이 의도된 우선순위다.
|
|
282
|
+
tier1 = _TIER1_RE.match(line)
|
|
283
|
+
if tier1:
|
|
284
|
+
title = tier1.group(1).strip()
|
|
285
|
+
commit_type = tier1.group(2)
|
|
286
|
+
desc = tier1.group(3).strip()
|
|
287
|
+
# 커밋 말미의 이슈 URL은 릴리즈 노트 렌더링에서 노이즈 — 제거.
|
|
288
|
+
desc = _TRAILING_URL_RE.sub('', desc).strip()
|
|
289
|
+
classified[commit_type].append(f"{title} — {desc}")
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
tier2 = _TIER2_RE.match(line)
|
|
293
|
+
if tier2:
|
|
294
|
+
commit_type, _scope, desc = tier2.group(1), tier2.group(2), tier2.group(3)
|
|
295
|
+
bucket = _TIER2_BUCKET_MAP[commit_type]
|
|
296
|
+
classified[bucket].append(desc.strip())
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
classified['changes'].append(line)
|
|
300
|
+
|
|
301
|
+
return classified
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
_FALLBACK_SECTION_TITLES = {
|
|
305
|
+
'feat': '### ✨ 기능',
|
|
306
|
+
'fix': '### 🐛 수정',
|
|
307
|
+
'docs': '### 📝 문서',
|
|
308
|
+
'refactor': '### ♻️ 리팩토링',
|
|
309
|
+
'test': '### ✅ 테스트',
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def render_fallback_md(classified: dict, version: str) -> str:
|
|
314
|
+
"""분류된 커밋 딕셔너리를 마크다운 릴리즈 노트로 렌더링."""
|
|
315
|
+
lines: list[str] = [f"## [{version}]", ""]
|
|
316
|
+
|
|
317
|
+
for bucket_key in ('feat', 'fix', 'docs', 'refactor', 'test'):
|
|
318
|
+
items = classified.get(bucket_key) or []
|
|
319
|
+
if not items:
|
|
320
|
+
continue
|
|
321
|
+
lines.append(_FALLBACK_SECTION_TITLES[bucket_key])
|
|
322
|
+
for item in items:
|
|
323
|
+
lines.append(f"- {item}")
|
|
324
|
+
lines.append("")
|
|
325
|
+
|
|
326
|
+
chore_items = list(classified.get('chore') or [])
|
|
327
|
+
changes_items = list(classified.get('changes') or [])
|
|
328
|
+
merged = chore_items + changes_items
|
|
329
|
+
if merged:
|
|
330
|
+
lines.append("### 🔧 변경사항")
|
|
331
|
+
for item in merged:
|
|
332
|
+
lines.append(f"- {item}")
|
|
333
|
+
lines.append("")
|
|
334
|
+
|
|
335
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ------------------------ 서브커맨드 구현부 ------------------------
|
|
339
|
+
|
|
340
|
+
def cmd_update_from_summary() -> int:
|
|
341
|
+
"""pr_body.md에서 Markdown을 파싱하여 CHANGELOG.json 갱신."""
|
|
342
|
+
version = os.environ.get('VERSION')
|
|
343
|
+
project_type = os.environ.get('PROJECT_TYPE')
|
|
344
|
+
# 멀티타입 — PROJECT_TYPES(csv) env가 있으면 배열로, 없으면 단수 키 fallback
|
|
345
|
+
project_types_csv = os.environ.get('PROJECT_TYPES', '')
|
|
346
|
+
project_types = [t.strip() for t in project_types_csv.split(',') if t.strip()]
|
|
347
|
+
if not project_types and project_type:
|
|
348
|
+
project_types = [project_type]
|
|
349
|
+
today = os.environ.get('TODAY')
|
|
350
|
+
pr_number_raw = os.environ.get('PR_NUMBER')
|
|
351
|
+
timestamp = os.environ.get('TIMESTAMP')
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
pr_number = int(pr_number_raw) if pr_number_raw else None
|
|
355
|
+
except ValueError:
|
|
356
|
+
pr_number = None
|
|
357
|
+
|
|
358
|
+
# 입력 파일 찾기 (pr_body.md 우선, 폴백으로 summary_section.html)
|
|
359
|
+
input_file = None
|
|
360
|
+
for filename in ['pr_body.md', 'summary_section.html']:
|
|
361
|
+
if os.path.isfile(filename):
|
|
362
|
+
input_file = filename
|
|
363
|
+
break
|
|
364
|
+
|
|
365
|
+
if not input_file:
|
|
366
|
+
print("❌ 입력 파일을 찾을 수 없습니다 (pr_body.md 또는 summary_section.html)")
|
|
367
|
+
return 1
|
|
368
|
+
|
|
369
|
+
try:
|
|
370
|
+
with open(input_file, 'r', encoding='utf-8') as f:
|
|
371
|
+
content = f.read()
|
|
372
|
+
|
|
373
|
+
print(f"📄 입력 파일: {input_file}")
|
|
374
|
+
print(f"📝 파일 크기: {len(content)} bytes")
|
|
375
|
+
|
|
376
|
+
# Markdown 파싱 (통합)
|
|
377
|
+
print("\n🔍 Markdown 파싱 시작...")
|
|
378
|
+
categories = _parse_summary_markdown(content)
|
|
379
|
+
|
|
380
|
+
parse_method = 'markdown' if categories else 'markdown_failed'
|
|
381
|
+
if categories:
|
|
382
|
+
print(f"✅ 파싱 성공: {len(categories)}개 카테고리")
|
|
383
|
+
else:
|
|
384
|
+
print("⚠️ 파싱 실패, raw_summary만 저장")
|
|
385
|
+
|
|
386
|
+
# raw_summary 생성 (노이즈 제거)
|
|
387
|
+
raw_summary = _clean_summary_noise(content)
|
|
388
|
+
|
|
389
|
+
# 릴리즈 데이터 생성
|
|
390
|
+
new_release = {
|
|
391
|
+
"version": version,
|
|
392
|
+
"project_type": project_type, # 기존 단수 키 — 유지 (하위 호환)
|
|
393
|
+
"project_types": project_types, # 신규 멀티타입 배열
|
|
394
|
+
"date": today,
|
|
395
|
+
"pr_number": pr_number,
|
|
396
|
+
"raw_summary": raw_summary,
|
|
397
|
+
"parsed_changes": categories or {},
|
|
398
|
+
"parse_method": parse_method,
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
# 파싱 결과 출력
|
|
402
|
+
print("\n📊 파싱 결과:")
|
|
403
|
+
print(f" - 파싱 방식: {parse_method}")
|
|
404
|
+
print(f" - raw_summary 길이: {len(raw_summary)} 문자")
|
|
405
|
+
print(f" - 파싱된 카테고리: {len(categories)}개")
|
|
406
|
+
for key, value in categories.items():
|
|
407
|
+
title = value.get('title', key)
|
|
408
|
+
items_count = len(value.get('items', []))
|
|
409
|
+
print(f" • {title}: {items_count}개 항목")
|
|
410
|
+
|
|
411
|
+
# CHANGELOG.json 업데이트
|
|
412
|
+
try:
|
|
413
|
+
with open('CHANGELOG.json', 'r', encoding='utf-8') as f:
|
|
414
|
+
changelog_data = json.load(f)
|
|
415
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
416
|
+
changelog_data = {
|
|
417
|
+
"metadata": {
|
|
418
|
+
"lastUpdated": timestamp,
|
|
419
|
+
"currentVersion": version,
|
|
420
|
+
"projectType": project_type,
|
|
421
|
+
"projectTypes": project_types,
|
|
422
|
+
"totalReleases": 0,
|
|
423
|
+
},
|
|
424
|
+
"releases": [],
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
# 방어: 파일이 존재하지만 스캐폴드 등 비정형 구조({"versions": []})라
|
|
428
|
+
# metadata/releases 키가 없을 수 있다 — 릴리스를 절대 막지 않는다 (실측: dogfood PR #1)
|
|
429
|
+
if not isinstance(changelog_data, dict):
|
|
430
|
+
changelog_data = {}
|
|
431
|
+
changelog_data.setdefault("metadata", {})
|
|
432
|
+
|
|
433
|
+
changelog_data["metadata"]["lastUpdated"] = timestamp
|
|
434
|
+
changelog_data["metadata"]["currentVersion"] = version
|
|
435
|
+
changelog_data["metadata"]["projectType"] = project_type
|
|
436
|
+
changelog_data["metadata"]["projectTypes"] = project_types
|
|
437
|
+
changelog_data["metadata"]["totalReleases"] = len(changelog_data.get("releases", [])) + 1
|
|
438
|
+
changelog_data.setdefault("releases", []).insert(0, new_release)
|
|
439
|
+
|
|
440
|
+
with open('CHANGELOG.json', 'w', encoding='utf-8') as f:
|
|
441
|
+
json.dump(changelog_data, f, indent=2, ensure_ascii=False)
|
|
442
|
+
|
|
443
|
+
print("\n✅ CHANGELOG.json 업데이트 완료!")
|
|
444
|
+
return 0
|
|
445
|
+
|
|
446
|
+
except Exception as e:
|
|
447
|
+
print(f"❌ update-from-summary 실패: {e}")
|
|
448
|
+
traceback.print_exc()
|
|
449
|
+
return 1
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def cmd_generate_md() -> int:
|
|
453
|
+
"""CHANGELOG.json을 기반으로 CHANGELOG.md 재생성."""
|
|
454
|
+
try:
|
|
455
|
+
with open('CHANGELOG.json', 'r', encoding='utf-8') as f:
|
|
456
|
+
data = json.load(f)
|
|
457
|
+
|
|
458
|
+
with open('CHANGELOG.md', 'w', encoding='utf-8') as f:
|
|
459
|
+
f.write("# Changelog\n\n")
|
|
460
|
+
|
|
461
|
+
metadata = data.get('metadata', {})
|
|
462
|
+
current_version = metadata.get('currentVersion', 'Unknown')
|
|
463
|
+
last_updated = metadata.get('lastUpdated', 'Unknown')
|
|
464
|
+
|
|
465
|
+
f.write(f"**현재 버전:** {current_version} \n")
|
|
466
|
+
f.write(f"**마지막 업데이트:** {last_updated} \n\n")
|
|
467
|
+
f.write("---\n\n")
|
|
468
|
+
|
|
469
|
+
for release in data.get('releases', []):
|
|
470
|
+
version = release.get('version', 'Unknown')
|
|
471
|
+
date = release.get('date', 'Unknown')
|
|
472
|
+
pr_number = release.get('pr_number')
|
|
473
|
+
|
|
474
|
+
f.write(f"## [{version}] - {date}\n\n")
|
|
475
|
+
|
|
476
|
+
if pr_number is not None:
|
|
477
|
+
f.write(f"**PR:** #{pr_number} \n\n")
|
|
478
|
+
|
|
479
|
+
parsed = release.get('parsed_changes') or {}
|
|
480
|
+
|
|
481
|
+
if parsed:
|
|
482
|
+
# 구조화된 데이터 출력
|
|
483
|
+
for _, items in parsed.items():
|
|
484
|
+
if not items:
|
|
485
|
+
continue
|
|
486
|
+
if isinstance(items, dict) and 'items' in items:
|
|
487
|
+
actual_items = items.get('items') or []
|
|
488
|
+
title = items.get('title') or ''
|
|
489
|
+
else:
|
|
490
|
+
actual_items = items
|
|
491
|
+
title = _normalize_text(_)
|
|
492
|
+
|
|
493
|
+
f.write(f"**{title}**\n")
|
|
494
|
+
for item in actual_items:
|
|
495
|
+
f.write(f"- {item}\n")
|
|
496
|
+
f.write("\n")
|
|
497
|
+
else:
|
|
498
|
+
# 파싱 실패 시 raw_summary 출력
|
|
499
|
+
raw_summary = release.get('raw_summary', '').strip()
|
|
500
|
+
if raw_summary:
|
|
501
|
+
raw_summary = _clean_summary_noise(raw_summary)
|
|
502
|
+
if raw_summary:
|
|
503
|
+
f.write(raw_summary + "\n\n")
|
|
504
|
+
else:
|
|
505
|
+
f.write("*변경사항 정보 없음*\n\n")
|
|
506
|
+
else:
|
|
507
|
+
f.write("*변경사항 정보 없음*\n\n")
|
|
508
|
+
|
|
509
|
+
f.write("---\n\n")
|
|
510
|
+
|
|
511
|
+
print("✅ CHANGELOG.md 재생성 완료!")
|
|
512
|
+
return 0
|
|
513
|
+
|
|
514
|
+
except Exception as e:
|
|
515
|
+
print(f"❌ CHANGELOG.md 생성 실패: {e}")
|
|
516
|
+
traceback.print_exc()
|
|
517
|
+
return 1
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def cmd_export_release_notes(version: str, output_path: str | None) -> int:
|
|
521
|
+
"""CHANGELOG에서 해당 버전 릴리즈 노트를 생성."""
|
|
522
|
+
notes_text = ""
|
|
523
|
+
|
|
524
|
+
# 1) CHANGELOG.json 시도
|
|
525
|
+
try:
|
|
526
|
+
if os.path.isfile('CHANGELOG.json'):
|
|
527
|
+
with open('CHANGELOG.json', 'r', encoding='utf-8') as f:
|
|
528
|
+
changelog = json.load(f)
|
|
529
|
+
releases = changelog.get('releases') or []
|
|
530
|
+
matched = next((r for r in releases if str(r.get('version')) == str(version)), None)
|
|
531
|
+
if matched:
|
|
532
|
+
header = f"버전 {matched.get('version')} 업데이트\n\n"
|
|
533
|
+
parsed_changes = matched.get('parsed_changes') or {}
|
|
534
|
+
if parsed_changes:
|
|
535
|
+
category_blocks: list[str] = []
|
|
536
|
+
for _, value in parsed_changes.items():
|
|
537
|
+
title = (value.get('title') or '').strip()
|
|
538
|
+
items = [it for it in (value.get('items') or []) if it]
|
|
539
|
+
if title and items:
|
|
540
|
+
block = "**" + title + "**\n" + "\n".join("- " + it for it in items)
|
|
541
|
+
category_blocks.append(block)
|
|
542
|
+
body = "\n\n".join(category_blocks) if category_blocks else (matched.get('raw_summary') or '').strip()
|
|
543
|
+
else:
|
|
544
|
+
body = (matched.get('raw_summary') or '').strip()
|
|
545
|
+
notes_text = (header + (body or "")).strip()
|
|
546
|
+
except Exception:
|
|
547
|
+
pass
|
|
548
|
+
|
|
549
|
+
# 2) CHANGELOG.md 폴백
|
|
550
|
+
if not notes_text and os.path.isfile('CHANGELOG.md'):
|
|
551
|
+
try:
|
|
552
|
+
with open('CHANGELOG.md', 'r', encoding='utf-8') as f:
|
|
553
|
+
md = f.read()
|
|
554
|
+
pattern = re.compile(rf"^## \[{re.escape(str(version))}\].*$", re.MULTILINE)
|
|
555
|
+
m = pattern.search(md)
|
|
556
|
+
if m:
|
|
557
|
+
start = m.end()
|
|
558
|
+
next_m = re.search(r"^## \\[", md[start:], re.MULTILINE)
|
|
559
|
+
section = md[start: start + next_m.start()] if next_m else md[start:]
|
|
560
|
+
body = section.strip()
|
|
561
|
+
notes_text = (f"버전 {version} 업데이트\n\n" + body).strip()
|
|
562
|
+
except Exception:
|
|
563
|
+
pass
|
|
564
|
+
|
|
565
|
+
# 3) 최종 폴백
|
|
566
|
+
if not notes_text:
|
|
567
|
+
notes_text = f"버전 {version} 업데이트\n앱 안정성 및 사용자 경험이 개선되었습니다."
|
|
568
|
+
|
|
569
|
+
if output_path:
|
|
570
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
571
|
+
f.write(notes_text)
|
|
572
|
+
else:
|
|
573
|
+
sys.stdout.write(notes_text + "\n")
|
|
574
|
+
return 0
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
# ------------------------ ai-summary 엔진 체인 ------------------------
|
|
578
|
+
|
|
579
|
+
_AI_DEFAULT_BASE_URL = "https://models.github.ai/inference"
|
|
580
|
+
_AI_DEFAULT_MODEL = "openai/gpt-4o-mini"
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _build_ai_prompt(commit_lines: list[str], pr_title: str | None, version: str) -> str:
|
|
584
|
+
"""AI에게 보낼 한국어 릴리즈 요약 프롬프트를 구성.
|
|
585
|
+
|
|
586
|
+
요청하는 출력 형식은 규칙 기반 폴백 렌더러(render_fallback_md)와 동일한
|
|
587
|
+
형식으로 맞춘다 — 다운스트림(릴리즈 노트 소비자)이 엔진과 무관하게 단일
|
|
588
|
+
형식만 보게 하기 위함이다.
|
|
589
|
+
"""
|
|
590
|
+
parts = [
|
|
591
|
+
"아래 커밋 목록을 바탕으로 한국어 릴리즈 요약을 작성해줘.",
|
|
592
|
+
f"출력 형식: 첫 줄은 '## [{version}]' 헤더로 시작하고,",
|
|
593
|
+
"해당 항목이 있는 섹션만 다음 이름으로 작성해줘:",
|
|
594
|
+
"'### ✨ 기능', '### 🐛 수정', '### 📝 문서', '### ♻️ 리팩토링', '### ✅ 테스트', '### 🔧 변경사항'.",
|
|
595
|
+
"각 항목은 '- '로 시작하는 불릿으로 작성해줘.",
|
|
596
|
+
]
|
|
597
|
+
if pr_title:
|
|
598
|
+
parts.append(f"PR 제목: {pr_title}")
|
|
599
|
+
parts.append("커밋 목록:")
|
|
600
|
+
parts.extend(f"- {line}" for line in commit_lines)
|
|
601
|
+
return "\n".join(parts)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def call_openai_compatible(base_url: str, token: str, model: str, prompt: str) -> str:
|
|
605
|
+
"""OpenAI 호환 /chat/completions 엔드포인트 호출 후 응답 텍스트 반환."""
|
|
606
|
+
url = base_url.rstrip('/') + "/chat/completions"
|
|
607
|
+
payload = {
|
|
608
|
+
"model": model,
|
|
609
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
610
|
+
}
|
|
611
|
+
data = json.dumps(payload).encode("utf-8")
|
|
612
|
+
req = urllib.request.Request(
|
|
613
|
+
url,
|
|
614
|
+
data=data,
|
|
615
|
+
method="POST",
|
|
616
|
+
headers={
|
|
617
|
+
"Content-Type": "application/json",
|
|
618
|
+
"Authorization": f"Bearer {token}",
|
|
619
|
+
},
|
|
620
|
+
)
|
|
621
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
622
|
+
body = json.loads(resp.read().decode("utf-8"))
|
|
623
|
+
return body["choices"][0]["message"]["content"]
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def cmd_ai_summary(commits_file: str, version: str, output_path: str, pr_title: str | None) -> int:
|
|
627
|
+
"""커밋 목록을 읽어 AI(우선) 또는 규칙 기반 폴백으로 릴리즈 요약을 생성."""
|
|
628
|
+
try:
|
|
629
|
+
with open(commits_file, 'r', encoding='utf-8') as f:
|
|
630
|
+
commit_lines = [line.rstrip('\n').rstrip('\r') for line in f]
|
|
631
|
+
except Exception:
|
|
632
|
+
commit_lines = []
|
|
633
|
+
|
|
634
|
+
ai_api_key = os.environ.get('AI_API_KEY')
|
|
635
|
+
ai_base_url = os.environ.get('AI_API_BASE_URL') or _AI_DEFAULT_BASE_URL
|
|
636
|
+
ai_model = os.environ.get('AI_MODEL') or _AI_DEFAULT_MODEL
|
|
637
|
+
github_token = os.environ.get('GITHUB_TOKEN')
|
|
638
|
+
|
|
639
|
+
engine = None
|
|
640
|
+
summary_text = None
|
|
641
|
+
prompt = _build_ai_prompt(commit_lines, pr_title, version)
|
|
642
|
+
|
|
643
|
+
if ai_api_key:
|
|
644
|
+
try:
|
|
645
|
+
candidate = call_openai_compatible(ai_base_url, ai_api_key, ai_model, prompt)
|
|
646
|
+
if candidate and candidate.strip():
|
|
647
|
+
summary_text = candidate
|
|
648
|
+
engine = "user-api"
|
|
649
|
+
else:
|
|
650
|
+
print("[warn] user-api failed: empty content in response", file=sys.stderr)
|
|
651
|
+
except Exception as e:
|
|
652
|
+
print(f"[warn] user-api failed: {e}", file=sys.stderr)
|
|
653
|
+
|
|
654
|
+
if summary_text is None and github_token:
|
|
655
|
+
try:
|
|
656
|
+
# GitHub Models는 자체 모델 카탈로그만 서빙한다 — 사용자 API용으로
|
|
657
|
+
# AI_MODEL이 오버라이드돼 있어도 여기서는 기본 모델을 쓴다
|
|
658
|
+
# (커스텀 모델명은 models.github.ai에서 404).
|
|
659
|
+
candidate = call_openai_compatible(_AI_DEFAULT_BASE_URL, github_token, _AI_DEFAULT_MODEL, prompt)
|
|
660
|
+
if candidate and candidate.strip():
|
|
661
|
+
summary_text = candidate
|
|
662
|
+
engine = "github-models"
|
|
663
|
+
else:
|
|
664
|
+
print("[warn] github-models failed: empty content in response", file=sys.stderr)
|
|
665
|
+
except Exception as e:
|
|
666
|
+
print(f"[warn] github-models failed: {e}", file=sys.stderr)
|
|
667
|
+
|
|
668
|
+
if summary_text is None:
|
|
669
|
+
classified = classify_commits(commit_lines)
|
|
670
|
+
summary_text = render_fallback_md(classified, version)
|
|
671
|
+
engine = "fallback"
|
|
672
|
+
|
|
673
|
+
write_ok = True
|
|
674
|
+
try:
|
|
675
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
676
|
+
f.write(summary_text)
|
|
677
|
+
except Exception as e:
|
|
678
|
+
# 파일을 못 쓴 사실을 숨기지 않는다 — ok=false로 보고하고,
|
|
679
|
+
# 요약 텍스트는 stderr로 구제 출력한다. 종료 코드는 0 유지
|
|
680
|
+
# (워크플로우 파이프라인을 끊지 않기 위한 계약).
|
|
681
|
+
write_ok = False
|
|
682
|
+
print(f"[warn] output write failed: {e}", file=sys.stderr)
|
|
683
|
+
print(summary_text, file=sys.stderr)
|
|
684
|
+
|
|
685
|
+
print(json.dumps({"ok": write_ok, "engine": engine, "output": output_path}))
|
|
686
|
+
return 0
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
# ------------------------------- CLI -------------------------------
|
|
690
|
+
|
|
691
|
+
def main(argv: list[str] | None = None) -> int:
|
|
692
|
+
parser = argparse.ArgumentParser(
|
|
693
|
+
prog='changelog_manager',
|
|
694
|
+
description='통합 체인지로그 매니저',
|
|
695
|
+
add_help=True
|
|
696
|
+
)
|
|
697
|
+
sub = parser.add_subparsers(dest='command', required=True)
|
|
698
|
+
|
|
699
|
+
sub.add_parser('update-from-summary', help='PR body에서 CHANGELOG.json 갱신')
|
|
700
|
+
sub.add_parser('generate-md', help='CHANGELOG.json → CHANGELOG.md 생성')
|
|
701
|
+
|
|
702
|
+
p_export = sub.add_parser('export', help='특정 버전 릴리즈 노트 추출')
|
|
703
|
+
p_export.add_argument('--version', required=True, help='버전 번호')
|
|
704
|
+
p_export.add_argument('--output', help='출력 파일 경로 (없으면 stdout)')
|
|
705
|
+
|
|
706
|
+
p_ai_summary = sub.add_parser('ai-summary', help='커밋 목록으로 AI/규칙 기반 릴리즈 요약 생성')
|
|
707
|
+
p_ai_summary.add_argument('--commits-file', required=True, help='커밋 제목 목록 파일 (한 줄당 1개)')
|
|
708
|
+
p_ai_summary.add_argument('--version', required=True, help='버전 번호')
|
|
709
|
+
p_ai_summary.add_argument('--output', required=True, help='요약 결과를 저장할 파일 경로')
|
|
710
|
+
p_ai_summary.add_argument('--pr-title', help='PR 제목 (프롬프트 컨텍스트로 사용, 선택)')
|
|
711
|
+
|
|
712
|
+
args = parser.parse_args(argv)
|
|
713
|
+
|
|
714
|
+
if args.command == 'update-from-summary':
|
|
715
|
+
return cmd_update_from_summary()
|
|
716
|
+
if args.command == 'generate-md':
|
|
717
|
+
return cmd_generate_md()
|
|
718
|
+
if args.command == 'export':
|
|
719
|
+
return cmd_export_release_notes(args.version, args.output)
|
|
720
|
+
if args.command == 'ai-summary':
|
|
721
|
+
return cmd_ai_summary(args.commits_file, args.version, args.output, args.pr_title)
|
|
722
|
+
return 2
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
if __name__ == '__main__':
|
|
726
|
+
sys.exit(main())
|