Add changelog generator skill

featuring a GenAI summary of the changes and the full
changelog generated as copy-paste-able HTML from the
git log.

Prompt: Use the generate-changelog skill to generate a changelog from the branches 7727 to 7778

Bug: webrtc:465491622
Change-Id: I24fcf89aa4caf9a6af77d6f564c5656c2e36dc2f
Reviewed-on: https://webrtc-review.googlesource.com/c/src/+/463801
Reviewed-by: Harald Alvestrand <hta@webrtc.org>
Commit-Queue: Philipp Hancke <philipp.hancke@googlemail.com>
Reviewed-by: Evan Shrubsole <eshr@webrtc.org>
Cr-Commit-Position: refs/heads/main@{#47614}
diff --git a/agents/skills/webrtc-changelog/SKILL.md b/agents/skills/webrtc-changelog/SKILL.md
new file mode 100644
index 0000000..0c42a44
--- /dev/null
+++ b/agents/skills/webrtc-changelog/SKILL.md
@@ -0,0 +1,46 @@
+---
+name: webrtc-changelog
+description: Generate a professional HTML changelog from WebRTC git logs. Automatically categorizes changes, includes a high-level summary, and provides detailed commit and review links.
+---
+
+# WebRTC Changelog Generator
+
+`webrtc-changelog` automates the generation of a professional, categorized HTML
+report for WebRTC changes.
+
+## Workflow
+
+1. **Fetch Branch Information:** Navigate to
+   `https://chromiumdash.appspot.com/branches` and extract the current and
+   previous branch numbers associated with the milestone from the **webrtc**
+   column of the first two rows in the **Branch details** table. These will be
+   used as `<branch2>` (current) and `<branch1>` (previous). Note the milestone
+   number (e.g., 148).
+2. **Update Git** Execute `git fetch origin` to retrieve the remote git
+   branches.
+3. **Fetch Git Log:** Execute
+   `git log branch-heads/<branch1>..branch-heads/<branch2>` to retrieve the
+   commit history.
+4. **Generate Summary:** Analyze the retrieved git log and produce a high-level
+   summary of the most significant changes. The summary MUST be formatted as an
+   HTML unordered list (`<ul><li>...</li></ul>`) for best presentation in the
+   report.
+5. **Generate HTML Report:** Use the `generate_changelog.py` script to create
+   the final report, injecting the generated HTML summary and the milestone:
+   ```bash
+   python3 tools_webrtc/generate_changelog.py <branch1> <branch2> <output_file> --summary_text "<summary_html>" --milestone M<milestone_number>
+   ```
+
+## Features
+
+### AI-Powered Summary
+
+- High-level, one-pager overview of the release.
+- Automatically includes commit and author statistics.
+
+### Categorized Changes
+
+- Automatically categorizes commits based on keywords in the commit.
+- Provides 📝 (Commit) and 🔍 (Review/Gerrit) links for each entry.
+- Automatically converts `Bug:` references (e.g., `webrtc:123`, `chromium:456`,
+  `b/789`) into hyperlinks.
diff --git a/tools_webrtc/generate_changelog.py b/tools_webrtc/generate_changelog.py
new file mode 100755
index 0000000..abe1d4d
--- /dev/null
+++ b/tools_webrtc/generate_changelog.py
@@ -0,0 +1,337 @@
+#!/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()