render_release_notes.py 2.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
  1. #!/usr/bin/env python3
  2. """Render a release's notes from docs/release-notes/ as the body of its GitHub PR or release."""
  3. import argparse
  4. import re
  5. from pathlib import Path
  6. ROOT = Path(__file__).resolve().parents[3]
  7. # MkDocs admonition type (including Material's aliases) -> GitHub alert type
  8. ALERTS = {
  9. 'attention': 'WARNING',
  10. 'bug': 'CAUTION',
  11. 'caution': 'WARNING',
  12. 'danger': 'CAUTION',
  13. 'error': 'CAUTION',
  14. 'failure': 'CAUTION',
  15. 'hint': 'TIP',
  16. 'important': 'IMPORTANT',
  17. 'info': 'NOTE',
  18. 'note': 'NOTE',
  19. 'tip': 'TIP',
  20. 'warning': 'WARNING',
  21. }
  22. def extract_section(text, version):
  23. """Return the lines of the section for the given version, excluding its heading."""
  24. lines = text.splitlines()
  25. heading = re.compile(rf'^## v{re.escape(version)} \(')
  26. start = next((i for i, line in enumerate(lines) if heading.match(line)), None)
  27. if start is None:
  28. raise SystemExit(f'No section for v{version} found')
  29. section = []
  30. for line in lines[start + 1:]:
  31. if line == '---' or line.startswith('## '):
  32. break
  33. section.append(line)
  34. return section
  35. def convert_admonitions(lines):
  36. """Convert MkDocs admonitions to GitHub alerts. GitHub alerts have no title, so it is dropped."""
  37. output = []
  38. i = 0
  39. while i < len(lines):
  40. if not (match := re.match(r'^!!! (\w+)', lines[i])):
  41. output.append(lines[i])
  42. i += 1
  43. continue
  44. output.append(f'> [!{ALERTS.get(match[1], "NOTE")}]')
  45. i += 1
  46. # The body is every following line indented four spaces, including blank lines between paragraphs
  47. end = i
  48. while end < len(lines) and (lines[end].startswith(' ') or not lines[end].strip()):
  49. end += 1
  50. while end > i and not lines[end - 1].strip():
  51. end -= 1
  52. output.extend(f'> {line[4:]}' if line.strip() else '>' for line in lines[i:end])
  53. i = end
  54. return output
  55. def main():
  56. parser = argparse.ArgumentParser(description=__doc__)
  57. parser.add_argument('version', help='Release version, e.g. 4.7.5')
  58. parser.add_argument('--format', choices=('pr', 'release'), default='pr')
  59. args = parser.parse_args()
  60. version = args.version.removeprefix('v')
  61. major, minor = version.split('.')[:2]
  62. text = (ROOT / 'docs' / 'release-notes' / f'version-{major}.{minor}.md').read_text()
  63. lines = convert_admonitions(extract_section(text, version))
  64. if args.format == 'release':
  65. # Promote each heading one level (### -> ##)
  66. lines = [re.sub(r'^#(#+ )', r'\1', line) for line in lines]
  67. print('\n'.join(lines).strip())
  68. if __name__ == '__main__':
  69. main()