project-auto-wizard 0.13.2 → 0.14.0

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.
Files changed (112) hide show
  1. package/README.md +1 -1
  2. package/bin/project-auto-wizard.js +18 -4
  3. package/package.json +2 -2
  4. package/payload/config/breaking-changes.json +28 -14
  5. package/payload/config/wizard-prompts.yml +102 -46
  6. package/payload/flutter-app/android/fastlane/Fastfile.playstore +44 -32
  7. package/payload/flutter-app/ios/ExportOptions.plist +8 -8
  8. package/payload/flutter-app/ios/fastlane/Fastfile +57 -43
  9. package/payload/scripts/changelog_manager.py +296 -257
  10. package/payload/scripts/issue_helper.py +54 -50
  11. package/payload/scripts/messages.py +2279 -0
  12. package/payload/scripts/truncate_release_notes.py +7 -6
  13. package/payload/scripts/version_manager.py +64 -61
  14. package/payload/version.yml.template +2 -1
  15. package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +17 -12
  16. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +59 -34
  17. package/payload/workflows/common/PROJECT-COMMON-ISSUE-HELPER.yaml +17 -15
  18. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +17 -10
  19. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +54 -44
  20. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +24 -17
  21. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +194 -184
  22. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +226 -213
  23. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +46 -42
  24. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +334 -241
  25. package/payload/workflows/flutter/PROJECT-FLUTTER-APP-BUILD-TRIGGER.yaml +152 -128
  26. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +246 -188
  27. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +353 -238
  28. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +96 -85
  29. package/payload/workflows/go/PROJECT-GO-CI.yaml +47 -36
  30. package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +945 -508
  31. package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +155 -133
  32. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +76 -60
  33. package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +97 -77
  34. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +62 -47
  35. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +942 -505
  36. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +148 -126
  37. package/payload/workflows/react/PROJECT-REACT-CI.yaml +86 -70
  38. package/payload/workflows/react/PROJECT-REACT-CICD.yaml +112 -92
  39. package/payload/workflows/spring/PROJECT-SPRING-CI.yml +112 -82
  40. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +180 -150
  41. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +161 -131
  42. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +970 -549
  43. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +193 -162
  44. package/src/cli/args.js +98 -52
  45. package/src/cli/help.js +8 -41
  46. package/src/commands/doctor.js +85 -89
  47. package/src/commands/dry-run.js +63 -43
  48. package/src/commands/full.js +79 -77
  49. package/src/commands/install-settings.js +16 -15
  50. package/src/commands/interactive-flutter.js +20 -19
  51. package/src/commands/interactive.js +135 -132
  52. package/src/commands/purge.js +47 -42
  53. package/src/commands/status.js +36 -36
  54. package/src/commands/uninstall.js +43 -41
  55. package/src/context.js +12 -11
  56. package/src/core/assets.js +12 -11
  57. package/src/core/baseline.js +27 -27
  58. package/src/core/branches.js +29 -27
  59. package/src/core/branding.js +7 -6
  60. package/src/core/breaking-check.js +22 -21
  61. package/src/core/breaking.js +15 -8
  62. package/src/core/copy/app-files.js +5 -4
  63. package/src/core/copy/flutter-app.js +8 -8
  64. package/src/core/copy/gitignore.js +58 -50
  65. package/src/core/copy/readme.js +45 -35
  66. package/src/core/copy/simple.js +16 -15
  67. package/src/core/copy/workflows.js +131 -124
  68. package/src/core/deploy-style.js +51 -48
  69. package/src/core/detect-fs.js +57 -57
  70. package/src/core/detect.js +67 -65
  71. package/src/core/errors.js +2 -2
  72. package/src/core/flutter-doctor.js +17 -16
  73. package/src/core/flutter-hooks.js +33 -32
  74. package/src/core/flutter-options.js +34 -33
  75. package/src/core/fsutil.js +12 -12
  76. package/src/core/installed-stores.js +4 -4
  77. package/src/core/logger.js +42 -40
  78. package/src/core/paths-resolve.js +88 -86
  79. package/src/core/paths.js +10 -10
  80. package/src/core/release-options.js +8 -8
  81. package/src/core/removal-exec.js +14 -13
  82. package/src/core/removal-plan.js +37 -37
  83. package/src/core/types.js +47 -47
  84. package/src/core/verify.js +44 -38
  85. package/src/core/version-yml.js +76 -68
  86. package/src/core/wizard-env.js +46 -46
  87. package/src/core/wizard-labels.js +63 -41
  88. package/src/i18n/catalog/en/cli.js +50 -0
  89. package/src/i18n/catalog/en/commands.js +187 -0
  90. package/src/i18n/catalog/en/copy.js +19 -0
  91. package/src/i18n/catalog/en/core.js +74 -0
  92. package/src/i18n/catalog/en/core2.js +15 -0
  93. package/src/i18n/catalog/en/ui.js +221 -0
  94. package/src/i18n/catalog/en.js +12 -0
  95. package/src/i18n/catalog/index.js +5 -0
  96. package/src/i18n/catalog/ko/cli.js +50 -0
  97. package/src/i18n/catalog/ko/commands.js +187 -0
  98. package/src/i18n/catalog/ko/copy.js +19 -0
  99. package/src/i18n/catalog/ko/core.js +74 -0
  100. package/src/i18n/catalog/ko/core2.js +15 -0
  101. package/src/i18n/catalog/ko/ui.js +221 -0
  102. package/src/i18n/catalog/ko.js +10 -0
  103. package/src/i18n/index.js +44 -0
  104. package/src/i18n/languages.js +9 -0
  105. package/src/index.js +128 -94
  106. package/src/ui/ansi.js +7 -7
  107. package/src/ui/banner.js +7 -6
  108. package/src/ui/env-plan.js +82 -81
  109. package/src/ui/prompts.js +97 -122
  110. package/src/ui/readline-engine.js +59 -58
  111. package/src/ui/status-cards.js +37 -31
  112. package/src/ui/summary.js +89 -88
@@ -2,22 +2,22 @@
2
2
  """
3
3
  changelog_manager.py
4
4
 
5
- 통합 체인지로그 매니저 스크립트.
5
+ Unified changelog manager script.
6
6
 
7
- 서브커맨드:
8
- - update-from-summary: 릴리즈 요약 Markdown을 파싱하여 CHANGELOG.json 갱신
9
- - generate-md : CHANGELOG.json을 기반으로 CHANGELOG.md 재생성
10
- - export : 특정 버전의 릴리즈 노트를 생성하여 stdout 또는 파일로 저장
11
- - ai-summary : 커밋 목록으로부터 AI(또는 규칙 기반 폴백) 릴리즈 요약 생성
7
+ Subcommands:
8
+ - update-from-summary: parse the release summary Markdown and update CHANGELOG.json
9
+ - generate-md : regenerate CHANGELOG.md from CHANGELOG.json
10
+ - export : build the release notes of one version and write them to stdout or a file
11
+ - ai-summary : build a release summary from a commit list (language model, or the rule-based fallback)
12
12
 
13
- 사용 예:
13
+ Examples:
14
14
  python3 changelog_manager.py update-from-summary
15
15
  python3 changelog_manager.py generate-md
16
16
  python3 changelog_manager.py export --version 0.0.2 --output release_notes.txt
17
17
  python3 changelog_manager.py ai-summary --commits-file commits.txt --version 1.2.3 --output summary.md
18
18
 
19
- 입력 파일:
20
- - pr_body.md: GitHub PR body (Markdown 형식)
19
+ Input file:
20
+ - pr_body.md: GitHub PR body (Markdown)
21
21
  """
22
22
 
23
23
  from __future__ import annotations
@@ -35,42 +35,44 @@ import urllib.error
35
35
  import urllib.request
36
36
 
37
37
  import issue_helper
38
+ from messages import SUPPORTED, t, tn, template, use_utf8_output
38
39
 
39
40
 
40
- # ----------------------------- 공통 유틸 -----------------------------
41
+ # ----------------------------- Common utilities -----------------------------
41
42
 
42
43
  def _normalize_text(text: str) -> str:
43
- """텍스트 정규화: HTML 엔티티 디코딩 및 공백 정리."""
44
+ """Normalize text: decode HTML entities and trim whitespace."""
44
45
  return html.unescape(text).strip()
45
46
 
46
47
 
47
48
  def _clean_summary_noise(text: str) -> str:
48
49
  """
49
- Summary 텍스트에서 불필요한 노이즈 제거.
50
+ Remove unwanted noise from summary text.
50
51
 
51
- 제거 대상:
52
- 1. HTML 주석 (<!-- ... -->)
53
- 2. 남은 HTML 태그
54
- 3. 연속된 빈 줄
52
+ Removed:
53
+ 1. HTML comments (<!-- ... -->)
54
+ 2. Remaining HTML tags
55
+ 3. Runs of blank lines
55
56
  """
56
57
  if not text:
57
58
  return text
58
59
 
59
- # 1. HTML 주석 제거
60
+ # 1. Remove HTML comments
60
61
  text = re.sub(r'<!--.*?-->', '', text, flags=re.DOTALL)
61
62
 
62
- # 2. 남은 HTML 태그 제거
63
+ # 2. Remove remaining HTML tags
63
64
  text = re.sub(r'<[^>]+>', '', text)
64
65
 
65
- # 3. 연속된 빈 줄 정리 (3개 이상 → 2개)
66
+ # 3. Collapse runs of blank lines (3 or more -> 2)
66
67
  text = re.sub(r'\n{3,}', '\n\n', text)
67
68
 
68
69
  return text.strip()
69
70
 
70
71
 
71
72
  def _strip_version_headings(text: str) -> str:
72
- """요약 본문의 `## [1.2.3]` 버전 헤더 줄을 뺀다 — CHANGELOG.md는 릴리스마다
73
- 자체 헤더를 쓰므로, 남겨 두면 커밋이 없는 릴리스에서 헤더가 두 번 찍힌다."""
73
+ """Drop `## [1.2.3]` version heading lines from the summary body — CHANGELOG.md
74
+ writes its own heading per release, so keeping them would print the heading twice
75
+ for a release without commits."""
74
76
  if not text:
75
77
  return text
76
78
  kept = []
@@ -83,52 +85,108 @@ def _strip_version_headings(text: str) -> str:
83
85
 
84
86
 
85
87
  def _make_safe_key(title: str, idx: int) -> str:
86
- """카테고리 제목을 안전한 키로 변환."""
87
- safe_key = re.sub(r'[^a-zA-Z0-9가-힣]', '_', title.lower()).strip('_')
88
+ """Convert a category title into a safe key."""
89
+ # \uac00-\ud7a3 is the Hangul syllable block; titles in either language keep their letters
90
+ safe_key = re.sub(r'[^a-zA-Z0-9\uac00-\ud7a3]', '_', title.lower()).strip('_')
88
91
  return safe_key if safe_key else f"category_{idx}"
89
92
 
90
93
 
91
- # ----------------------- Markdown 파서 (통합) -----------------------
94
+ # Category keys stored in CHANGELOG.json for the standard sections, so the same category
95
+ # is one key whatever language the notes were written in.
96
+ _STANDARD_CATEGORY_KEYS = ('breaking', 'feat', 'fix', 'perf', 'docs', 'refactor', 'test', 'deps', 'changes', 'wip')
97
+
98
+
99
+ def _standard_key_by_title() -> dict:
100
+ """Safe-key form of every standard section title (all supported languages) -> fixed category key."""
101
+ lookup: dict[str, str] = {}
102
+ for lang in SUPPORTED:
103
+ for bucket in _STANDARD_CATEGORY_KEYS:
104
+ title = template(f"changelog.section_{bucket}", lang).lstrip('#').strip()
105
+ lookup[_make_safe_key(title, 0)] = bucket
106
+ return lookup
107
+
108
+
109
+ def _category_key(title: str, idx: int) -> str:
110
+ """Storage key for a category: a fixed key for standard sections in any language, else the safe title key."""
111
+ safe_key = _make_safe_key(title, idx)
112
+ return _standard_key_by_title().get(safe_key, safe_key)
113
+
114
+
115
+ def _category_display_title(key: str, stored_title: str) -> str:
116
+ """Heading to show: standard categories follow the current language, custom ones keep their own title."""
117
+ if key in _STANDARD_CATEGORY_KEYS:
118
+ return t(f"changelog.section_{key}").lstrip('#').strip()
119
+ return stored_title or key
120
+
121
+
122
+ def _normalize_parsed_changes(parsed) -> dict:
123
+ """Read parsed_changes into {fixed key: {'title', 'items'}}.
124
+
125
+ Releases stored before keys became language-independent may hold the same category under
126
+ several keys (`breaking_changes`, a Korean title key, ...); those are merged here by title.
127
+ Entries may also be a bare item list (very old format), whose key doubles as the title."""
128
+ result: dict[str, dict] = {}
129
+ if not isinstance(parsed, dict):
130
+ return result
131
+ for raw_key, entry in parsed.items():
132
+ if isinstance(entry, dict):
133
+ title = _normalize_text(str(entry.get('title') or '')) or _normalize_text(str(raw_key))
134
+ items = entry.get('items') or []
135
+ elif isinstance(entry, list):
136
+ title = _normalize_text(str(raw_key))
137
+ items = entry
138
+ else:
139
+ continue
140
+ key = _category_key(title, len(result))
141
+ target = result.setdefault(key, {'title': title, 'items': []})
142
+ # Only items already collected from an earlier entry are skipped; repeats inside one entry stay as stored
143
+ seen = set(target['items'])
144
+ target['items'].extend(str(it) for it in items if it and str(it) not in seen)
145
+ return result
146
+
147
+
148
+ # ----------------------- Markdown parser (unified) -----------------------
92
149
 
93
150
  def _parse_summary_markdown(md_content: str) -> dict:
94
151
  """
95
- 릴리즈 요약 Markdown을 카테고리/항목으로 파싱.
152
+ Parse release summary Markdown into categories/items.
96
153
 
97
- 3단계 폴백 전략:
98
- 1. 섹션 파싱 (AI 엔진 체인·규칙 기반 폴백이 실제로 생성하는 형식)
99
- 2. 관대한 파싱 (중첩 불릿 등 형식 변형 대응)
100
- 3. 휴리스틱 파싱 (최후 수단)
154
+ Three-stage fallback strategy:
155
+ 1. Section parsing (the format the engine chain and rule-based fallback emit)
156
+ 2. Lenient parsing (tolerates variants such as nested bullets)
157
+ 3. Heuristic parsing (last resort)
101
158
 
102
- 예상 형식 (_build_ai_prompt / render_fallback_md가 지정하는 형식):
159
+ Expected format (as defined by _build_ai_prompt / render_fallback_md); headings in
160
+ any language are accepted because titles are taken verbatim:
103
161
  ## [1.2.3]
104
162
 
105
- ### ✨ 기능
106
- - 사용자 로그인 추가
107
- - 대시보드 위젯 추가
163
+ ### ✨ Features
164
+ - Add user login
165
+ - Add dashboard widget
108
166
 
109
- ### 🐛 수정
110
- - 널 포인터 예외 수정
167
+ ### 🐛 Fixes
168
+ - Fix null pointer exception
111
169
  """
112
- # 1단계: 섹션 파싱
170
+ # Stage 1: section parsing
113
171
  detected = _parse_markdown_sections(md_content)
114
172
  if detected:
115
- print(" → 섹션 파서 성공")
173
+ print(t("changelog.parser_section_ok"))
116
174
  return detected
117
175
 
118
- # 2단계: 관대한 파싱
176
+ # Stage 2: lenient parsing
119
177
  detected = _parse_markdown_lenient(md_content)
120
178
  if detected:
121
- print(" → 관대한 파서 성공")
179
+ print(t("changelog.parser_lenient_ok"))
122
180
  return detected
123
181
 
124
- # 3단계: 휴리스틱 파싱
182
+ # Stage 3: heuristic parsing
125
183
  detected = _parse_markdown_heuristic(md_content)
126
184
  if detected:
127
- print(" → 휴리스틱 파서 성공")
185
+ print(t("changelog.parser_heuristic_ok"))
128
186
  return detected
129
187
 
130
188
 
131
- # `## [1.2.3]` / `## v1.2.3` 처럼 카테고리가 아니라 릴리즈 버전을 가리키는 헤더
189
+ # Headings such as `## [1.2.3]` / `## v1.2.3` point at the release version, not a category
132
190
  _VERSION_HEADING_RE = re.compile(r'^\[?\s*v?\d+(?:\.\d+)*\s*\]?$')
133
191
 
134
192
  _HEADING_RE = re.compile(r'^\s{0,3}(#{2,6})\s+(.+?)\s*#*\s*$')
@@ -137,10 +195,10 @@ _BULLET_RE = re.compile(r'^\s*[\*\-\+]\s+(.+?)\s*$')
137
195
 
138
196
  def _parse_markdown_sections(md_content: str) -> dict:
139
197
  """
140
- 섹션 파서: `### 카테고리` 헤딩 + `- 항목` 불릿 형식.
198
+ Section parser: `### Category` heading + `- item` bullet format.
141
199
 
142
- AI 엔진 체인(사용자 지정 API → Copilot CLI)과 규칙 기반 폴백이
143
- 동일하게 생성하는 형식이므로 1순위로 시도한다.
200
+ The engine chain (user API -> Copilot CLI) and the rule-based fallback emit
201
+ the same format, so it is tried first.
144
202
  """
145
203
  detected: dict[str, dict] = {}
146
204
  order: list[str] = []
@@ -150,11 +208,11 @@ def _parse_markdown_sections(md_content: str) -> dict:
150
208
  heading = _HEADING_RE.match(line)
151
209
  if heading:
152
210
  title = heading.group(2).strip()
153
- # 버전 헤더(`## [1.2.3]`)는 카테고리가 아니다
211
+ # A version heading (`## [1.2.3]`) is not a category
154
212
  if not title or _VERSION_HEADING_RE.match(title):
155
213
  current_key = None
156
214
  continue
157
- key = _make_safe_key(title, len(order))
215
+ key = _category_key(title, len(order))
158
216
  if key not in detected:
159
217
  detected[key] = {'title': title, 'items': []}
160
218
  order.append(key)
@@ -170,7 +228,7 @@ def _parse_markdown_sections(md_content: str) -> dict:
170
228
  if item:
171
229
  detected[current_key]['items'].append(item)
172
230
 
173
- # 헤딩만 있고 항목이 하나도 없으면 이 형식이 아니라고 보고 다음 파서로 넘긴다
231
+ # Headings without a single item mean this is not the format; hand over to the next parser
174
232
  if not any(entry['items'] for entry in detected.values()):
175
233
  return {}
176
234
 
@@ -179,50 +237,49 @@ def _parse_markdown_sections(md_content: str) -> dict:
179
237
 
180
238
  def _parse_markdown_lenient(md_content: str) -> dict:
181
239
  """
182
- 관대한 파서: 형식 변형에 대응.
240
+ Lenient parser: copes with format variants.
183
241
 
184
- 지원:
185
- - 들여쓰기 1~8칸 (탭 포함)
186
- - bold 선택적 (**제목** 또는 제목)
187
- - 다양한 리스트 마커 (*, -, +)
242
+ Supports:
243
+ - 1-8 spaces of indentation (tabs included)
244
+ - optional bold (**Title** or Title)
245
+ - various list markers (*, -, +)
188
246
  """
189
247
  content = md_content.replace('\t', ' ')
190
248
  detected: dict[str, dict] = {}
191
249
 
192
- # 패턴: 카테고리 + 중첩 항목
250
+ # Pattern: category + nested items
193
251
  pattern = r'(?:^|\n)([\*\-\+])\s*(\*\*)?([^\*\n]+?)(\*\*)?\s*\n((?:(?:^|\n)\s{1,8}[\*\-\+]\s+.+)*)'
194
252
  matches = re.findall(pattern, content, re.MULTILINE)
195
253
 
196
254
  for idx, (marker, bold_start, category_title, bold_end, items_text) in enumerate(matches):
197
255
  category_title = category_title.strip()
198
256
 
199
- # 항목 추출
257
+ # Extract items
200
258
  items = re.findall(r'(?:^|\n)\s{1,8}[\*\-\+]\s+(.+)', items_text, re.MULTILINE)
201
259
  items = [item.strip() for item in items if item.strip()]
202
260
 
203
261
  if not category_title and not items:
204
262
  continue
205
263
 
206
- # 너무 긴 제목은 카테고리가 아님
264
+ # A title that long is not a category
207
265
  if len(category_title) > 100:
208
266
  continue
209
267
 
210
- safe_key = _make_safe_key(category_title, idx)
211
- detected[safe_key] = {
212
- 'title': category_title,
213
- 'items': items,
214
- }
268
+ safe_key = _category_key(category_title, idx)
269
+ # Same category twice (e.g. English and Korean titles) is merged, not overwritten
270
+ entry = detected.setdefault(safe_key, {'title': category_title, 'items': []})
271
+ entry['items'].extend(items)
215
272
 
216
273
  return detected
217
274
 
218
275
 
219
276
  def _parse_markdown_heuristic(md_content: str) -> dict:
220
277
  """
221
- 휴리스틱 파서: 줄 단위로 카테고리/항목 추론.
278
+ Heuristic parser: infer categories/items line by line.
222
279
 
223
- 규칙:
224
- 1. Bold 텍스트(**...**) → 카테고리
225
- 2. 들여쓰기 있는 줄 → 항목
280
+ Rules:
281
+ 1. Bold text (**...**) -> category
282
+ 2. Indented line -> item
226
283
  """
227
284
  lines = md_content.split('\n')
228
285
  detected: dict[str, dict] = {}
@@ -234,18 +291,18 @@ def _parse_markdown_heuristic(md_content: str) -> dict:
234
291
  if not stripped or stripped.startswith('<!--') or stripped.startswith('##'):
235
292
  continue
236
293
 
237
- # Bold 텍스트 → 카테고리
294
+ # Bold text -> category
238
295
  bold_match = re.search(r'\*\*([^\*]+)\*\*', stripped)
239
296
  if bold_match:
240
297
  title = bold_match.group(1).strip()
241
298
  title = re.sub(r'^[\*\-\+\d\.]+\s*', '', title).strip()
242
299
 
243
300
  if title and len(title) < 100:
244
- current_key = _make_safe_key(title, len(detected))
245
- detected[current_key] = {'title': title, 'items': []}
301
+ current_key = _category_key(title, len(detected))
302
+ detected.setdefault(current_key, {'title': title, 'items': []})
246
303
  continue
247
304
 
248
- # 들여쓰기 있는 줄 → 항목
305
+ # Indented line -> item
249
306
  if line.startswith((' ', '\t')) and stripped:
250
307
  item = re.sub(r'^[\*\-\+\d\.]+\s*', '', stripped).strip()
251
308
  item = re.sub(r'<[^>]+>', '', item).strip()
@@ -253,19 +310,19 @@ def _parse_markdown_heuristic(md_content: str) -> dict:
253
310
  if current_key and item and len(item) > 3:
254
311
  detected[current_key]['items'].append(item)
255
312
 
256
- # 빈 카테고리 제거
313
+ # Drop empty categories
257
314
  return {k: v for k, v in detected.items() if v.get('items')}
258
315
 
259
316
 
260
- # ------------------------ 3단계 규칙 기반 폴백 파서 ------------------------
317
+ # ------------------------ 3-tier rule-based fallback parser ------------------------
261
318
 
262
- # 1단계 패턴은 제목도 같은 정규식에서 캡처한다 — " : type : " 마커(타입 앞 콜론에
263
- # 반드시 공백 선행)가 유일한 구분자이므로, 제목 안의 맨몸 콜론("v1:2" 등)에서
264
- # 잘리지 않는다. 별도 split 재수행 금지.
265
- # 타입은 대소문자를 가리지 않는다(Conventional Commits 스펙). `!`는 breaking 표시.
319
+ # The tier-1 pattern captures the title in the same regex — the " : type : " marker
320
+ # (the colon before the type must be preceded by a space) is the only separator, so a
321
+ # bare colon inside the title ("v1:2" etc.) does not cut it short. Do not re-split.
322
+ # Types are case-insensitive (per Conventional Commits). `!` marks a breaking change.
266
323
  _TIER1_RE = re.compile(r'^(.+?)\s:\s*(feat|fix|chore|docs|refactor|test)\s*(!)?\s*:\s*(.+)$', re.IGNORECASE)
267
324
  _TRAILING_URL_RE = re.compile(r'\s*https?://\S+$')
268
- # `feat : 내용`처럼 콜론 앞 공백도 흔한 표기라 허용한다.
325
+ # A space before the colon, as in `feat : text`, is common, so it is allowed.
269
326
  _TIER2_RE = re.compile(
270
327
  r'^(feat|fix|chore|docs|refactor|test|perf|style|build|ci)(\([^)]*\))?\s*(!)?\s*:\s*(.+)$',
271
328
  re.IGNORECASE,
@@ -285,14 +342,16 @@ _TIER2_BUCKET_MAP = {
285
342
 
286
343
  _FALLBACK_BUCKET_KEYS = ('breaking', 'feat', 'fix', 'perf', 'chore', 'docs', 'refactor', 'test', 'deps', 'changes', 'wip')
287
344
 
288
- # 의존성 갱신(Dependabot·Renovate 등)과 작업 중 커밋은 일반 변경사항과 섞이면 노트가 흐려진다.
345
+ # Dependency updates (Dependabot, Renovate, ...) and work-in-progress commits blur the notes when mixed with regular changes.
289
346
  _DEPS_SCOPE_RE = re.compile(r'^\(deps(?:-dev)?\)$', re.IGNORECASE)
290
347
  _DEPS_FREEFORM_RE = re.compile(r'^Bump \S+ from \S+ to \S+', re.IGNORECASE)
291
348
  _WIP_RE = re.compile(r'^\[?wip\b', re.IGNORECASE)
292
349
 
293
- # 승격 폭 판단 전용 — 표준 타입 뒤 `!` 마커와 본문 푸터 `BREAKING CHANGE:`가 breaking 신호.
294
- # `hotfix!:`·`WIP!:`처럼 표준 타입이 아닌 단어의 `!`는 되돌리기 어려운 major를 만들므로 인정하지 않는다.
295
- # 푸터는 커밋 목록에 본문 줄이 함께 들어올 때만 보인다(제목만 수집하면 `!` 마커만 판정된다).
350
+ # Used only for the bump level — a `!` marker after a standard type and a body footer
351
+ # `BREAKING CHANGE:` are the breaking signals.
352
+ # A `!` after a non-standard word such as `hotfix!:` / `WIP!:` is not accepted, since it would
353
+ # cause a hard-to-undo major bump.
354
+ # The footer is only visible when body lines are part of the commit list (with subjects only, just the `!` marker counts).
296
355
  _BREAKING_FOOTER_RE = re.compile(r'^BREAKING[ -]CHANGE\s*:\s*(.*)$')
297
356
 
298
357
 
@@ -308,15 +367,15 @@ def _is_breaking(line: str) -> bool:
308
367
 
309
368
  def classify_commits(lines: list[str]) -> dict:
310
369
  """
311
- 커밋 제목 목록을 3단계 규칙으로 분류.
370
+ Classify commit subjects with a 3-tier rule set.
312
371
 
313
- 1단계: 제목 컨벤션 — "제목 : type : 내용 [URL]"
314
- 2단계: Conventional Commits — "type(scope)!: 내용"
315
- (style/build/ci → chore, chore(deps)/build(deps) → deps 버킷)
316
- 3단계: 위 두 형식에 매칭되지 않으면 "changes" 버킷 (자유 형식, Bump… → deps, WIP → wip)
317
- `!` 마커·BREAKING CHANGE 푸터는 "breaking" 버킷으로 모은다.
372
+ Tier 1: title convention — "title : type : text [URL]"
373
+ Tier 2: Conventional Commits — "type(scope)!: text"
374
+ (style/build/ci -> chore, chore(deps)/build(deps) -> deps bucket)
375
+ Tier 3: lines matching neither go to the "changes" bucket (free form, Bump... -> deps, WIP -> wip)
376
+ `!` markers and BREAKING CHANGE footers are collected in the "breaking" bucket.
318
377
 
319
- 제외 대상 (매칭 전에 걸러냄): [skip ci] 포함 줄, "Merge "로 시작하는 줄, 빈 줄.
378
+ Excluded (filtered before matching): lines containing [skip ci], lines starting with "Merge ", blank lines.
320
379
  """
321
380
  classified: dict[str, list[str]] = {key: [] for key in _FALLBACK_BUCKET_KEYS}
322
381
 
@@ -334,18 +393,19 @@ def classify_commits(lines: list[str]) -> dict:
334
393
  classified['breaking'].append(footer.group(1).strip())
335
394
  continue
336
395
 
337
- # 1단계가 2단계보다 먼저다 — 트레이드오프: "제목 : feat : 내용" 형식은
338
- # "feat: ..." Conventional Commits와 겹칠 수 없지만(타입 앞에 제목 필수),
339
- # 제목이 있는 줄에 " : type : "가 우연히 들어가면 tier-2 해석 기회 없이
340
- # tier-1로 확정된다. 이 컨벤션을 쓰는 레포에서는 이것이 의도된 우선순위다.
396
+ # Tier 1 runs before tier 2 — trade-off: the "title : feat : text" format cannot
397
+ # overlap with "feat: ..." Conventional Commits (a title must precede the type),
398
+ # but a line with a title that happens to contain " : type : " is settled as
399
+ # tier 1 with no chance for tier-2 interpretation. For repos using this
400
+ # convention that is the intended priority.
341
401
  tier1 = _TIER1_RE.match(line)
342
402
  if tier1:
343
403
  title = tier1.group(1).strip()
344
404
  commit_type = tier1.group(2).lower()
345
405
  desc = tier1.group(4).strip()
346
- # 커밋 말미의 이슈 URL은 릴리즈 노트 렌더링에서 노이즈 — 제거.
406
+ # A trailing issue URL on a commit is noise in the release notes — remove it.
347
407
  desc = _TRAILING_URL_RE.sub('', desc).strip()
348
- # breaking 커밋은 major 승격의 근거라 별도 섹션에 드러낸다.
408
+ # A breaking commit justifies a major bump, so it gets its own section.
349
409
  bucket = 'breaking' if tier1.group(3) else commit_type
350
410
  classified[bucket].append(f"{title} — {desc}")
351
411
  continue
@@ -372,28 +432,24 @@ def classify_commits(lines: list[str]) -> dict:
372
432
  return classified
373
433
 
374
434
 
375
- _FALLBACK_SECTION_TITLES = {
376
- 'breaking': '### ⚠️ 호환성 깨짐',
377
- 'feat': '### ✨ 기능',
378
- 'fix': '### 🐛 수정',
379
- 'perf': '### ⚡ 성능',
380
- 'docs': '### 📝 문서',
381
- 'refactor': '### ♻️ 리팩토링',
382
- 'test': '### ✅ 테스트',
383
- 'deps': '### 📦 의존성',
384
- 'wip': '### 🚧 작업 중',
385
- }
435
+ # Section titles come from the message catalog so they follow the configured language;
436
+ # resolved lazily (not at import time) so the language can be switched per run.
437
+ _FALLBACK_SECTION_KEYS = ('breaking', 'feat', 'fix', 'perf', 'docs', 'refactor', 'test', 'deps', 'changes', 'wip')
438
+
439
+
440
+ def _section_title(bucket_key: str) -> str:
441
+ return t(f"changelog.section_{bucket_key}")
386
442
 
387
443
 
388
444
  def render_fallback_md(classified: dict, version: str) -> str:
389
- """분류된 커밋 딕셔너리를 마크다운 릴리즈 노트로 렌더링."""
445
+ """Render the classified commit dict as Markdown release notes."""
390
446
  lines: list[str] = [f"## [{version}]", ""]
391
447
 
392
448
  def add_section(bucket_key):
393
449
  items = classified.get(bucket_key) or []
394
450
  if not items:
395
451
  return
396
- lines.append(_FALLBACK_SECTION_TITLES[bucket_key])
452
+ lines.append(_section_title(bucket_key))
397
453
  for item in items:
398
454
  lines.append(f"- {item}")
399
455
  lines.append("")
@@ -405,7 +461,7 @@ def render_fallback_md(classified: dict, version: str) -> str:
405
461
  changes_items = list(classified.get('changes') or [])
406
462
  merged = chore_items + changes_items
407
463
  if merged:
408
- lines.append("### 🔧 변경사항")
464
+ lines.append(_section_title('changes'))
409
465
  for item in merged:
410
466
  lines.append(f"- {item}")
411
467
  lines.append("")
@@ -416,11 +472,11 @@ def render_fallback_md(classified: dict, version: str) -> str:
416
472
 
417
473
 
418
474
  def classify_bump_level(lines: list[str]) -> str:
419
- """커밋 제목 목록에서 semver 승격 폭을 규칙 기반으로 판단.
475
+ """Decide the semver bump level from commit subjects, rule-based.
420
476
 
421
- - 표준 타입 뒤 `!` 마커 또는 `BREAKING CHANGE:` 푸터 포함 -> major
422
- - `feat:`(classify_commits의 feat 버킷과 동일 판정 기준) 포함 -> minor
423
- - 그 외(매칭 실패 포함) -> patch
477
+ - a `!` marker after a standard type, or a `BREAKING CHANGE:` footer -> major
478
+ - `feat:` (same criterion as the feat bucket of classify_commits) -> minor
479
+ - anything else (including no match) -> patch
424
480
  """
425
481
  for raw_line in lines:
426
482
  line = raw_line.strip()
@@ -432,22 +488,19 @@ def classify_bump_level(lines: list[str]) -> str:
432
488
  return 'minor' if classified.get('feat') else 'patch'
433
489
 
434
490
 
435
- _BUMP_AI_PROMPT_PREFIX = (
436
- "다음은 정해진 커밋 컨벤션을 따르지 않는 자유형식 커밋 메시지들이다.\n"
437
- "이 중 사용자 대상 새로운 기능(feature) 추가로 보이는 것이 하나라도 있으면 정확히 MINOR라고만 답하고,\n"
438
- "없으면 정확히 PATCH라고만 답해라. 다른 말은 절대 덧붙이지 마라.\n"
439
- "커밋 목록:\n"
440
- )
491
+ def _bump_ai_prompt_prefix() -> str:
492
+ # The reply tokens MINOR/PATCH are parsed by code, so every language must ask for exactly those.
493
+ return t("changelog.bump_prompt_prefix")
441
494
 
442
495
 
443
496
  def _ai_assisted_minor_upgrade(unclassified_lines: list[str]) -> bool:
444
- """규칙 분류가 patch일 때, 미분류 자유형식 커밋에 한해 AI에게 minor 업그레이드
445
- 여부만 보조 판단시킨다. AI는 절대 major를 만들 수 없다 — major는 항상 명시적
446
- `!` 마커만 신뢰한다(classify_bump_level에서 이미 확정됨). 응답이 정확히
447
- 'MINOR'가 아니거나 호출이 실패하면 무조건 False(규칙 결과 patch 유지)."""
497
+ """When the rules say patch, ask the model only whether the unclassified free-form
498
+ commits justify a minor upgrade. The model can never produce a major — major only ever
499
+ trusts an explicit `!` marker (already settled in classify_bump_level). If the reply
500
+ is not exactly 'MINOR' or the call fails, always return False (keep the rule result, patch)."""
448
501
  if not unclassified_lines:
449
502
  return False
450
- prompt = _BUMP_AI_PROMPT_PREFIX + "\n".join(f"- {line}" for line in unclassified_lines)
503
+ prompt = _bump_ai_prompt_prefix() + "\n".join(f"- {line}" for line in unclassified_lines)
451
504
 
452
505
  settings = _user_api_settings()
453
506
  if settings:
@@ -495,11 +548,11 @@ def cmd_collect_issue_closes(commit_shas_file: str, merged_prs_file: str) -> int
495
548
 
496
549
 
497
550
  def cmd_classify_bump(commits_file: str) -> int:
498
- """커밋 목록 파일을 읽어 semver 승격 폭(major/minor/patch)을 stdout 마지막 줄에 출력.
551
+ """Read a commit list file and print the semver bump level (major/minor/patch) as the last stdout line.
499
552
 
500
- 규칙 우선(feat->minor, !마커->major, 그외->patch). 규칙 결과가 patch이고
501
- 분류 안 된 자유형식 커밋이 있으면, AI에게 patch->minor 업그레이드 여부만
502
- 보조 판단시킨다(major는 AI가 절대 만들 수 없음).
553
+ Rules first (feat->minor, ! marker->major, else patch). If the rule result is patch
554
+ and there are unclassified free-form commits, ask the model only whether to upgrade
555
+ patch->minor (the model can never produce a major).
503
556
  """
504
557
  try:
505
558
  with open(commits_file, 'r', encoding='utf-8') as f:
@@ -517,12 +570,12 @@ def cmd_classify_bump(commits_file: str) -> int:
517
570
  return 0
518
571
 
519
572
 
520
- # ------------------------ 서브커맨드 구현부 ------------------------
573
+ # ------------------------ Subcommand implementations ------------------------
521
574
 
522
575
  def cmd_update_from_summary() -> int:
523
- """pr_body.md에서 Markdown을 파싱하여 CHANGELOG.json 갱신."""
576
+ """Parse Markdown from pr_body.md and update CHANGELOG.json."""
524
577
  version = os.environ.get('VERSION')
525
- # PROJECT_TYPES(csv)가 유일한 입력 — 단수 PROJECT_TYPE 폴백은 제거됐다
578
+ # PROJECT_TYPES (csv) is the only input — the singular PROJECT_TYPE fallback is gone
526
579
  project_types_csv = os.environ.get('PROJECT_TYPES', '')
527
580
  project_types = [t.strip() for t in project_types_csv.split(',') if t.strip()]
528
581
  today = os.environ.get('TODAY')
@@ -534,7 +587,7 @@ def cmd_update_from_summary() -> int:
534
587
  except ValueError:
535
588
  pr_number = None
536
589
 
537
- # 입력 파일 찾기 (pr_body.md 우선, 폴백으로 summary_section.html)
590
+ # Find the input file (pr_body.md first, summary_section.html as fallback)
538
591
  input_file = None
539
592
  for filename in ['pr_body.md', 'summary_section.html']:
540
593
  if os.path.isfile(filename):
@@ -542,30 +595,30 @@ def cmd_update_from_summary() -> int:
542
595
  break
543
596
 
544
597
  if not input_file:
545
- print("❌ 입력 파일을 찾을 수 없습니다 (pr_body.md 또는 summary_section.html)")
598
+ print(t("changelog.err_no_input"))
546
599
  return 1
547
600
 
548
601
  try:
549
602
  with open(input_file, 'r', encoding='utf-8') as f:
550
603
  content = f.read()
551
604
 
552
- print(f"📄 입력 파일: {input_file}")
553
- print(f"📝 파일 크기: {len(content)} bytes")
605
+ print(t("changelog.input_file", input_file=input_file))
606
+ print(t("changelog.file_size", size=len(content)))
554
607
 
555
- # Markdown 파싱 (통합)
556
- print("\n🔍 Markdown 파싱 시작...")
608
+ # Markdown parsing (unified)
609
+ print(t("changelog.parse_start"))
557
610
  categories = _parse_summary_markdown(content)
558
611
 
559
612
  parse_method = 'markdown' if categories else 'markdown_failed'
560
613
  if categories:
561
- print(f"✅ 파싱 성공: {len(categories)}개 카테고리")
614
+ print(tn("changelog.parse_ok", len(categories)))
562
615
  else:
563
- print("⚠️ 파싱 실패, raw_summary만 저장")
616
+ print(t("changelog.parse_failed"))
564
617
 
565
- # raw_summary 생성 (노이즈 제거)
618
+ # Build raw_summary (noise removed)
566
619
  raw_summary = _strip_version_headings(_clean_summary_noise(content))
567
620
 
568
- # 릴리즈 데이터 생성
621
+ # Build the release record
569
622
  new_release = {
570
623
  "version": version,
571
624
  "project_types": project_types,
@@ -576,24 +629,24 @@ def cmd_update_from_summary() -> int:
576
629
  "parse_method": parse_method,
577
630
  }
578
631
 
579
- # 파싱 결과 출력
580
- print("\n📊 파싱 결과:")
581
- print(f" - 파싱 방식: {parse_method}")
582
- print(f" - raw_summary 길이: {len(raw_summary)} 문자")
583
- print(f" - 파싱된 카테고리: {len(categories)}개")
632
+ # Print the parse result
633
+ print(t("changelog.result_header"))
634
+ print(t("changelog.result_method", method=parse_method))
635
+ print(t("changelog.result_raw_len", n=len(raw_summary)))
636
+ print(t("changelog.result_categories", n=len(categories)))
584
637
  for key, value in categories.items():
585
638
  title = value.get('title', key)
586
639
  items_count = len(value.get('items', []))
587
- print(f" • {title}: {items_count}개 항목")
640
+ print(tn("changelog.result_category_item", items_count, title=title))
588
641
 
589
- # CHANGELOG.json 업데이트
642
+ # Update CHANGELOG.json
590
643
  try:
591
644
  with open('CHANGELOG.json', 'r', encoding='utf-8') as f:
592
645
  changelog_data = json.load(f)
593
646
  except json.JSONDecodeError as e:
594
- # 머지 충돌 마커 등으로 깨진 파일을 새 구조로 덮으면 기존 이력이 전부 사라진다.
595
- print(f"❌ CHANGELOG.json을 해석할 수 없어 갱신을 중단합니다 (기존 이력 보호): {e}")
596
- print(f"::error::CHANGELOG.json이 올바른 JSON이 아닙니다: {e}", file=sys.stderr)
647
+ # Overwriting a file broken by e.g. merge-conflict markers with a fresh structure would erase all history.
648
+ print(t("changelog.err_json_unreadable", error=e))
649
+ print(t("changelog.err_json_annotation", error=e), file=sys.stderr)
597
650
  return 1
598
651
  except FileNotFoundError:
599
652
  changelog_data = {
@@ -606,8 +659,8 @@ def cmd_update_from_summary() -> int:
606
659
  "releases": [],
607
660
  }
608
661
 
609
- # 방어: 파일이 존재하지만 스캐폴드 등 비정형 구조({"versions": []})라
610
- # metadata/releases 키가 없을 수 있다 — 릴리스를 절대 막지 않는다 (실측: dogfood PR #1)
662
+ # Defensive: the file may exist in an irregular scaffold structure ({"versions": []})
663
+ # without metadata/releases keys — never block a release because of it
611
664
  if not isinstance(changelog_data, dict):
612
665
  changelog_data = {}
613
666
  changelog_data.setdefault("metadata", {})
@@ -615,7 +668,7 @@ def cmd_update_from_summary() -> int:
615
668
  changelog_data["metadata"]["lastUpdated"] = timestamp
616
669
  changelog_data["metadata"]["currentVersion"] = version
617
670
  changelog_data["metadata"]["projectTypes"] = project_types
618
- # 같은 버전은 교체한다 — 워크플로우 재실행 시 항목이 중복으로 쌓이지 않게.
671
+ # Replace the same version — so re-running the workflow does not pile up duplicates.
619
672
  releases = [
620
673
  r for r in (changelog_data.get("releases") or [])
621
674
  if not (isinstance(r, dict) and str(r.get("version")) == str(version))
@@ -627,17 +680,17 @@ def cmd_update_from_summary() -> int:
627
680
  with open('CHANGELOG.json', 'w', encoding='utf-8') as f:
628
681
  json.dump(changelog_data, f, indent=2, ensure_ascii=False)
629
682
 
630
- print("\n✅ CHANGELOG.json 업데이트 완료!")
683
+ print(t("changelog.json_updated"))
631
684
  return 0
632
685
 
633
686
  except Exception as e:
634
- print(f"❌ update-from-summary 실패: {e}")
687
+ print(t("changelog.err_update_failed", error=e))
635
688
  traceback.print_exc()
636
689
  return 1
637
690
 
638
691
 
639
692
  def cmd_generate_md() -> int:
640
- """CHANGELOG.json을 기반으로 CHANGELOG.md 재생성."""
693
+ """Regenerate CHANGELOG.md from CHANGELOG.json."""
641
694
  try:
642
695
  with open('CHANGELOG.json', 'r', encoding='utf-8') as f:
643
696
  data = json.load(f)
@@ -649,8 +702,8 @@ def cmd_generate_md() -> int:
649
702
  current_version = metadata.get('currentVersion', 'Unknown')
650
703
  last_updated = metadata.get('lastUpdated', 'Unknown')
651
704
 
652
- f.write(f"**현재 버전:** {current_version} \n")
653
- f.write(f"**마지막 업데이트:** {last_updated} \n\n")
705
+ f.write(t("changelog.md_current_version", version=current_version) + " \n")
706
+ f.write(t("changelog.md_last_updated", updated=last_updated) + " \n\n")
654
707
  f.write("---\n\n")
655
708
 
656
709
  for release in data.get('releases', []):
@@ -663,52 +716,43 @@ def cmd_generate_md() -> int:
663
716
  if pr_number is not None:
664
717
  f.write(f"**PR:** #{pr_number} \n\n")
665
718
 
666
- parsed = release.get('parsed_changes') or {}
719
+ parsed = _normalize_parsed_changes(release.get('parsed_changes'))
667
720
 
668
721
  if parsed:
669
- # 구조화된 데이터 출력
670
- for _, items in parsed.items():
671
- if not items:
672
- continue
673
- if isinstance(items, dict) and 'items' in items:
674
- actual_items = items.get('items') or []
675
- title = items.get('title') or ''
676
- else:
677
- actual_items = items
678
- title = _normalize_text(_)
679
-
680
- f.write(f"**{title}**\n")
681
- for item in actual_items:
722
+ # Print the structured data; headings follow the current language
723
+ for key, entry in parsed.items():
724
+ f.write(f"**{_category_display_title(key, entry['title'])}**\n")
725
+ for item in entry['items']:
682
726
  f.write(f"- {item}\n")
683
727
  f.write("\n")
684
728
  else:
685
- # 파싱 실패 시 raw_summary 출력
729
+ # On parse failure, print raw_summary
686
730
  raw_summary = release.get('raw_summary', '').strip()
687
731
  if raw_summary:
688
732
  raw_summary = _strip_version_headings(_clean_summary_noise(raw_summary))
689
733
  if raw_summary:
690
734
  f.write(raw_summary + "\n\n")
691
735
  else:
692
- f.write("*변경사항 정보 없음*\n\n")
736
+ f.write(t("changelog.md_no_changes") + "\n\n")
693
737
  else:
694
- f.write("*변경사항 정보 없음*\n\n")
738
+ f.write(t("changelog.md_no_changes") + "\n\n")
695
739
 
696
740
  f.write("---\n\n")
697
741
 
698
- print("✅ CHANGELOG.md 재생성 완료!")
742
+ print(t("changelog.md_regenerated"))
699
743
  return 0
700
744
 
701
745
  except Exception as e:
702
- print(f"❌ CHANGELOG.md 생성 실패: {e}")
746
+ print(t("changelog.err_md_failed", error=e))
703
747
  traceback.print_exc()
704
748
  return 1
705
749
 
706
750
 
707
751
  def cmd_export_release_notes(version: str, output_path: str | None) -> int:
708
- """CHANGELOG에서 해당 버전 릴리즈 노트를 생성."""
752
+ """Build the release notes of the given version from the CHANGELOG."""
709
753
  notes_text = ""
710
754
 
711
- # 1) CHANGELOG.json 시도
755
+ # 1) Try CHANGELOG.json
712
756
  try:
713
757
  if os.path.isfile('CHANGELOG.json'):
714
758
  with open('CHANGELOG.json', 'r', encoding='utf-8') as f:
@@ -716,24 +760,23 @@ def cmd_export_release_notes(version: str, output_path: str | None) -> int:
716
760
  releases = changelog.get('releases') or []
717
761
  matched = next((r for r in releases if str(r.get('version')) == str(version)), None)
718
762
  if matched:
719
- header = f"버전 {matched.get('version')} 업데이트\n\n"
720
- parsed_changes = matched.get('parsed_changes') or {}
763
+ header = t("changelog.export_header", version=matched.get('version')) + "\n\n"
764
+ parsed_changes = _normalize_parsed_changes(matched.get('parsed_changes'))
721
765
  if parsed_changes:
722
766
  category_blocks: list[str] = []
723
- for _, value in parsed_changes.items():
724
- title = (value.get('title') or '').strip()
725
- items = [it for it in (value.get('items') or []) if it]
726
- if title and items:
727
- block = "**" + title + "**\n" + "\n".join("- " + it for it in items)
767
+ for key, entry in parsed_changes.items():
768
+ title = _category_display_title(key, entry['title']).strip()
769
+ if title and entry['items']:
770
+ block = "**" + title + "**\n" + "\n".join("- " + it for it in entry['items'])
728
771
  category_blocks.append(block)
729
772
  body = "\n\n".join(category_blocks) if category_blocks else _strip_version_headings((matched.get('raw_summary') or '').strip())
730
773
  else:
731
774
  body = _strip_version_headings((matched.get('raw_summary') or '').strip())
732
775
  notes_text = (header + (body or "")).strip()
733
776
  except Exception as e:
734
- print(f"::warning::CHANGELOG.json에서 {version} 노트를 읽지 못했습니다: {e}", file=sys.stderr)
777
+ print(t("changelog.warn_json_notes", version=version, error=e), file=sys.stderr)
735
778
 
736
- # 2) CHANGELOG.md 폴백
779
+ # 2) CHANGELOG.md fallback
737
780
  if not notes_text and os.path.isfile('CHANGELOG.md'):
738
781
  try:
739
782
  with open('CHANGELOG.md', 'r', encoding='utf-8') as f:
@@ -744,16 +787,16 @@ def cmd_export_release_notes(version: str, output_path: str | None) -> int:
744
787
  start = m.end()
745
788
  next_m = re.search(r"^## \[", md[start:], re.MULTILINE)
746
789
  section = md[start: start + next_m.start()] if next_m else md[start:]
747
- # generate-md가 릴리스 사이에 넣는 구분선은 노트 내용이 아니다
790
+ # The separator generate-md puts between releases is not note content
748
791
  body = re.sub(r'(?:\n\s*-{3,}\s*)+$', '', '\n' + section.strip()).strip()
749
- notes_text = (f"버전 {version} 업데이트\n\n" + body).strip()
792
+ notes_text = (t("changelog.export_header", version=version) + "\n\n" + body).strip()
750
793
  except Exception as e:
751
- # 조용히 삼키면 고정 문구 폴백이 정상 출력처럼 보여 원인을 알 수 없다.
752
- print(f"::warning::CHANGELOG.md에서 {version} 노트를 읽지 못했습니다: {e}", file=sys.stderr)
794
+ # Swallowing it silently would make the fixed-text fallback look like normal output with no visible cause.
795
+ print(t("changelog.warn_md_notes", version=version, error=e), file=sys.stderr)
753
796
 
754
- # 3) 최종 폴백
797
+ # 3) Final fallback
755
798
  if not notes_text:
756
- notes_text = f"버전 {version} 업데이트\n앱 안정성 및 사용자 경험이 개선되었습니다."
799
+ notes_text = t("changelog.export_fallback", version=version)
757
800
 
758
801
  if output_path:
759
802
  with open(output_path, 'w', encoding='utf-8') as f:
@@ -763,17 +806,17 @@ def cmd_export_release_notes(version: str, output_path: str | None) -> int:
763
806
  return 0
764
807
 
765
808
 
766
- # ------------------------ ai-summary 엔진 체인 ------------------------
809
+ # ------------------------ ai-summary engine chain ------------------------
767
810
 
768
- # Copilot Free/Student 계정은 모델명 지정이 거부되고 auto 모델 선택만 허용된다.
811
+ # Copilot Free/Student accounts reject an explicit model name and only allow auto model selection.
769
812
  _COPILOT_MODEL = "auto"
770
813
  _COPILOT_TIMEOUT_SECONDS = 90
771
814
 
772
815
 
773
816
  def _warn_engine_failure(message: str) -> str:
774
- """엔진 실패를 Actions 실행 요약(Annotations)에 경고로 띄운다.
775
- 평문 로그만 남기면 잡 로그를 열기 전에는 fallback 사유를 알 수 없다.
776
- stdout은 결과 JSON 계약용이라 stderr로 쓴다. 반환값은 한 줄로 줄인 사유."""
817
+ """Surface an engine failure as a warning in the Actions run summary (Annotations).
818
+ With plain logs only, the fallback reason stays hidden until the job log is opened.
819
+ stdout is reserved for the result JSON contract, so this writes to stderr. Returns the reason squeezed onto one line."""
777
820
  reason = " ".join(str(message).split())[:200]
778
821
  escaped = reason.replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A')
779
822
  print(f"::warning::{escaped}", file=sys.stderr)
@@ -781,42 +824,36 @@ def _warn_engine_failure(message: str) -> str:
781
824
 
782
825
 
783
826
  def _user_api_settings() -> tuple[str, str, str] | None:
784
- """사용자 지정 AI 티어 설정. AI_API_KEY와 AI_API_BASE_URL, AI_MODEL이 모두 있어야 한다.
827
+ """Settings for the user-supplied model tier. AI_API_KEY, AI_API_BASE_URL and AI_MODEL must all be present.
785
828
 
786
- 키만 있고 URL·모델이 비어 있으면 그 키를 어디로도 보내지 않고 경고 후 건너뛴다
787
- (종료된 기본 엔드포인트으로 사용자 키가 흘러가던 문제 방지)."""
829
+ If only the key is set and URL/model are empty, the key is sent nowhere: warn and skip
830
+ (prevents the user's key from flowing to a retired default endpoint)."""
788
831
  api_key = os.environ.get('AI_API_KEY')
789
832
  base_url = os.environ.get('AI_API_BASE_URL')
790
833
  model = os.environ.get('AI_MODEL')
791
834
  if not api_key:
792
- # 변수만 등록하고 secret을 빠뜨리면 아무 표시 없이 규칙 요약으로 넘어가 설정이 먹은 줄 안다
835
+ # If only the variables are registered and the secret is missing, it falls to the rule summary unnoticed and the user thinks the setup worked
793
836
  if base_url or model:
794
- print(
795
- "::warning::AI_API_BASE_URL/AI_MODEL이 설정됐지만 AI_API_KEY secret이 없어 사용자 API 티어를 건너뜁니다",
796
- file=sys.stderr,
797
- )
837
+ print(t("changelog.warn_missing_key"), file=sys.stderr)
798
838
  return None
799
839
  if not base_url or not model:
800
- print(
801
- "::warning::AI_API_KEY가 설정됐지만 AI_API_BASE_URL/AI_MODEL이 없어 사용자 API 티어를 건너뜁니다",
802
- file=sys.stderr,
803
- )
840
+ print(t("changelog.warn_missing_url_model"), file=sys.stderr)
804
841
  return None
805
842
  return api_key, base_url, model
806
843
 
807
844
 
808
845
  def _copilot_enabled() -> bool:
809
- """version.yml의 copilot_ai가 켜져 있고(워크플로우가 COPILOT_AI로 전달) 토큰이 있을 때만 True."""
846
+ """True only when copilot_ai is on in version.yml (passed by the workflow as COPILOT_AI) and a token is present."""
810
847
  return os.environ.get('COPILOT_AI', '').strip().lower() == 'true' and bool(os.environ.get('GITHUB_TOKEN'))
811
848
 
812
849
 
813
850
  def call_copilot_cli(prompt: str) -> str:
814
- """Copilot CLI를 텍스트 생성 전용으로 호출해 응답 텍스트를 반환.
851
+ """Call the Copilot CLI for text generation only and return the response text.
815
852
 
816
- 프롬프트에 필요한 정보가 이미 다 들어 있으므로 에이전트 기능은 전부 막는다:
817
- 빈 임시 디렉터리에서 실행하고, shell/write/url 도구를 거부하며, 내장 MCP와
818
- 커스텀 지침 로딩을 끈다.
819
- 실패(비정상 종료·타임아웃·CLI 없음)는 예외로 올려 호출부가 fallback한다."""
853
+ The prompt already carries all needed information, so every tool capability is blocked:
854
+ run in an empty temp directory, deny the shell/write/url tools, and turn off the
855
+ built-in MCPs and custom-instruction loading.
856
+ Failures (non-zero exit, timeout, missing CLI) are raised so the caller falls back."""
820
857
  with tempfile.TemporaryDirectory() as workdir:
821
858
  result = subprocess.run(
822
859
  [
@@ -834,10 +871,10 @@ def call_copilot_cli(prompt: str) -> str:
834
871
 
835
872
 
836
873
  def _is_valid_copilot_summary(text: str) -> bool:
837
- """프롬프트가 요구한 Markdown 형식인지 최소한만 검사한다.
874
+ """Minimal check that the text is the Markdown format the prompt asked for.
838
875
 
839
- 섹션 헤딩('### ')이 하나도 없거나 코드펜스로 시작하는(통째로 감싼) 응답은 릴리스 노트로
840
- 쓰지 않는다."""
876
+ A response with no section heading ('### ') at all, or one starting with a code fence
877
+ (wrapped as a whole), is not used as release notes."""
841
878
  stripped = text.strip()
842
879
  if not stripped or stripped.startswith('```'):
843
880
  return False
@@ -845,33 +882,34 @@ def _is_valid_copilot_summary(text: str) -> bool:
845
882
 
846
883
 
847
884
  def _build_ai_prompt(commit_lines: list[str], pr_title: str | None, version: str, diff_stat: str | None = None) -> str:
848
- """AI에게 보낼 한국어 릴리즈 요약 프롬프트를 구성.
885
+ """Build the release-summary prompt sent to the model (in the configured language).
849
886
 
850
- 요청하는 출력 형식은 규칙 기반 폴백 렌더러(render_fallback_md)와 동일한
851
- 형식으로 맞춘다 — 다운스트림(릴리즈 노트 소비자)이 엔진과 무관하게 단일
852
- 형식만 보게 하기 위함이다.
887
+ The requested output format matches the rule-based fallback renderer
888
+ (render_fallback_md) — so downstream consumers of the release notes see a single
889
+ format regardless of the engine.
853
890
  """
891
+ titles = {key: _section_title(key) for key in _FALLBACK_SECTION_KEYS}
854
892
  parts = [
855
- "아래 커밋 목록을 바탕으로 한국어 릴리즈 요약을 작성해줘.",
856
- f"출력 형식: 첫 줄은 '## [{version}]' 헤더로 시작하고,",
857
- "해당 항목이 있는 섹션만 다음 이름으로 작성해줘:",
858
- "'### ⚠️ 호환성 깨짐', '### ✨ 기능', '### 🐛 수정', '### ⚡ 성능', '### 📝 문서', '### ♻️ 리팩토링',",
859
- "'### ✅ 테스트', '### 📦 의존성', '### 🔧 변경사항', '### 🚧 작업 중'.",
860
- "타입 뒤에 '!'가 붙었거나 BREAKING CHANGE인 커밋은 반드시 '### ⚠️ 호환성 깨짐'에 넣어줘.",
861
- "각 항목은 '- '로 시작하는 불릿으로 작성해줘.",
893
+ t("changelog.prompt_intro"),
894
+ t("changelog.prompt_format", version=version),
895
+ t("changelog.prompt_sections_intro"),
896
+ t("changelog.prompt_names_1", **titles),
897
+ t("changelog.prompt_names_2", **titles),
898
+ t("changelog.prompt_breaking", **titles),
899
+ t("changelog.prompt_bullets"),
862
900
  ]
863
901
  if pr_title:
864
- parts.append(f"PR 제목: {pr_title}")
902
+ parts.append(t("changelog.prompt_pr_title", title=pr_title))
865
903
  if diff_stat and diff_stat.strip():
866
- parts.append("파일별 변경 요약:")
904
+ parts.append(t("changelog.prompt_diff_stat"))
867
905
  parts.append(diff_stat.strip())
868
- parts.append("커밋 목록:")
906
+ parts.append(t("changelog.prompt_commits"))
869
907
  parts.extend(f"- {line}" for line in commit_lines)
870
908
  return "\n".join(parts)
871
909
 
872
910
 
873
911
  def call_openai_compatible(base_url: str, token: str, model: str, prompt: str) -> str:
874
- """OpenAI 호환 /chat/completions 엔드포인트 호출 후 응답 텍스트 반환."""
912
+ """Call an OpenAI-compatible /chat/completions endpoint and return the response text."""
875
913
  url = base_url.rstrip('/') + "/chat/completions"
876
914
  payload = {
877
915
  "model": model,
@@ -893,12 +931,12 @@ def call_openai_compatible(base_url: str, token: str, model: str, prompt: str) -
893
931
 
894
932
 
895
933
  def cmd_ai_summary(commits_file: str, version: str, output_path: str, pr_title: str | None, diff_stat_file: str | None = None) -> int:
896
- """커밋 목록을 읽어 AI(우선) 또는 규칙 기반 폴백으로 릴리즈 요약을 생성."""
934
+ """Read the commit list and build a release summary with the model (preferred) or the rule-based fallback."""
897
935
  try:
898
936
  with open(commits_file, 'r', encoding='utf-8') as f:
899
937
  commit_lines = [line.rstrip('\n').rstrip('\r') for line in f]
900
938
  except Exception as e:
901
- print(f"::warning::커밋 목록 파일을 읽지 못해 빈 목록으로 요약합니다: {e}", file=sys.stderr)
939
+ print(t("changelog.warn_commits_unreadable", error=e), file=sys.stderr)
902
940
  commit_lines = []
903
941
 
904
942
  diff_stat = None
@@ -948,16 +986,16 @@ def cmd_ai_summary(commits_file: str, version: str, output_path: str, pr_title:
948
986
  with open(output_path, 'w', encoding='utf-8') as f:
949
987
  f.write(summary_text)
950
988
  except Exception as e:
951
- # 파일을 못 쓴 사실을 숨기지 않는다 — ok=false로 보고하고,
952
- # 요약 텍스트는 stderr로 구제 출력한다. 종료 코드는 0 유지
953
- # (워크플로우 파이프라인을 끊지 않기 위한 계약).
989
+ # Do not hide that the file could not be written — report ok=false and
990
+ # rescue-print the summary text to stderr. Keep exit code 0
991
+ # (contract so the workflow pipeline is not cut).
954
992
  write_ok = False
955
993
  print(f"[warn] output write failed: {e}", file=sys.stderr)
956
994
  print(summary_text, file=sys.stderr)
957
995
 
958
996
  result = {"ok": write_ok, "engine": engine, "output": output_path}
959
997
  if engine == "fallback" and failures:
960
- # 워크플로우가 PR 댓글의 engine 줄에 사유를 붙일 수 있도록 함께 넘긴다.
998
+ # Passed along so the workflow can append the reason to the engine line of the PR comment.
961
999
  result["fallback_reason"] = "; ".join(r.replace("[warn] ", "", 1) for r in failures)
962
1000
  print(json.dumps(result))
963
1001
  return 0
@@ -966,33 +1004,34 @@ def cmd_ai_summary(commits_file: str, version: str, output_path: str, pr_title:
966
1004
  # ------------------------------- CLI -------------------------------
967
1005
 
968
1006
  def main(argv: list[str] | None = None) -> int:
1007
+ use_utf8_output()
969
1008
  parser = argparse.ArgumentParser(
970
1009
  prog='changelog_manager',
971
- description='통합 체인지로그 매니저',
1010
+ description=t('changelog.cli_description'),
972
1011
  add_help=True
973
1012
  )
974
1013
  sub = parser.add_subparsers(dest='command', required=True)
975
1014
 
976
- sub.add_parser('update-from-summary', help='PR body에서 CHANGELOG.json 갱신')
977
- sub.add_parser('generate-md', help='CHANGELOG.json → CHANGELOG.md 생성')
1015
+ sub.add_parser('update-from-summary', help=t('changelog.cli_update_help'))
1016
+ sub.add_parser('generate-md', help=t('changelog.cli_generate_help'))
978
1017
 
979
- p_classify_bump = sub.add_parser('classify-bump', help='커밋 목록으로 semver 승격 폭(major/minor/patch) 판단')
980
- p_classify_bump.add_argument('--commits-file', required=True, help='커밋 제목 목록 파일 (한 줄당 1개)')
1018
+ p_classify_bump = sub.add_parser('classify-bump', help=t('changelog.cli_classify_help'))
1019
+ p_classify_bump.add_argument('--commits-file', required=True, help=t('changelog.cli_commits_file_help'))
981
1020
 
982
- p_export = sub.add_parser('export', help='특정 버전 릴리즈 노트 추출')
983
- p_export.add_argument('--version', required=True, help='버전 번호')
984
- p_export.add_argument('--output', help='출력 파일 경로 (없으면 stdout)')
1021
+ p_export = sub.add_parser('export', help=t('changelog.cli_export_help'))
1022
+ p_export.add_argument('--version', required=True, help=t('changelog.cli_version_help'))
1023
+ p_export.add_argument('--output', help=t('changelog.cli_output_help'))
985
1024
 
986
- p_ai_summary = sub.add_parser('ai-summary', help='커밋 목록으로 AI/규칙 기반 릴리즈 요약 생성')
987
- p_ai_summary.add_argument('--commits-file', required=True, help='커밋 제목 목록 파일 (한 줄당 1개)')
988
- p_ai_summary.add_argument('--version', required=True, help='버전 번호')
989
- p_ai_summary.add_argument('--output', required=True, help='요약 결과를 저장할 파일 경로')
990
- p_ai_summary.add_argument('--pr-title', help='PR 제목 (프롬프트 컨텍스트로 사용, 선택)')
991
- p_ai_summary.add_argument('--diff-stat-file', help='git diff --stat 출력 파일 (프롬프트 컨텍스트 확장, 선택)')
1025
+ p_ai_summary = sub.add_parser('ai-summary', help=t('changelog.cli_ai_summary_help'))
1026
+ p_ai_summary.add_argument('--commits-file', required=True, help=t('changelog.cli_commits_file_help'))
1027
+ p_ai_summary.add_argument('--version', required=True, help=t('changelog.cli_version_help'))
1028
+ p_ai_summary.add_argument('--output', required=True, help=t('changelog.cli_summary_output_help'))
1029
+ p_ai_summary.add_argument('--pr-title', help=t('changelog.cli_pr_title_help'))
1030
+ p_ai_summary.add_argument('--diff-stat-file', help=t('changelog.cli_diff_stat_help'))
992
1031
 
993
- p_collect = sub.add_parser('collect-issue-closes', help='develop에 머지된 PR 중 이번 릴리스에 포함된 이슈 번호 목록 추출')
994
- p_collect.add_argument('--commit-shas-file', required=True, help='이번 릴리스에 포함된 커밋 SHA 목록 파일 (한 줄당 1개)')
995
- p_collect.add_argument('--merged-prs-file', required=True, help='gh pr list --json number,headRefName,mergeCommit 출력 JSON 파일')
1032
+ p_collect = sub.add_parser('collect-issue-closes', help=t('changelog.cli_collect_help'))
1033
+ p_collect.add_argument('--commit-shas-file', required=True, help=t('changelog.cli_shas_help'))
1034
+ p_collect.add_argument('--merged-prs-file', required=True, help=t('changelog.cli_prs_help'))
996
1035
 
997
1036
  args = parser.parse_args(argv)
998
1037