-
Notifications
You must be signed in to change notification settings - Fork 315
Release notes generator script #4866
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 65 commits
218ddfc
51e7f34
6af501b
98ea3a9
a8e9880
afd0d2c
ce3bdce
bc1dd9c
6ba5b88
be742b1
1dacebf
4f0e081
821790c
34e366a
27204d1
2033b89
7638d2a
d9f6831
93a5982
2b702fe
35ad073
2d0030c
fbb3c30
3d6f4d0
c220bb3
1bf92b2
06af4d8
a14ad87
20d0b6e
b48089f
5bd61ad
2dddedb
3572bd8
020fed3
1c5cbe2
0d173ad
030d705
6326f66
e25aad5
6418551
4140c52
b10f809
c6eafb8
1191215
51d6d9f
69af386
88cf631
2c0a3ce
66dd826
f177f16
1946dca
75848d9
284c6f2
89bac35
ff62313
4a042fc
fbb46ad
2c5d73c
bf239d0
c79890e
b72d217
3e9c7f7
0da3e8a
1d6ecc6
9b07b5b
e852e9d
11a6444
aebee0a
38499c3
2187aab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| name: Deploy Release Notes Function | ||
|
|
||
| on: workflow_dispatch | ||
|
|
||
| jobs: | ||
| deploy: | ||
| runs-on: ubuntu-latest | ||
|
|
||
| permissions: | ||
| contents: read | ||
| id-token: write | ||
|
|
||
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| - name: Google Cloud Auth | ||
| id: auth | ||
| uses: google-github-actions/auth@v2 | ||
| with: | ||
| credentials_json: ${{ secrets.GCP_SA_CREDENTIALS }} | ||
|
|
||
| - name: Set up gcloud | ||
| uses: google-github-actions/setup-gcloud@v2 | ||
|
|
||
| - name: Deploy to Cloud Functions | ||
| working-directory: functions/release_notes | ||
| run: | | ||
| gcloud functions deploy release-notes \ | ||
| --gen2 \ | ||
| --trigger-http \ | ||
| --allow-unauthenticated \ | ||
| --region=us-central1 \ | ||
| --timeout=240 \ | ||
| --memory=2Gi \ | ||
| --runtime=python311 \ | ||
| --entry-point=handle_release_notes \ | ||
| --service-account=review-helper@moz-bugbug.iam.gserviceaccount.com \ | ||
| --set-secrets=OPENAI_API_KEY=openai-api-key:latest |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,250 @@ | ||
| import logging | ||
| import re | ||
| from itertools import batched | ||
| from typing import Generator, Optional | ||
|
|
||
| import requests | ||
| from langchain.chains import LLMChain | ||
| from langchain.prompts import PromptTemplate | ||
|
|
||
| from bugbug import bugzilla, db | ||
|
|
||
| KEYWORDS_TO_REMOVE = [ | ||
| "Backed out", | ||
| "a=testonly", | ||
| "DONTBUILD", | ||
| "add tests", | ||
| "disable test", | ||
| "back out", | ||
| "backout", | ||
| "add test", | ||
| "added test", | ||
| "ignore-this-changeset", | ||
| "CLOSED TREE", | ||
| "nightly", | ||
| ] | ||
|
|
||
| PRODUCT_OR_COMPONENT_TO_IGNORE = [ | ||
| "Firefox Build System::Task Configuration", | ||
| "Developer Infrastructure::", | ||
| ] | ||
|
|
||
|
|
||
| def get_previous_version(current_version: str) -> str: | ||
| match = re.search(r"(\d+)", current_version) | ||
| if not match: | ||
| raise ValueError("No number found in the version string") | ||
|
|
||
| number = match.group(0) | ||
| decremented_number = str(int(number) - 1) | ||
| return ( | ||
| current_version[: match.start()] | ||
| + decremented_number | ||
| + current_version[match.end() :] | ||
| ) | ||
|
|
||
|
|
||
| logging.basicConfig(level=logging.INFO) | ||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class ReleaseNotesCommitsSelector: | ||
| def __init__(self, chunk_size: int, llm: LLMChain): | ||
| self.chunk_size = chunk_size | ||
| self.bug_id_to_component = {} | ||
| db.download(bugzilla.BUGS_DB) | ||
suhaibmujahid marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| for bug in bugzilla.get_bugs(): | ||
| self.bug_id_to_component[ | ||
| bug["id"] | ||
| ] = f"{bug['product']}::{bug['component']}" | ||
| self.llm = llm | ||
| self.summarization_prompt = PromptTemplate( | ||
| input_variables=["input_text"], | ||
| template="""You are an expert in writing Firefox release notes. Your task is to analyze a list of commits and identify important user-facing changes. Follow these steps: | ||
|
|
||
| 1. Must Include Only Meaningful Changes: | ||
| - Only keep commits that significantly impact users and are strictly user-facing, such as: | ||
| - New features | ||
| - UI changes | ||
| - Major performance improvements | ||
| - Security patches (if user-facing) | ||
| - Web platform changes that affect how websites behave | ||
| - DO NOT include: | ||
| - Small bug fixes unless critical | ||
| - Internal code refactoring | ||
| - Test changes or documentation updates | ||
| - Developer tooling or CI/CD pipeline changes | ||
| Again, only include changes that are STRICTLY USER-FACING. | ||
|
|
||
| 2. Output Format: | ||
| - Use simple, non-technical language suitable for release notes. | ||
| - Use the following strict format for each relevant commit, in CSV FORMAT: | ||
| [Type of Change],Description of the change,Bug XXXX,Reason why the change is impactful for end users | ||
| - Possible types of change: [Feature], [Fix], [Performance], [Security], [UI], [DevTools], [Web Platform], etc. | ||
|
|
||
| 3. Be Aggressive in Filtering: | ||
| - If you're unsure whether a commit impacts end users, EXCLUDE it. | ||
| - Do not list developer-focused changes. | ||
|
|
||
| 4. Select Only the Top 10 Commits: | ||
| - If there are more than 10 relevant commits, choose the most impactful ones. | ||
|
|
||
| 5. Input: | ||
| Here is the chunk of commit logs you need to focus on: | ||
suhaibmujahid marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| {input_text} | ||
suhaibmujahid marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| 6. Output Requirements: | ||
| - Output must be raw CSV text—no formatting, no extra text. | ||
| - Do not wrap the output in triple backticks (` ``` `) or use markdown formatting. | ||
| - Do not include the words "CSV" or any headers—just the data. | ||
| """, | ||
| ) | ||
|
|
||
| self.summarization_chain = LLMChain( | ||
| llm=self.llm, | ||
| prompt=self.summarization_prompt, | ||
| ) | ||
|
|
||
| self.cleanup_prompt = PromptTemplate( | ||
| input_variables=["combined_list"], | ||
| template="""Review the following list of release notes and remove anything that is not worthy of official release notes. Keep only changes that are meaningful, impactful, and directly relevant to end users, such as: | ||
| - New features that users will notice and interact with. | ||
| - Significant fixes that resolve major user-facing issues. | ||
| - Performance improvements that make a clear difference in speed or responsiveness. | ||
| - Accessibility enhancements that improve usability for a broad set of users. | ||
| - Critical security updates that protect users from vulnerabilities. | ||
|
|
||
| Strict Filtering Criteria - REMOVE the following: | ||
| - Overly technical web platform changes (e.g., spec compliance tweaks, behind-the-scenes API adjustments). | ||
| - Developer-facing features that have no direct user impact. | ||
| - Minor UI refinements (e.g., button width adjustments, small animation tweaks). | ||
| - Bug fixes that don’t impact most users. | ||
| - Obscure web compatibility changes that apply only to edge-case websites. | ||
| - Duplicate entries or similar changes that were already listed. | ||
|
|
||
| Here is the list to filter: | ||
| {combined_list} | ||
suhaibmujahid marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| Instructions: | ||
| - KEEP THE SAME FORMAT (do not change the structure of entries that remain). | ||
| - REMOVE UNWORTHY ENTRIES ENTIRELY (do not rewrite them—just delete). | ||
| - DO NOT ADD ANY TEXT BEFORE OR AFTER THE LIST. | ||
| - The output must be only the cleaned-up list, formatted exactly the same way. | ||
| """, | ||
| ) | ||
|
|
||
| self.cleanup_chain = LLMChain( | ||
| llm=self.llm, | ||
| prompt=self.cleanup_prompt, | ||
| ) | ||
|
|
||
| def batch_commit_logs(self, commit_log: str) -> list[str]: | ||
| return [ | ||
| "\n".join(batch) | ||
| for batch in batched(commit_log.strip().split("\n"), self.chunk_size) | ||
| ] | ||
|
|
||
| def generate_commit_shortlist(self, commit_log_list: list[str]) -> list[str]: | ||
| commit_log_list_combined = "\n".join(commit_log_list) | ||
| chunks = self.batch_commit_logs(commit_log_list_combined) | ||
| return [ | ||
| self.summarization_chain.run({"input_text": chunk}).strip() | ||
| for chunk in chunks | ||
| ] | ||
|
|
||
| def filter_irrelevant_commits( | ||
marco-c marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| self, commit_log_list: list[tuple[str, str, str]] | ||
| ) -> Generator[str, None, None]: | ||
| ignore_revs_url = "https://hg.mozilla.org/mozilla-central/raw-file/tip/.hg-annotate-ignore-revs" | ||
| response = requests.get(ignore_revs_url) | ||
| response.raise_for_status() | ||
| raw_commits_to_ignore = response.text.strip().splitlines() | ||
| hashes_to_ignore = { | ||
| line.split(" ", 1)[0] | ||
| for line in raw_commits_to_ignore | ||
| if re.search(r"Bug \d+", line, re.IGNORECASE) | ||
| } | ||
|
|
||
| for desc, author, node in commit_log_list: | ||
| bug_match = re.search(r"(Bug (\d+).*)", desc, re.IGNORECASE) | ||
| if ( | ||
| not any( | ||
| keyword.lower() in desc.lower() for keyword in KEYWORDS_TO_REMOVE | ||
| ) | ||
| and bug_match | ||
| and re.search(r"\br=[^\s,]+", desc) | ||
| and author | ||
| != "Mozilla Releng Treescript <[email protected]>" | ||
| and node not in hashes_to_ignore | ||
| ): | ||
| bug_id = int(bug_match.group(2)) | ||
|
|
||
| bug_component = self.bug_id_to_component.get(bug_id) | ||
| if bug_component and any( | ||
| to_ignore in bug_component | ||
| for to_ignore in PRODUCT_OR_COMPONENT_TO_IGNORE | ||
| ): | ||
| continue | ||
| yield bug_match.group(1) | ||
|
|
||
| def get_commit_logs(self) -> Optional[list[tuple[str, str, str]]]: | ||
| url = f"https://hg.mozilla.org/releases/mozilla-release/json-pushes?fromchange={self.version1}&tochange={self.version2}&full=1" | ||
| response = requests.get(url) | ||
| response.raise_for_status() | ||
|
|
||
| data = response.json() | ||
| commit_log_list = [ | ||
| ( | ||
| changeset["desc"].strip(), | ||
| changeset.get("author", "").strip(), | ||
| changeset.get("node", "").strip(), | ||
| ) | ||
| for push_data in data.values() | ||
| for changeset in push_data["changesets"] | ||
| if "desc" in changeset and changeset["desc"].strip() | ||
| ] | ||
|
|
||
| return commit_log_list if commit_log_list else None | ||
|
|
||
| def remove_duplicate_bugs(self, csv_text: str) -> str: | ||
| seen = set() | ||
| unique_lines = [] | ||
| for line in csv_text.strip().splitlines(): | ||
| parts = line.split(",", 3) | ||
| if len(parts) < 3: | ||
| continue | ||
| bug_id = parts[2].strip() | ||
| if bug_id not in seen: | ||
| seen.add(bug_id) | ||
| unique_lines.append(line) | ||
| return "\n".join(unique_lines) | ||
|
|
||
| def get_final_release_notes_commits(self, version: str) -> Optional[list[str]]: | ||
| self.version2 = version | ||
| self.version1 = get_previous_version(version) | ||
suhaibmujahid marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| logger.info(f"Generating commit shortlist for: {self.version2}") | ||
| commit_log_list = self.get_commit_logs() | ||
|
|
||
| if not commit_log_list: | ||
| return None | ||
|
|
||
| logger.info("Filtering irrelevant commits...") | ||
| filtered_commits = list(self.filter_irrelevant_commits(commit_log_list)) | ||
|
|
||
| if not filtered_commits: | ||
| return None | ||
|
|
||
| logger.info("Generating commit shortlist...") | ||
| commit_shortlist = self.generate_commit_shortlist(filtered_commits) | ||
|
|
||
| if not commit_shortlist: | ||
| return None | ||
|
|
||
| logger.info("Refining commit shortlist...") | ||
| combined_list = "\n".join(commit_shortlist) | ||
| cleaned = self.cleanup_chain.run({"combined_list": combined_list}).strip() | ||
|
|
||
| logger.info("Removing duplicates...") | ||
suhaibmujahid marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| deduped = self.remove_duplicate_bugs(cleaned) | ||
| return deduped.splitlines() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import logging | ||
| import os | ||
|
|
||
| import flask | ||
| import functions_framework | ||
|
|
||
| from bugbug import generative_model_tool | ||
| from bugbug.tools.release_notes import ReleaseNotesCommitsSelector | ||
| from bugbug.utils import get_secret | ||
|
|
||
| logging.basicConfig(level=logging.INFO) | ||
| logger = logging.getLogger(__name__) | ||
|
|
||
| os.environ["OPENAI_API_KEY"] = get_secret("OPENAI_API_KEY") | ||
suhaibmujahid marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| tool: ReleaseNotesCommitsSelector | None = None | ||
|
|
||
| DEFAULT_CHUNK_SIZE = 1000 | ||
|
|
||
|
|
||
| @functions_framework.http | ||
| def handle_release_notes(request: flask.Request): | ||
| global tool | ||
|
|
||
| if request.method != "GET": | ||
| return "Only GET requests are allowed", 405 | ||
|
|
||
| version = request.args.get("version") | ||
| if not version: | ||
| return "Missing 'version' query parameter", 400 | ||
|
|
||
| if tool is None: | ||
| logger.info("Initializing new ReleaseNotesCommitsSelector...") | ||
|
|
||
| llm = generative_model_tool.create_openai_llm() | ||
| tool = ReleaseNotesCommitsSelector(chunk_size=DEFAULT_CHUNK_SIZE, llm=llm) | ||
| tool.llm_name = "openai" | ||
| tool.chunk_size = DEFAULT_CHUNK_SIZE | ||
suhaibmujahid marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| notes = tool.get_final_release_notes_commits(version=version) | ||
|
|
||
| if not notes: | ||
| return {"commits": []}, 200 | ||
suhaibmujahid marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| return {"commits": notes}, 200 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| bugbug | ||
suhaibmujahid marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| Flask==2.2.5 | ||
| functions-framework==3.5.0 | ||
| langchain | ||
| openai | ||
| requests | ||
suhaibmujahid marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import argparse | ||
| import logging | ||
|
|
||
| from bugbug import generative_model_tool | ||
| from bugbug.tools.release_notes import ReleaseNotesCommitsSelector | ||
|
|
||
| logging.basicConfig(level=logging.INFO) | ||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def main(): | ||
| parser = argparse.ArgumentParser(description="Generate Firefox release notes.") | ||
| generative_model_tool.create_llm_to_args(parser) | ||
| parser.add_argument("--version", required=True, help="Target version identifier") | ||
| parser.add_argument( | ||
| "--chunk-size", type=int, default=100, help="Number of commits per chunk" | ||
| ) | ||
|
|
||
| args = parser.parse_args() | ||
| llm = generative_model_tool.create_llm_from_args(args) | ||
|
|
||
| selector = ReleaseNotesCommitsSelector(chunk_size=args.chunk_size, llm=llm) | ||
| results = selector.get_final_release_notes_commits(version=args.version) | ||
| print(results) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| from bugbug.tools.release_notes import get_previous_version | ||
|
|
||
|
|
||
| def test_get_previous_version(): | ||
| assert get_previous_version("FIREFOX_BETA_135_BASE") == "FIREFOX_BETA_134_BASE" | ||
| assert get_previous_version("FIREFOX_NIGHTLY_132") == "FIREFOX_NIGHTLY_131" | ||
| assert get_previous_version("FIREFOX_RELEASE_130_2") == "FIREFOX_RELEASE_129_2" | ||
|
||
Uh oh!
There was an error while loading. Please reload this page.