Skip to content

Commit 05e3a61

Browse files
authored
Improve changelog automation prompt (#9091)
* Improve changelog automation prompt * Update generate_changelog.py
1 parent 9743de3 commit 05e3a61

1 file changed

Lines changed: 119 additions & 76 deletions

File tree

.github/scripts/generate_changelog.py

Lines changed: 119 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env python3
22
"""
33
Generate a changelog from merged PR release notes for a given milestone.
4-
4+
55
Expects these environment variables:
66
GITHUB_TOKEN - GitHub personal access token or Actions token
77
REPOS - Comma-separated list of repositories in "owner/repo" format
@@ -17,28 +17,28 @@
1717
ANTHROPIC_API_KEY - (optional) If set, release notes are polished by Claude
1818
before being written to the changelog.
1919
"""
20-
20+
2121
import os
2222
import re
2323
import requests
2424
from datetime import date
2525
from requests.adapters import HTTPAdapter
2626
from urllib3.util.retry import Retry
27-
27+
2828
GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
2929
REPOS = [r.strip() for r in os.environ["REPOS"].split(",") if r.strip()]
3030
MILESTONE_TITLE = os.environ["MILESTONE"]
3131
VERSION = os.environ["VERSION"]
32-
32+
3333
HEADERS = {
3434
"Authorization": f"token {GITHUB_TOKEN}",
3535
"Accept": "application/vnd.github.v3+json",
3636
}
37-
37+
3838
# Shared timeout for all GitHub API requests (seconds).
3939
# Prevents the workflow from hanging indefinitely if the API stalls.
4040
API_TIMEOUT = 30
41-
41+
4242
# Standard requests retry strategy using urllib3 — handles transient failures
4343
# (rate limits, server errors) with exponential back-off and respects Retry-After.
4444
GITHUB_RETRY = Retry(
@@ -54,27 +54,25 @@
5454
SESSION = requests.Session()
5555
SESSION.headers.update(HEADERS)
5656
SESSION.mount("https://", GITHUB_ADAPTER)
57-
57+
5858
SYSTEM_PROMPT = """You are an expert technical writer and copyeditor for Mattermost software release notes. Your task is to transform raw, unstructured release notes from pull requests into a clean, categorized, and grammatically correct changelog entry that matches Mattermost's established changelog format exactly.
59-
59+
6060
Here are your instructions:
61-
61+
6262
1. **Section structure:** Use `###` for top-level sections and `####` for subsections. Only include sections that have relevant content — do not output empty sections. Do NOT add horizontal rules or line separators between sections. Do NOT add a blank line between a section/subsection heading and its first bullet point.
63-
63+
6464
Top-level sections and their subsections, in this order:
65-
65+
6666
- `### Upgrade Impact` — for changes that affect upgrading, with subsections as applicable:
6767
- `#### Database Schema Changes` — schema migrations such as new tables, new columns, changed columns, or new indexes. Example items: "Added a new ``Watermarks`` table.", "Added a new column ``DeleteAt`` to the ``ChannelMembers`` table."
68-
- `#### config.json` — new or changed configuration settings. Use this exact block format for each plan grouping:
69-
68+
- `#### config.json` — new or changed configuration settings. Use this exact block format for each plan grouping (no blank line between the `#### config.json` heading and the description paragraph, and no blank line between the description paragraph and the first bullet):
7069
New setting options were added to ``config.json``. Below is a list of the additions and their default values on install. The settings can be modified in ``config.json``, or the System Console when available.
71-
7270
- **Changes to Enterprise Advanced plan:**
7371
- Under ``ExperimentalSettings`` in ``config.json``, added ``EnableWatermark`` configuration setting to add watermarking toggle in the server.
74-
72+
7573
Adapt the plan name (e.g. "Changes to All plans:", "Changes to Enterprise plan:", "Changes to Enterprise Advanced plan:") and list each setting change as a bullet under the appropriate plan heading.
7674
- `#### Compatibility` — minimum version requirement changes for browsers, OS, or clients. Example: "Updated minimum Edge and Chrome versions to 146+."
77-
- `### Improvements` — for new features and enhancements only. Do NOT place items beginning with "Fixed..." here — those belong in Bug Fixes. Begin this section with the line `See [this blog post](BLOG_POST_URL) on the highlights in our latest release.` (use the exact placeholder `BLOG_POST_URL` — it will be replaced automatically). Then add subsections as applicable:
75+
- `### Improvements` — for new features and enhancements only. Do NOT place items beginning with "Fixed..." here — those belong in Bug Fixes. Begin this section with the line `See [this blog post](BLOG_POST_URL) on the highlights in our latest release.` (use the exact placeholder `BLOG_POST_URL` — it will be replaced automatically), followed by a blank line before the first `####` subsection heading. Then add subsections as applicable:
7876
- `#### User Interface` — user interface and UX changes and new visual features. Pre-packaged plugin version updates go at the TOP of this subsection, before other items. Always write "user interface" in full — never abbreviate as "UI".
7977
- `#### Plugins/Integrations` — plugin and integration improvements (use as a separate subsection when there are enough items to warrant it)
8078
- `#### Administration` — System Console features, logging, support packet changes
@@ -87,15 +85,15 @@
8785
- `### Go Version` — Always include this section. The Go version content will be injected automatically — output only this heading with no content beneath it.
8886
- `### Open Source Components` — open source component additions or removals. Format each item as: "Added ``<package>`` to <repo_url>." or "Removed ``<package>`` from <repo_url>." Example: "Added ``x/text`` to https://github.com/mattermost/mattermost/." Only include if there are relevant notes.
8987
- `### Security` — security-related fixes not already covered under Bug Fixes
90-
88+
9189
2. **Sentence patterns:** Follow these conventions consistently:
9290
- New features and additions: "Added [feature]..." or "Added support for [feature]..."
9391
- Bug fixes: "Fixed an issue where..." or "Fixed an issue with..." — never use "Fixed a bug"; always use "Fixed an issue".
9492
- Improvements to existing things: "Improved [thing]..." or "Updated [thing]..."
9593
- Removals: "Removed [thing]..."
96-
97-
3. **Terminology:** Always write "user interface" in full — never use the abbreviation "UI".
98-
94+
95+
3. **Terminology:** Always write "user interface" in full — never use the abbreviation "UI". Always spell out messaging abbreviations: "DM" → "Direct Message", "GM" → "Group Message", "DM/GM" → "Direct/Group Message".
96+
9997
4. **Code formatting:** Use double backticks for all of the following:
10098
- Configuration settings (e.g., ``ServiceSettings.EnableDynamicClientRegistration``)
10199
- API endpoints (e.g., ``/api/v4/posts``)
@@ -105,18 +103,20 @@
105103
- File names (e.g., ``config.json``)
106104
- Feature flags (e.g., ``MM_FEATUREFLAGS_CJKSEARCH``)
107105
- Package names in Open Source Components (e.g., ``x/text``)
108-
109-
5. **Markdown formatting:** Indent each bullet point with two spaces (e.g., ` - item`). Ensure correct and clean Markdown syntax throughout. Do not insert horizontal rules (`---`) or any other separators between sections. Do not add a blank line between a heading and its first bullet.
110-
106+
107+
5. **Markdown formatting:** Indent each bullet point with two spaces (e.g., ` - item`). Ensure correct and clean Markdown syntax throughout. Do not insert horizontal rules (`---`) or any other separators between sections. Do not add a blank line between a section/subsection heading and its first bullet point.
108+
111109
6. **License requirements:** When a feature requires a specific Mattermost license, note it inline at the end of the bullet point (e.g., "Requires Enterprise Advanced license" or "Requires Enterprise license").
112-
113-
7. **Proofreading:** Correct any typos, grammatical errors, awkward phrasing, or inconsistencies. Replace any instance of "Fixed a bug" with "Fixed an issue". Aim for clear, concise, and professional language.
114-
115-
8. **Tone:** Maintain a neutral, informative, and professional tone consistent with technical documentation.
116-
117-
9. **Focus:** Output only the section content (headings and bullet points). Do not include the release version header line or any introductory or concluding remarks from yourself."""
118-
119-
110+
111+
7. **One section per release note:** Each raw release note must appear in exactly one section — whichever section best fits its primary purpose. Do not split a single release note across multiple sections or subsections, even if it touches more than one area (e.g. an admin feature that also introduces an API endpoint belongs entirely in Administration, not partially in Administration and partially in API Changes).
112+
113+
8. **Proofreading:** Correct any typos, grammatical errors, awkward phrasing, or inconsistencies. Replace any instance of "Fixed a bug" with "Fixed an issue". Aim for clear, concise, and professional language.
114+
115+
9. **Tone:** Maintain a neutral, informative, and professional tone consistent with technical documentation.
116+
117+
10. **Focus:** Output only the section content (headings and bullet points). Do not include the release version header line or any introductory or concluding remarks from yourself."""
118+
119+
120120
def get_milestone_number(repo: str, title: str) -> int | None:
121121
"""Look up the numeric ID for a milestone by its title in the given repo."""
122122
url = f"https://api.github.com/repos/{repo}/milestones"
@@ -145,8 +145,8 @@ def get_milestone_number(repo: str, title: str) -> int | None:
145145
if recent_titles:
146146
print(f" Most recently due milestones: {', '.join(recent_titles)}")
147147
return None
148-
149-
148+
149+
150150
def get_merged_prs(repo: str, milestone_number: int) -> list:
151151
"""Fetch all merged PRs belonging to the given milestone in the given repo."""
152152
prs = []
@@ -179,24 +179,24 @@ def get_merged_prs(repo: str, milestone_number: int) -> list:
179179
prs.append(item)
180180
page += 1
181181
return prs
182-
183-
182+
183+
184184
def extract_release_notes(body: str) -> list[str] | None:
185185
"""
186186
Extract release note text from a PR body.
187-
187+
188188
Looks for a '#### Release Note' section, then pulls the content of any
189189
fenced code blocks within it (plain ``` or ```release-note).
190190
Returns None if the section is missing or all entries are NONE.
191191
"""
192192
if not body:
193193
return None
194-
194+
195195
# Normalize line endings (GitHub API may return \r\n on some PR bodies)
196196
body = body.replace("\r\n", "\n").replace("\r", "\n")
197-
197+
198198
notes = []
199-
199+
200200
# Primary path: look for a '#### Release Note(s)' section heading
201201
section_match = re.search(
202202
r"####\s+Release\s+Notes?\s*\n(.*?)(?=\n####|\Z)",
@@ -218,7 +218,7 @@ def extract_release_notes(body: str) -> list[str] | None:
218218
plain = section.strip()
219219
if plain and plain.upper() != "NONE":
220220
notes.append(plain)
221-
221+
222222
# Secondary path: scan the entire body for ```release-note blocks.
223223
# Catches PRs that use the block format without a #### Release Note heading.
224224
if not notes:
@@ -228,10 +228,10 @@ def extract_release_notes(body: str) -> list[str] | None:
228228
content = block.strip()
229229
if content and content.upper() != "NONE":
230230
notes.append(content)
231-
231+
232232
return notes if notes else None
233-
234-
233+
234+
235235
def polish_with_ai(raw_notes: list[str]) -> str:
236236
"""
237237
Send raw release notes to Claude for categorization, formatting, and proofreading.
@@ -241,40 +241,71 @@ def polish_with_ai(raw_notes: list[str]) -> str:
241241
if not api_key:
242242
print("ℹ️ ANTHROPIC_API_KEY not set — skipping AI polish, using raw notes")
243243
return "\n".join(f"- {note}" for note in raw_notes)
244-
244+
245245
try:
246246
import anthropic
247247
except ImportError:
248248
print("⚠️ anthropic package not installed — skipping AI polish")
249249
return "\n".join(f"- {note}" for note in raw_notes)
250-
250+
251251
print("✨ Sending notes to Claude for categorization and proofreading...")
252252
client = anthropic.Anthropic(api_key=api_key)
253-
253+
254254
raw_text = "\n\n---\n\n".join(raw_notes)
255255
user_message = f"Here are the raw release notes to process:\n\n{raw_text}"
256-
256+
257257
response = client.messages.create(
258258
model="claude-sonnet-4-6",
259259
max_tokens=4096,
260260
system=SYSTEM_PROMPT,
261261
messages=[{"role": "user", "content": user_message}],
262262
)
263-
263+
264264
return response.content[0].text.strip()
265-
266-
265+
266+
267+
def normalize_go_version(go_version: str) -> str:
268+
"""Normalize a Go version string to v-prefixed form.
269+
270+
Accepts both "go1.22.5" (Go toolchain convention) and "v1.22.5"
271+
(Mattermost changelog convention) and always returns the latter.
272+
"""
273+
v = go_version.strip()
274+
if v.startswith("go"):
275+
v = v[2:] # strip the "go" prefix
276+
if not v.startswith("v"):
277+
v = "v" + v
278+
return v
279+
280+
281+
def extract_previous_go_version(changelog_path: str) -> str | None:
282+
"""Scan the changelog file for the most recently documented Go version.
283+
284+
Used when GO_VERSION is not supplied (i.e. unchanged from the previous
285+
release) so the generated entry still shows a concrete version number
286+
rather than a generic "unchanged" message.
287+
"""
288+
if not os.path.exists(changelog_path):
289+
return None
290+
with open(changelog_path, "r", encoding="utf-8") as f:
291+
content = f.read()
292+
match = re.search(r"is built with Go\s+``(v?[\d.]+)``", content)
293+
if match:
294+
return normalize_go_version(match.group(1))
295+
return None
296+
297+
267298
def insert_changelog_entry(entry: str, changelog_path: str = "CHANGELOG.md") -> None:
268299
"""Insert a new version entry into the changelog after the static file header block."""
269300
existing = ""
270301
if os.path.exists(changelog_path):
271302
with open(changelog_path, "r") as f:
272303
existing = f.read()
273-
304+
274305
# The v11 changelog file has a static header block ending with the platform scope note.
275306
# New entries are inserted immediately after this block so the header is preserved.
276307
HEADER_END_MARKER = "may not represent all affected configurations.\n```"
277-
308+
278309
if HEADER_END_MARKER in existing:
279310
idx = existing.index(HEADER_END_MARKER) + len(HEADER_END_MARKER)
280311
new_content = existing[:idx] + "\n\n\n" + entry + existing[idx:]
@@ -284,28 +315,28 @@ def insert_changelog_entry(entry: str, changelog_path: str = "CHANGELOG.md") ->
284315
new_content = parts[0] + "\n\n" + entry + ("\n" + parts[2] if len(parts) > 2 else "")
285316
else:
286317
new_content = entry + "\n" + existing
287-
318+
288319
with open(changelog_path, "w") as f:
289320
f.write(new_content)
290-
291-
321+
322+
292323
def main():
293324
print(f"🔍 Milestone: {MILESTONE_TITLE} | Repos: {', '.join(REPOS)}\n")
294-
325+
295326
all_notes = []
296327
total_prs = 0
297328
no_notes_prs = []
298-
329+
299330
for repo in REPOS:
300331
print(f"── {repo}")
301332
milestone_number = get_milestone_number(repo, MILESTONE_TITLE)
302333
if milestone_number is None:
303334
continue
304-
335+
305336
prs = get_merged_prs(repo, milestone_number)
306337
print(f" 📋 Found {len(prs)} merged PR(s)")
307338
total_prs += len(prs)
308-
339+
309340
for pr in sorted(prs, key=lambda p: p["number"]):
310341
notes = extract_release_notes(pr.get("body") or "")
311342
if notes:
@@ -316,14 +347,15 @@ def main():
316347
no_notes_prs.append((repo, pr))
317348
print(f" ⏭️ #{pr['number']}: {pr['title']} (NONE / no notes)")
318349
print()
319-
350+
320351
# Derive short version for heading/anchor: "v11.7.0" → "v11.7", "11.7.0" → "v11.7"
321352
version_short = "v" + re.sub(r"\.0$", "", VERSION.lstrip("v"))
322-
353+
323354
release_type = os.environ.get("RELEASE_TYPE", "feature").strip().lower()
324355
release_date = os.environ.get("RELEASE_DATE", "").strip() or date.today().strftime("%Y-%m-%d")
325356
go_version = os.environ.get("GO_VERSION", "").strip()
326-
357+
changelog_path = os.environ.get("CHANGELOG_PATH", "CHANGELOG.md")
358+
327359
if release_type == "esr":
328360
anchor = f"(release-{version_short}-extended-support-release)="
329361
heading = (
@@ -332,17 +364,29 @@ def main():
332364
f"(https://docs.mattermost.com/product-overview/release-policy.html#release-types)"
333365
)
334366
else:
335-
anchor = f"(release-{version_short})="
336-
heading = f"## Release {version_short}"
337-
367+
anchor = f"(release-{version_short}-feature-release)="
368+
heading = (
369+
f"## Release {version_short} - "
370+
f"[Feature Release]"
371+
f"(https://docs.mattermost.com/product-overview/release-policy.html#release-types)"
372+
)
373+
338374
entry = f"{anchor}\n{heading}\n\n**Release day: {release_date}**\n\n"
339-
340-
# Build the Go Version section content
375+
376+
# Build the Go Version section content.
377+
# Mattermost uses a single bullet point in this section.
378+
# When GO_VERSION is not provided (unchanged from previous release), we look up
379+
# the previous version from the changelog so the line still shows a concrete number.
341380
if go_version:
342-
go_section = f"### Go Version\n - Updated to ``{go_version}``."
381+
formatted_go = normalize_go_version(go_version)
382+
go_section = f"### Go Version\n - {version_short} is built with Go ``{formatted_go}``."
343383
else:
344-
go_section = "### Go Version\n - Go version is the same as in the previous release."
345-
384+
prev_go = extract_previous_go_version(changelog_path)
385+
if prev_go:
386+
go_section = f"### Go Version\n - {version_short} is built with Go ``{prev_go}``."
387+
else:
388+
go_section = f"### Go Version\n - {version_short} uses the same Go version as the previous release."
389+
346390
if all_notes:
347391
polished = polish_with_ai(all_notes)
348392
blog_url = os.environ.get("BLOG_POST_URL", "").strip()
@@ -375,18 +419,17 @@ def main():
375419
else:
376420
entry += go_section + "\n"
377421
entry += "\n_No other release notes for this version._\n"
378-
379-
changelog_path = os.environ.get("CHANGELOG_PATH", "CHANGELOG.md")
422+
380423
insert_changelog_entry(entry, changelog_path)
381-
424+
382425
prs_with_notes = total_prs - len(no_notes_prs)
383426
print(f"✅ Changelog updated with notes from {prs_with_notes} PR(s) across {len(REPOS)} repo(s)")
384-
427+
385428
if no_notes_prs:
386429
print(f"\n⚠️ {len(no_notes_prs)} PR(s) had no release notes (marked NONE or section missing):")
387430
for repo, pr in no_notes_prs:
388431
print(f" [{repo}] #{pr['number']}: {pr['title']}")
389-
390-
432+
433+
391434
if __name__ == "__main__":
392435
main()

0 commit comments

Comments
 (0)