| #!/usr/bin/env vpython3 |
| |
| # Copyright (c) 2026 The WebRTC project authors. All Rights Reserved. |
| # |
| # Use of this source code is governed by a BSD-style license |
| # that can be found in the LICENSE file in the root of the source |
| # tree. An additional intellectual property rights grant can be found |
| # in the file PATENTS. All contributing project authors may |
| # be found in the AUTHORS file in the root of the source tree. |
| """Script to generate a WebRTC changelog in HTML format. |
| |
| This script parses git logs between two branches and categorizes commits |
| to produce a user-friendly changelog. |
| """ |
| |
| import argparse |
| import collections |
| import re |
| import subprocess |
| import sys |
| |
| # Define categories and keyword mappings |
| CATEGORIES = { |
| 'API': ['api/'], |
| 'Transport': ['transport', 'dtls', 'ice', 'p2p', 'rtp', 'sctp'], |
| 'Audio': ['audio', 'aec3', 'voice', 'audio_processing'], |
| 'Video': ['video', 'h264', 'vp8', 'vp9', 'av1', 'video_coding'], |
| 'Peerconnection': ['pc/', 'peer_connection', 'signaling', 'jsep'], |
| 'Stats': ['stats', 'collector', 'rtc_stats'], |
| 'Security': ['security', 'bounds', 'overflow'], |
| 'Infrastructure': ['build', 'gn', 'ninja', 'iwyu', 'owners', 'watchlist'], |
| } |
| |
| CHANGELOG_CSS = """ |
| body { |
| font-family: sans-serif; |
| line-height: 1.4; |
| color: #333; |
| max-width: 1200px; |
| margin: 0 auto; |
| padding: 15px; |
| font-size: 0.9em; |
| } |
| h1 { |
| color: #1a73e8; |
| border-bottom: 2px solid #1a73e8; |
| padding-bottom: 5px; |
| font-size: 1.5em; |
| } |
| h2 { |
| color: #1a73e8; |
| font-size: 1.2em; |
| margin: 15px 0 5px 0; |
| } |
| table { |
| width: 100%; |
| border-collapse: collapse; |
| margin-top: 10px; |
| margin-bottom: 20px; |
| } |
| th, td { |
| border: 1px solid #dadce0; |
| padding: 6px; |
| text-align: left; |
| vertical-align: top; |
| } |
| th { |
| background-color: #f8f9fa; |
| } |
| a { |
| color: #1a73e8; |
| text-decoration: none; |
| } |
| .external-links { |
| margin-bottom: 15px; |
| padding: 10px; |
| background: #f1f3f4; |
| border-radius: 4px; |
| } |
| .external-links a { |
| font-weight: bold; |
| margin-right: 15px; |
| } |
| .summary-container { |
| display: flex; |
| gap: 15px; |
| align-items: flex-start; |
| } |
| .overall-summary { |
| flex: 2; |
| } |
| .category-summary { |
| flex: 1; |
| } |
| .category-summary table { |
| margin-top: 0; |
| } |
| """ |
| |
| |
| def get_category(subject, body): |
| """Categorizes a commit based on keywords in the subject and body.""" |
| combined = (subject + ' ' + body).lower() |
| # Split by whitespace or the presence of "/" but keep the "/" |
| # e.g. "pc/srtp_session.cc" -> ["pc/", "srtp_session.cc"] |
| # Then strip punctuation to allow exact matching with keywords. |
| words = [ |
| w.strip('.,:;()[]{}') for w in re.split(r'(?<=/)|\s+', combined) if w |
| ] |
| for cat, keywords in CATEGORIES.items(): |
| if any(kw in words for kw in keywords): |
| return cat |
| return 'General' |
| |
| |
| def format_single_bug(bug_str): |
| patterns = [ |
| (r'webrtc:(\d+)', r'https://issues.webrtc.org/issues/\1'), |
| (r'chromium:(\d+)', r'https://issues.chromium.org/issues/\1'), |
| (r'b/(\d+)', r'https://issues.chromium.org/issues/\1'), |
| (r'b:(\d+)', r'https://issues.chromium.org/issues/\1'), |
| ] |
| for pattern, url in patterns: |
| if re.search(pattern, bug_str, re.IGNORECASE): |
| url_part = re.sub(pattern, url, bug_str, flags=re.IGNORECASE) |
| return (f'<a href="{url_part}">' |
| f'{bug_str}</a>') |
| return bug_str |
| |
| |
| def format_bugs(bug_list): |
| """Formats a list of bug strings into HTML links, separated by commas.""" |
| if not bug_list: |
| return 'None' |
| formatted_bugs = [] |
| for bug_str in bug_list.split(','): |
| bug_str = bug_str.strip() |
| if bug_str and bug_str.lower() != 'none': |
| formatted_bugs.append(format_single_bug(bug_str)) |
| return ', '.join(formatted_bugs) if formatted_bugs else 'None' |
| |
| |
| def parse_git_commits(log_text): |
| # Split by the custom delimiter, and filter out empty strings |
| commit_chunks = log_text.split('--WEBRTC-COMMIT-DELIMITER--') |
| commits = [] |
| for chunk in commit_chunks: |
| chunk = chunk.strip() |
| if not chunk: |
| continue |
| |
| lines = chunk.splitlines() |
| if len(lines) < 2: |
| # Should have at least (hash, author, subject) and a |
| # (potentially empty) body line. |
| continue |
| |
| [commit_hash, author, subject] = lines[0].split(' ', 2) |
| body_lines = lines[1:] |
| |
| bugs = [] |
| review_url = None |
| parsed_body = [] |
| # Extract Bug and Reviewed-on from body lines and keep all body lines. |
| for line in body_lines: |
| line_stripped = line.strip() |
| if line_stripped.startswith('Bug:') or line_stripped.startswith( |
| 'Fixed:'): |
| # Note: this is a single string even if there are multiple |
| # comma-separated bug ids. |
| bugs.append(line.split(':', 1)[1].strip()) |
| elif line_stripped.startswith('Reviewed-on:'): |
| review_url = line.split(':', 1)[1].strip() |
| parsed_body.append(line) |
| category = get_category(subject, '\n'.join(parsed_body)) |
| bug = ','.join(bugs) if bugs else 'None' |
| |
| commits.append({ |
| 'hash': commit_hash, |
| 'author': author, |
| 'subject': subject, |
| 'bug': bug, |
| 'review_url': review_url, |
| 'category': category, |
| 'body': parsed_body |
| }) |
| |
| return commits |
| |
| |
| def parse_log(log_text): |
| """Parses and filter git commits.""" |
| raw_commits = parse_git_commits(log_text) |
| |
| filtered_commits = [] |
| for commit in raw_commits: |
| if commit['subject'] == 'Update WebRTC code version': |
| continue |
| if commit['subject'].startswith('Roll '): |
| continue |
| exclude_prefixes = ('Revert', 'Reland', 'Reapply') |
| if commit['subject'].startswith(exclude_prefixes): |
| continue |
| if 'webrtc-version-updater' in commit['author']: |
| continue |
| filtered_commits.append(commit) |
| |
| return filtered_commits |
| |
| |
| def generate_html(commits, |
| from_branch, |
| to_branch, |
| milestone=None, |
| summary_text=None): |
| """Generates an HTML changelog from a list of commits.""" |
| authors = set(commit['author'] for commit in commits) |
| full_log_url = (f'https://webrtc.googlesource.com/src/+log/branch-heads/' |
| f'{from_branch}..branch-heads/{to_branch}') |
| schedule_url = 'https://chromiumdash.appspot.com/schedule' |
| cat_counts = collections.defaultdict(int) |
| for commit in commits: |
| cat_counts[commit['category']] += 1 |
| |
| ai_summary_html = (f'<div id="ai-summary">{summary_text}</div>' |
| if summary_text else |
| '<div id="ai-summary">AI_SUMMARY_PLACEHOLDER</div>') |
| |
| formatted_milestone = f'{milestone} ' if milestone else '' |
| html = [ |
| f"""<!DOCTYPE html> |
| <html> |
| <head> |
| <style> |
| {CHANGELOG_CSS} |
| </style> |
| </head> |
| <body> |
| <h1>WebRTC Changelog {formatted_milestone} ({from_branch}..{to_branch})</h1> |
| <div class="external-links"> |
| <a href="{full_log_url}">Full list of changes</a> |
| <a href="{schedule_url}">Chromium Release Schedule</a> |
| </div> |
| <div class="summary-container"> |
| <div class="overall-summary"> |
| <p> |
| This release contains {len(commits)} commits |
| by {len(authors)} authors. |
| </p> |
| <h2>Summary (AI-generated)</h2> |
| {ai_summary_html} |
| </div> |
| <div class="category-summary"> |
| <h2>Categories</h2> |
| <table> |
| <thead><tr><th>Category</th><th>Changes</th></tr></thead> |
| <tbody> |
| """ |
| ] |
| for cat, count in sorted(cat_counts.items()): |
| html.append(f'<tr><td>{cat}</td><td>{count}</td></tr>') |
| html.append("""</tbody></table></div></div> |
| <h2>Detailed List of Changes (newest first)</h2> |
| <table> |
| <thead> |
| <tr> |
| <th>Change Description</th> |
| <th>Category</th> |
| <th>Links</th> |
| <th>Bug</th> |
| </tr> |
| </thead> |
| <tbody> |
| """) |
| for commit in commits: |
| commit_link = (f'<a href="https://webrtc.googlesource.com/src/+/' |
| f'{commit["hash"]}">📝</a>') |
| review_link = (f'<a href="{commit["review_url"]}">🔍</a>' |
| if commit['review_url'] else '') |
| html.append( |
| f'<tr><td>{commit["subject"]}</td><td>{commit["category"]}</td>' |
| f'<td style="white-space:nowrap;">{commit_link} {review_link}</td>' |
| f'<td>{format_bugs(commit["bug"])}</td></tr>') |
| html.append('</tbody></table></body></html>') |
| return ''.join(html) |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser( |
| description='Generate WebRTC changelog with optional AI summary.') |
| parser.add_argument('branch1', |
| help='First branch for comparison (e.g., 7727).') |
| parser.add_argument('branch2', |
| help='Second branch for comparison (e.g., 7778).') |
| parser.add_argument('output_file', help='Path to the output HTML file.') |
| parser.add_argument('--summary_text', |
| help='Optional AI-generated summary text to inject.', |
| default=None) |
| parser.add_argument('--milestone', |
| help='Optional milestone name (e.g. M148).', |
| default=None) |
| |
| args = parser.parse_args() |
| |
| try: |
| log_content = subprocess.check_output([ |
| 'git', 'log', '--format=%h %ae %s%n%b--WEBRTC-COMMIT-DELIMITER--', |
| f'branch-heads/{args.branch1}..branch-heads/{args.branch2}' |
| ]).decode('utf-8') |
| except subprocess.CalledProcessError as error: |
| print(f'Error running git log: {error}', file=sys.stderr) |
| sys.exit(1) |
| |
| commits = parse_log(log_content) |
| |
| if not commits: |
| print('No commits found in the specified range.') |
| sys.exit(0) |
| |
| branch_info = f'({args.branch1} to {args.branch2})' |
| if args.milestone: |
| branch_info = f'{args.milestone} {branch_info}' |
| |
| html = generate_html(commits, |
| from_branch=args.branch1, |
| to_branch=args.branch2, |
| milestone=args.milestone, |
| summary_text=args.summary_text) |
| |
| with open(args.output_file, 'w') as file_handle: |
| file_handle.write(html) |
| |
| print(f'Changelog written to {args.output_file}') |
| |
| |
| if __name__ == '__main__': |
| main() |