Skip to content

Commit d2dee8f

Browse files
committed
feat: add script to generate release changelog JSON from thunderbird-notes
1 parent 89fb80a commit d2dee8f

1 file changed

Lines changed: 282 additions & 0 deletions

File tree

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
#!/usr/bin/env python3
2+
# Converts Thunderbird/K-9 Mail release notes into JSON changelog resources.
3+
#
4+
# The script:
5+
# - Loads release notes from https://github.com/thunderbird/thunderbird-notes.
6+
# - Extracts changelog data for a specific release version.
7+
# - Generates version-specific JSON changelog files.
8+
# - Updates the changelog index.
9+
# - Validates all generated files against the project's JSON schemas.
10+
#
11+
# Supported application IDs:
12+
# - com.fsck.k9
13+
# - net.thunderbird.android
14+
# - net.thunderbird.android.beta
15+
#
16+
# Usage:
17+
# python scripts/changelog/migrate_changelog_to_json.py <applicationid> <version>
18+
#
19+
# Example:
20+
# python scripts/changelog/migrate_changelog_to_json.py net.thunderbird.android 11.0
21+
22+
import argparse
23+
import json
24+
import re
25+
from pathlib import Path
26+
27+
import requests
28+
import yaml
29+
from jsonschema import Draft202012Validator
30+
31+
APP_CONFIG = {
32+
"com.fsck.k9": {"app_dir": "app-k9mail", "build_type": "main"},
33+
"net.thunderbird.android": {"app_dir": "app-thunderbird", "build_type": "release"},
34+
"net.thunderbird.android.beta": {"app_dir": "app-thunderbird", "build_type": "beta"},
35+
}
36+
37+
def to_resource_name(version: str) -> str:
38+
normalized = re.sub(r"[^a-z0-9]+", "_", version.lower()).strip("_")
39+
return f"changelog_release_{normalized}"
40+
41+
def load_schema(schema_path: Path) -> dict:
42+
with schema_path.open("r", encoding="utf-8") as file:
43+
return json.load(file)
44+
45+
def validate_json(
46+
data: dict,
47+
schema: dict,
48+
description: str,
49+
) -> None:
50+
validator = Draft202012Validator(
51+
schema
52+
)
53+
54+
errors = sorted(
55+
validator.iter_errors(data),
56+
key=lambda error: list(
57+
error.absolute_path
58+
),
59+
)
60+
61+
if not errors:
62+
return
63+
64+
messages = []
65+
66+
for error in errors:
67+
location = (
68+
"/".join(
69+
str(part)
70+
for part in error.absolute_path
71+
)
72+
or "<root>"
73+
)
74+
75+
messages.append(
76+
f"{location}: "
77+
f"{error.message}"
78+
)
79+
80+
raise ValueError(
81+
f"{description} failed schema "
82+
f"validation:\n"
83+
+ "\n".join(messages)
84+
)
85+
86+
def load_release_notes(version: str, notesrepo: str, notesbranch: str) -> dict:
87+
filename = f"{version}.yml"
88+
directory = "android_release"
89+
if "0b" in version:
90+
filename = f"{version[:-1]}eta.yml"
91+
directory = "android_beta"
92+
93+
repo_path = Path(notesrepo).expanduser()
94+
if repo_path.is_dir():
95+
yaml_file = repo_path / directory / filename
96+
with yaml_file.open("r", encoding="utf-8") as f:
97+
return yaml.safe_load(f)
98+
99+
url = (
100+
f"https://api.github.com/repos/{notesrepo}/contents/"
101+
f"{directory}/{filename}?ref={notesbranch}"
102+
)
103+
response = requests.get(
104+
url,
105+
headers={"Accept": "application/vnd.github.v3.raw"},
106+
timeout=30,
107+
)
108+
response.raise_for_status()
109+
return yaml.safe_load(response.text)
110+
111+
def extract_release(version: str, application: str, yaml_content: dict) -> dict:
112+
release_info = next(
113+
(r for r in yaml_content["release"]["releases"] if r["version"] == version),
114+
None,
115+
)
116+
if release_info is None:
117+
raise ValueError(f"Version '{version}' not found")
118+
119+
beta_group = None
120+
match = re.search(r"0b(\d+)$", version)
121+
if match:
122+
beta_group = int(match.group(1))
123+
124+
notes = []
125+
for note in yaml_content["notes"]:
126+
if beta_group is not None and note.get("group") != beta_group:
127+
continue
128+
if note.get("thunderbird_only", False) and application == "k9mail":
129+
continue
130+
if note.get("k9mail_only", False) and application == "thunderbird":
131+
continue
132+
133+
text = note.get("note", "").strip()
134+
if not text:
135+
continue
136+
137+
change_type = determine_change_type(note.get("tag", "changed").lower())
138+
entry = {
139+
"type": change_type,
140+
"text": text,
141+
"source": "thunderbird-notes",
142+
}
143+
if "issues" in note:
144+
entry["issues"] = note["issues"]
145+
notes.append(entry)
146+
147+
return {
148+
"schemaVersion": 1,
149+
"version": version,
150+
"date": release_info["release_date"],
151+
"notes": notes,
152+
}
153+
154+
def determine_change_type(
155+
text: str,
156+
) -> str:
157+
normalized = text.lower()
158+
159+
if normalized.startswith(
160+
"fixed"
161+
):
162+
return "fixed"
163+
164+
if (
165+
normalized.startswith(
166+
"added"
167+
)
168+
or normalized.startswith(
169+
"new"
170+
)
171+
or normalized.startswith(
172+
"support"
173+
)
174+
):
175+
return "new"
176+
177+
return "changed"
178+
179+
def write_release_file(release_data: dict, output_dir: Path) -> str:
180+
resource_name = to_resource_name(release_data["version"])
181+
output_file = output_dir / f"{resource_name}.json"
182+
with output_file.open("w", encoding="utf-8") as f:
183+
json.dump(release_data, f, indent=2, ensure_ascii=False)
184+
return resource_name
185+
186+
def create_index_entry(release_data: dict, resource_name: str) -> dict:
187+
return {
188+
"version": release_data["version"],
189+
"date": release_data["date"],
190+
"resourceName": resource_name,
191+
}
192+
193+
def load_existing_index(output_dir: Path) -> dict:
194+
index_file = output_dir / "changelog_index.json"
195+
if not index_file.exists():
196+
return {"schemaVersion": 1, "releases": []}
197+
with index_file.open("r", encoding="utf-8") as f:
198+
return json.load(f)
199+
200+
def update_index(index: dict, release_data: dict, resource_name: str) -> dict:
201+
releases = [
202+
r for r in index["releases"]
203+
if r["version"] != release_data["version"]
204+
]
205+
releases.append(create_index_entry(release_data, resource_name))
206+
releases.sort(key=lambda r: r["date"], reverse=True)
207+
index["releases"] = releases
208+
return index
209+
210+
def write_index_file(index: dict, output_dir: Path) -> None:
211+
with (output_dir / "changelog_index.json").open("w", encoding="utf-8") as f:
212+
json.dump(index, f, indent=2, ensure_ascii=False)
213+
214+
def main():
215+
parser = argparse.ArgumentParser()
216+
parser.add_argument(
217+
"applicationid",
218+
choices=[
219+
"net.thunderbird.android",
220+
"net.thunderbird.android.beta",
221+
"com.fsck.k9",
222+
],
223+
)
224+
parser.add_argument("version")
225+
parser.add_argument("versioncode", nargs="?", default="0")
226+
parser.add_argument(
227+
"--repository", "-r",
228+
default="thunderbird/thunderbird-notes",
229+
)
230+
parser.add_argument("--branch", "-b", default="master")
231+
args = parser.parse_args()
232+
233+
config = APP_CONFIG[args.applicationid]
234+
application = "k9mail" if args.applicationid == "com.fsck.k9" else "thunderbird"
235+
236+
repo_root = Path(__file__).resolve().parents[2]
237+
238+
output_dir = (
239+
repo_root / config["app_dir"] / "src" / config["build_type"]
240+
/ "res" / "raw" / "changelog"
241+
)
242+
243+
schema_dir = repo_root / "schemas"
244+
release_schema = load_schema(schema_dir / "changelog-release.schema.json")
245+
index_schema = load_schema(schema_dir / "changelog-index.schema.json")
246+
247+
output_dir.mkdir(parents=True, exist_ok=True)
248+
249+
yaml_content = load_release_notes(
250+
args.version,
251+
args.repository,
252+
args.branch,
253+
)
254+
255+
release_data = extract_release(
256+
args.version,
257+
application,
258+
yaml_content,
259+
)
260+
print(release_data)
261+
262+
validate_json(
263+
release_data,
264+
release_schema,
265+
f"Release {args.version}",
266+
)
267+
268+
resource_name = write_release_file(release_data, output_dir)
269+
270+
index = load_existing_index(output_dir)
271+
index = update_index(index, release_data, resource_name)
272+
273+
validate_json(index, index_schema, "Changelog index")
274+
275+
write_index_file(index, output_dir)
276+
277+
print(
278+
f"Generated {resource_name}.json and updated changelog_index.json"
279+
)
280+
281+
if __name__ == "__main__":
282+
main()

0 commit comments

Comments
 (0)