Skip to content

Commit f75a661

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

1 file changed

Lines changed: 283 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)