blob: abe1d4d191a23765aad95f5bcb611200e3e1c815 [file] [edit]
#!/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()