#!/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'' f'{bug_str}') 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'
{summary_text}
' if summary_text else '
AI_SUMMARY_PLACEHOLDER
') formatted_milestone = f'{milestone} ' if milestone else '' html = [ f"""

WebRTC Changelog {formatted_milestone} ({from_branch}..{to_branch})

This release contains {len(commits)} commits by {len(authors)} authors.

Summary (AI-generated)

{ai_summary_html}

Categories

""" ] for cat, count in sorted(cat_counts.items()): html.append(f'') html.append("""
CategoryChanges
{cat}{count}

Detailed List of Changes (newest first)

""") for commit in commits: commit_link = (f'📝') review_link = (f'🔍' if commit['review_url'] else '') html.append( f'' f'' f'') html.append('
Change Description Category Links Bug
{commit["subject"]}{commit["category"]}{commit_link} {review_link}{format_bugs(commit["bug"])}
') 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()