project-auto-wizard 0.13.3 → 0.15.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.
- package/README.ja.md +2 -0
- package/README.ko.md +2 -0
- package/README.md +3 -1
- package/README.zh-CN.md +2 -0
- package/bin/project-auto-wizard.js +18 -4
- package/package.json +2 -2
- package/payload/config/breaking-changes.json +28 -14
- package/payload/config/wizard-prompts.yml +102 -46
- package/payload/flutter-app/android/fastlane/Fastfile.playstore +44 -32
- package/payload/flutter-app/ios/ExportOptions.plist +8 -8
- package/payload/flutter-app/ios/fastlane/Fastfile +57 -43
- package/payload/scripts/changelog_manager.py +304 -265
- package/payload/scripts/issue_helper.py +54 -50
- package/payload/scripts/messages.py +2585 -0
- package/payload/scripts/truncate_release_notes.py +7 -6
- package/payload/scripts/version_manager.py +110 -107
- package/payload/version.yml.template +2 -1
- package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +23 -16
- package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +106 -63
- package/payload/workflows/common/PROJECT-COMMON-ISSUE-HELPER.yaml +17 -15
- package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +55 -45
- package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +90 -72
- package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +59 -45
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +194 -184
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +226 -213
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +46 -42
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +334 -241
- package/payload/workflows/flutter/PROJECT-FLUTTER-APP-BUILD-TRIGGER.yaml +152 -128
- package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +246 -188
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +353 -238
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +96 -85
- package/payload/workflows/go/PROJECT-GO-CI.yaml +47 -36
- package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +945 -508
- package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +155 -133
- package/payload/workflows/next/PROJECT-NEXT-CI.yaml +76 -60
- package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +97 -77
- package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +62 -47
- package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +942 -505
- package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +148 -126
- package/payload/workflows/react/PROJECT-REACT-CI.yaml +86 -70
- package/payload/workflows/react/PROJECT-REACT-CICD.yaml +112 -92
- package/payload/workflows/spring/PROJECT-SPRING-CI.yml +112 -82
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +180 -150
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +161 -131
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +970 -549
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +193 -162
- package/src/cli/args.js +98 -52
- package/src/cli/help.js +8 -41
- package/src/commands/doctor.js +90 -89
- package/src/commands/dry-run.js +64 -43
- package/src/commands/full.js +79 -77
- package/src/commands/install-settings.js +16 -15
- package/src/commands/interactive-flutter.js +20 -19
- package/src/commands/interactive.js +135 -132
- package/src/commands/purge.js +47 -42
- package/src/commands/status.js +36 -36
- package/src/commands/uninstall.js +43 -41
- package/src/context.js +12 -11
- package/src/core/assets.js +12 -11
- package/src/core/baseline.js +27 -27
- package/src/core/branches.js +29 -27
- package/src/core/branding.js +7 -6
- package/src/core/breaking-check.js +22 -21
- package/src/core/breaking.js +15 -8
- package/src/core/copy/app-files.js +5 -4
- package/src/core/copy/flutter-app.js +8 -8
- package/src/core/copy/gitignore.js +58 -50
- package/src/core/copy/readme.js +102 -36
- package/src/core/copy/simple.js +16 -15
- package/src/core/copy/workflows.js +131 -124
- package/src/core/deploy-style.js +51 -48
- package/src/core/detect-fs.js +57 -57
- package/src/core/detect.js +67 -65
- package/src/core/errors.js +2 -2
- package/src/core/flutter-doctor.js +17 -16
- package/src/core/flutter-hooks.js +33 -32
- package/src/core/flutter-options.js +34 -33
- package/src/core/fsutil.js +12 -12
- package/src/core/installed-stores.js +4 -4
- package/src/core/logger.js +42 -40
- package/src/core/paths-resolve.js +88 -86
- package/src/core/paths.js +10 -10
- package/src/core/release-options.js +8 -8
- package/src/core/removal-exec.js +14 -13
- package/src/core/removal-plan.js +37 -37
- package/src/core/types.js +47 -47
- package/src/core/verify.js +44 -38
- package/src/core/version-yml.js +76 -68
- package/src/core/wizard-env.js +46 -46
- package/src/core/wizard-labels.js +63 -41
- package/src/i18n/catalog/en/cli.js +50 -0
- package/src/i18n/catalog/en/commands.js +188 -0
- package/src/i18n/catalog/en/copy.js +20 -0
- package/src/i18n/catalog/en/core.js +74 -0
- package/src/i18n/catalog/en/core2.js +15 -0
- package/src/i18n/catalog/en/ui.js +221 -0
- package/src/i18n/catalog/en.js +12 -0
- package/src/i18n/catalog/index.js +5 -0
- package/src/i18n/catalog/ko/cli.js +50 -0
- package/src/i18n/catalog/ko/commands.js +188 -0
- package/src/i18n/catalog/ko/copy.js +20 -0
- package/src/i18n/catalog/ko/core.js +74 -0
- package/src/i18n/catalog/ko/core2.js +15 -0
- package/src/i18n/catalog/ko/ui.js +221 -0
- package/src/i18n/catalog/ko.js +10 -0
- package/src/i18n/index.js +44 -0
- package/src/i18n/languages.js +9 -0
- package/src/index.js +132 -97
- package/src/ui/ansi.js +7 -7
- package/src/ui/banner.js +7 -6
- package/src/ui/env-plan.js +82 -81
- package/src/ui/prompts.js +97 -122
- package/src/ui/readline-engine.js +59 -58
- package/src/ui/status-cards.js +37 -31
- package/src/ui/summary.js +90 -89
|
@@ -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:
|
|
9
|
-
- generate-md : CHANGELOG.
|
|
10
|
-
- export :
|
|
11
|
-
- ai-summary :
|
|
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
|
-
"""
|
|
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
|
-
|
|
50
|
+
Remove unwanted noise from summary text.
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
1. HTML
|
|
53
|
-
2.
|
|
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.
|
|
63
|
+
# 2. Remove remaining HTML tags
|
|
63
64
|
text = re.sub(r'<[^>]+>', '', text)
|
|
64
65
|
|
|
65
|
-
# 3.
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
152
|
+
Parse release summary Markdown into categories/items.
|
|
96
153
|
|
|
97
|
-
|
|
98
|
-
1.
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
#
|
|
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 =
|
|
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
|
-
-
|
|
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 =
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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 =
|
|
245
|
-
detected
|
|
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
|
|
263
|
-
#
|
|
264
|
-
#
|
|
265
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
294
|
-
# `
|
|
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
|
-
|
|
370
|
+
Classify commit subjects with a 3-tier rule set.
|
|
312
371
|
|
|
313
|
-
1
|
|
314
|
-
2
|
|
315
|
-
|
|
316
|
-
3
|
|
317
|
-
`!`
|
|
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
|
-
|
|
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
|
|
338
|
-
# "feat: ..." Conventional Commits
|
|
339
|
-
#
|
|
340
|
-
# tier
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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(
|
|
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
|
-
"""
|
|
475
|
+
"""Decide the semver bump level from commit subjects, rule-based.
|
|
420
476
|
|
|
421
|
-
-
|
|
422
|
-
- `feat:`(
|
|
423
|
-
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
"""
|
|
445
|
-
|
|
446
|
-
`!`
|
|
447
|
-
'MINOR'
|
|
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 =
|
|
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:
|
|
@@ -455,13 +508,13 @@ def _ai_assisted_minor_upgrade(unclassified_lines: list[str]) -> bool:
|
|
|
455
508
|
try:
|
|
456
509
|
return call_openai_compatible(base_url, api_key, model, prompt).strip() == 'MINOR'
|
|
457
510
|
except Exception as e:
|
|
458
|
-
_warn_engine_failure(
|
|
511
|
+
_warn_engine_failure("[warn] " + t("changelog.engine_bump_failed", error=e))
|
|
459
512
|
|
|
460
513
|
if _copilot_enabled():
|
|
461
514
|
try:
|
|
462
515
|
return call_copilot_cli(prompt).strip() == 'MINOR'
|
|
463
516
|
except Exception as e:
|
|
464
|
-
_warn_engine_failure(
|
|
517
|
+
_warn_engine_failure("[warn] " + t("changelog.engine_bump_copilot_failed", error=e))
|
|
465
518
|
return False
|
|
466
519
|
|
|
467
520
|
|
|
@@ -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
|
-
"""
|
|
551
|
+
"""Read a commit list file and print the semver bump level (major/minor/patch) as the last stdout line.
|
|
499
552
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
|
576
|
+
"""Parse Markdown from pr_body.md and update CHANGELOG.json."""
|
|
524
577
|
version = os.environ.get('VERSION')
|
|
525
|
-
# PROJECT_TYPES(csv)
|
|
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
|
-
#
|
|
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("
|
|
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(
|
|
553
|
-
print(
|
|
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("
|
|
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(
|
|
614
|
+
print(tn("changelog.parse_ok", len(categories)))
|
|
562
615
|
else:
|
|
563
|
-
print("
|
|
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("
|
|
581
|
-
print(
|
|
582
|
-
print(
|
|
583
|
-
print(
|
|
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(
|
|
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(
|
|
596
|
-
print(
|
|
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
|
-
#
|
|
610
|
-
# metadata/releases
|
|
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("
|
|
683
|
+
print(t("changelog.json_updated"))
|
|
631
684
|
return 0
|
|
632
685
|
|
|
633
686
|
except Exception as e:
|
|
634
|
-
print(
|
|
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.
|
|
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(
|
|
653
|
-
f.write(
|
|
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')
|
|
719
|
+
parsed = _normalize_parsed_changes(release.get('parsed_changes'))
|
|
667
720
|
|
|
668
721
|
if parsed:
|
|
669
|
-
#
|
|
670
|
-
for
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
#
|
|
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("
|
|
736
|
+
f.write(t("changelog.md_no_changes") + "\n\n")
|
|
693
737
|
else:
|
|
694
|
-
f.write("
|
|
738
|
+
f.write(t("changelog.md_no_changes") + "\n\n")
|
|
695
739
|
|
|
696
740
|
f.write("---\n\n")
|
|
697
741
|
|
|
698
|
-
print("
|
|
742
|
+
print(t("changelog.md_regenerated"))
|
|
699
743
|
return 0
|
|
700
744
|
|
|
701
745
|
except Exception as e:
|
|
702
|
-
print(
|
|
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
|
-
"""
|
|
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 =
|
|
720
|
-
parsed_changes = matched.get('parsed_changes')
|
|
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
|
|
724
|
-
title = (
|
|
725
|
-
|
|
726
|
-
|
|
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(
|
|
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 = (
|
|
792
|
+
notes_text = (t("changelog.export_header", version=version) + "\n\n" + body).strip()
|
|
750
793
|
except Exception as e:
|
|
751
|
-
#
|
|
752
|
-
print(
|
|
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 =
|
|
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
|
|
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
|
-
"""
|
|
775
|
-
|
|
776
|
-
stdout
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
[
|
|
@@ -829,15 +866,15 @@ def call_copilot_cli(prompt: str) -> str:
|
|
|
829
866
|
capture_output=True, text=True, timeout=_COPILOT_TIMEOUT_SECONDS,
|
|
830
867
|
)
|
|
831
868
|
if result.returncode != 0:
|
|
832
|
-
raise RuntimeError(
|
|
869
|
+
raise RuntimeError(t("changelog.engine_copilot_exit", code=result.returncode, detail=result.stderr.strip()[:200]))
|
|
833
870
|
return result.stdout
|
|
834
871
|
|
|
835
872
|
|
|
836
873
|
def _is_valid_copilot_summary(text: str) -> bool:
|
|
837
|
-
"""
|
|
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
|
-
"""
|
|
885
|
+
"""Build the release-summary prompt sent to the model (in the configured language).
|
|
849
886
|
|
|
850
|
-
|
|
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
|
-
|
|
857
|
-
"
|
|
858
|
-
"
|
|
859
|
-
"
|
|
860
|
-
"
|
|
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(
|
|
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
|
|
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
|
-
"""
|
|
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(
|
|
939
|
+
print(t("changelog.warn_commits_unreadable", error=e), file=sys.stderr)
|
|
902
940
|
commit_lines = []
|
|
903
941
|
|
|
904
942
|
diff_stat = None
|
|
@@ -923,9 +961,9 @@ def cmd_ai_summary(commits_file: str, version: str, output_path: str, pr_title:
|
|
|
923
961
|
summary_text = candidate
|
|
924
962
|
engine = "user-api"
|
|
925
963
|
else:
|
|
926
|
-
failures.append(_warn_engine_failure("[warn]
|
|
964
|
+
failures.append(_warn_engine_failure("[warn] " + t("changelog.engine_user_api_empty")))
|
|
927
965
|
except Exception as e:
|
|
928
|
-
failures.append(_warn_engine_failure(
|
|
966
|
+
failures.append(_warn_engine_failure("[warn] " + t("changelog.engine_user_api_failed", error=e)))
|
|
929
967
|
|
|
930
968
|
if summary_text is None and _copilot_enabled():
|
|
931
969
|
try:
|
|
@@ -934,9 +972,9 @@ def cmd_ai_summary(commits_file: str, version: str, output_path: str, pr_title:
|
|
|
934
972
|
summary_text = candidate
|
|
935
973
|
engine = "copilot"
|
|
936
974
|
else:
|
|
937
|
-
failures.append(_warn_engine_failure("[warn]
|
|
975
|
+
failures.append(_warn_engine_failure("[warn] " + t("changelog.engine_copilot_invalid")))
|
|
938
976
|
except Exception as e:
|
|
939
|
-
failures.append(_warn_engine_failure(
|
|
977
|
+
failures.append(_warn_engine_failure("[warn] " + t("changelog.engine_copilot_failed", error=e)))
|
|
940
978
|
|
|
941
979
|
if summary_text is None:
|
|
942
980
|
classified = classify_commits(commit_lines)
|
|
@@ -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
|
-
#
|
|
952
|
-
#
|
|
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
|
-
print(
|
|
993
|
+
print("[warn] " + t("changelog.output_write_failed", error=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
|
-
#
|
|
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='
|
|
977
|
-
sub.add_parser('generate-md', help='
|
|
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=
|
|
980
|
-
p_classify_bump.add_argument('--commits-file', required=True, help=
|
|
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=
|
|
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='
|
|
987
|
-
p_ai_summary.add_argument('--commits-file', required=True, help=
|
|
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=
|
|
991
|
-
p_ai_summary.add_argument('--diff-stat-file', help=
|
|
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='
|
|
994
|
-
p_collect.add_argument('--commit-shas-file', required=True, help=
|
|
995
|
-
p_collect.add_argument('--merged-prs-file', required=True, help='
|
|
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
|
|