diff --git a/.github/scripts/generate_release_notes.py b/.github/scripts/generate_release_notes.py deleted file mode 100644 index d992f1087bc..00000000000 --- a/.github/scripts/generate_release_notes.py +++ /dev/null @@ -1,181 +0,0 @@ -import os -import requests -import re -from datetime import datetime -from collections import defaultdict -from datetime import timezone - -GITHUB_TOKEN = os.environ['GITHUB_TOKEN'] -REPO = os.environ['GITHUB_REPOSITORY'] -RELEASE_TAG = os.environ.get('RELEASE_TAG', 'Unreleased') -START_DATE_STR = os.environ.get('START_DATE') # expected format: yyyy-mm-dd -END_DATE_STR = os.environ.get('END_DATE') # expected format: yyyy-mm-dd - -def parse_date_aware(date_str): - # append UTC offset if missing and parse - if date_str and 'T' not in date_str: - date_str = date_str + "T00:00:00+00:00" - elif date_str and date_str.endswith('Z'): - date_str = date_str.replace('Z', '+00:00') - return datetime.fromisoformat(date_str) if date_str else None - -START_DATE = parse_date_aware(START_DATE_STR) -END_DATE = parse_date_aware(END_DATE_STR) - -API_URL = f"https://api.github.com/repos/{REPO}/commits" -HEADERS = {"Authorization": f"token {GITHUB_TOKEN}"} - -def fetch_commits(): - commits = [] - page = 1 - params = { - "per_page": 100, - } - if START_DATE_STR: - params["since"] = START_DATE_STR + "T00:00:00Z" - if END_DATE_STR: - params["until"] = END_DATE_STR + "T23:59:59Z" - - while True: - params["page"] = page - response = requests.get(API_URL, headers=HEADERS, params=params) - data = response.json() - if not data or 'message' in data: - break - commits.extend(data) - page += 1 - return commits - -def is_bot_commit(commit): - # Same as before, skip commits by known bots (dependabot, etc.) - author = commit.get('author') - commit_author_name = commit['commit']['author']['name'].lower() if commit['commit']['author']['name'] else '' - author_login = author.get('login', '').lower() if author else '' - bot_indicators = ['bot', 'dependabot', 'actions-user'] - if any(bot_name in author_login for bot_name in bot_indicators): - return True - if any(bot_name in commit_author_name for bot_name in bot_indicators): - return True - - # Also skip commits with messages indicating package bumps or updates - message = commit['commit']['message'].lower() - - # List of keywords to detect in commit messages - keywords = ['bump', 'applying package updates', 'no_ci', 'no ci'] - - for keyword in keywords: - if keyword in message: - return True - - return False - -def filter_commits_by_date(commits): - if not START_DATE and not END_DATE: - filtered = [] - for c in commits: - if not is_bot_commit(c): - filtered.append(c) - return filtered - - filtered = [] - for commit in commits: - if is_bot_commit(commit): - continue - commit_date = datetime.fromisoformat(commit['commit']['author']['date'].replace("Z", "+00:00")) - if START_DATE and commit_date < START_DATE: - continue - if END_DATE and commit_date > END_DATE: - continue - filtered.append(commit) - return filtered - -def categorize_commits(commits): - categories = { - "Reliability": [], - "New Features": [], - "Breaking Changes": [], - "New Architecture-specific changes": [], - "Other": [] - } - - # Keywords for each category (lowercase) - keywords = { - "Reliability": ["fix", "bug", "error", "issue", "crash", "fault", "defect", "patch"], - "New Features": ["feature", "add", "introduce", "support", "enable"], - "Breaking Changes": ["break", "remove", "deprecated", "incompatible", "remove support", "change api", "breaking"], - "New Architecture-specific changes": ["implement", "new", "fabric", "arch", "modal", "architecture", "refactor", "restructure", "modularize"] - } - - for commit in commits: - message = commit['commit']['message'] - sha = commit['sha'] - url = commit['html_url'] - entry = f"- {message.splitlines()[0]} [{message.splitlines()[0]} · microsoft/react-native-windows@{sha[:7]} (github.com)]({url})" - msg_lower = message.lower() - - # Track which categories matched to avoid multiple assignments - matched_categories = [] - - for category, keys in keywords.items(): - if any(key in msg_lower for key in keys): - matched_categories.append(category) - - # Prioritize categories by order: Breaking > New Features > Reliability > Architecture > Other - if "Breaking Changes" in matched_categories: - categories["Breaking Changes"].append(entry) - elif "New Features" in matched_categories: - categories["New Features"].append(entry) - elif "Reliability" in matched_categories: - categories["Reliability"].append(entry) - elif "New Architecture-specific changes" in matched_categories: - categories["New Architecture-specific changes"].append(entry) - else: - categories["Other"].append(entry) - - return categories - -def generate_release_notes(commits, categories): - if commits: - # Use input dates if provided, else fallback to commit dates - start_date = START_DATE_STR or datetime.fromisoformat( - commits[0]['commit']['author']['date'].replace("Z", "+00:00")).strftime("%Y-%m-%d") - end_date = END_DATE_STR or datetime.fromisoformat( - commits[-1]['commit']['author']['date'].replace("Z", "+00:00")).strftime("%Y-%m-%d") - # Format to mm/dd/yyyy for release notes - start_date_fmt = datetime.fromisoformat(start_date).strftime("%m/%d/%Y") - end_date_fmt = datetime.fromisoformat(end_date).strftime("%m/%d/%Y") - else: - start_date_fmt = START_DATE_STR or "N/A" - end_date_fmt = END_DATE_STR or "N/A" - - notes = [] - notes.append(f"{RELEASE_TAG} Release Notes") - notes.append("") - notes.append( - f"We're excited to release React Native Windows {RELEASE_TAG} targeting React Native {RELEASE_TAG}! " - f"There have been many changes to both react-native-windows and react-native itself, and we would love your " - f"feedback on anything that doesn't work as expected. This release includes the commits to React Native Windows " - f"from {start_date_fmt} - {end_date_fmt}." - ) - notes.append("") - notes.append("## How to upgrade") - notes.append("You can view the changes made to the default new React Native Windows applications for C++ and C# " - "using React Native Upgrade Helper. See this [document](https://microsoft.github.io/react-native-windows/docs/upgrade-app) for more details.") - notes.append("") - for category, entries in categories.items(): - if entries: - notes.append(f"## {category}") - notes.extend(entries) - notes.append("") - return "\n".join(notes) - -def main(): - commits = fetch_commits() - commits = filter_commits_by_date(commits) - categories = categorize_commits(commits) - release_notes = generate_release_notes(commits, categories) - with open("release_notes.md", "w", encoding="utf-8") as f: - f.write(release_notes) - -if __name__ == "__main__": - main() diff --git a/.github/workflows/generate-release-notes.yml b/.github/workflows/generate-release-notes.yml deleted file mode 100644 index 0b5a00950e7..00000000000 --- a/.github/workflows/generate-release-notes.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Generate Release Notes - -# commenting out the trigger for now, find way to run this script in some 'yarn release-notes' command -# on: -# push: -# paths: -# - '.github/workflows/generate-release-notes.yml' -# pull_request: -# paths: -# - '.github/workflows/generate-release-notes.yml' - -jobs: - generate-release-notes: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - repository: anupriya13/react-native-windows - fetch-depth: 0 - ref: ${{ github.head_ref != '' && github.head_ref || github.ref_name }} - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: 3.11.12 - - - name: Install dependencies - run: pip install requests - - - name: Generate release notes file - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: '0.79.0' # Adjust this release as needed - START_DATE: '2025-05-06' # Adjust this date as needed - END_DATE: '2025-05-30' # Adjust this date as needed - run: | - python .github/scripts/generate_release_notes.py > release_notes.md - mkdir -p .github/release_notes - mv release_notes.md .github/release_notes/release_notes.md - - - name: Upload release notes - uses: actions/upload-artifact@v4 - with: - name: release-notes - path: .github/release_notes/release_notes.md diff --git a/change/@rnw-scripts-generate-release-notes-6cbf3b31-93bb-44c9-91a0-284f2d6a73d3.json b/change/@rnw-scripts-generate-release-notes-6cbf3b31-93bb-44c9-91a0-284f2d6a73d3.json new file mode 100644 index 00000000000..2660e9b1ea3 --- /dev/null +++ b/change/@rnw-scripts-generate-release-notes-6cbf3b31-93bb-44c9-91a0-284f2d6a73d3.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Add a new \"yarn release-notes\" script to generate release notes", + "packageName": "@rnw-scripts/generate-release-notes", + "email": "54227869+anupriya13@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/package.json b/package.json index 861e3ef8ee9..35deae932aa 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "format": "format-files -i -style=file", "format:verify": "format-files -i -style=file -verify", "postinstall": "yarn build", + "release-notes": "yarn workspace @rnw-scripts/generate-release-notes release-notes", "spellcheck": "npx cspell", "test": "lage test --verbose --passWithNoTests", "validate-overrides": "react-native-platform-override validate" diff --git a/packages/@rnw-scripts/generate-release-notes/.gitignore b/packages/@rnw-scripts/generate-release-notes/.gitignore new file mode 100644 index 00000000000..a7d1a810b89 --- /dev/null +++ b/packages/@rnw-scripts/generate-release-notes/.gitignore @@ -0,0 +1 @@ +release_notes.md \ No newline at end of file diff --git a/packages/@rnw-scripts/generate-release-notes/ReadMe.md b/packages/@rnw-scripts/generate-release-notes/ReadMe.md new file mode 100644 index 00000000000..8d59716eb71 --- /dev/null +++ b/packages/@rnw-scripts/generate-release-notes/ReadMe.md @@ -0,0 +1,43 @@ +### Type of Change +Automate release notes creation by adding a new yarn script. Automating the process of creating release notes so that we don't have to manually copy paste the commits. + + +### Why +To save us some time when generating release notes. Fetches commit from start and end date range, ignores bots and creates the release notes md file. It also categorizes the commits. Please cross-check the generated release-notes.md file and update it manually if required like regrouping commits or updating the Summary/Explanation for the PR commit. + +## Format + +`Explanation. [PRName (#11168) · microsoft/react-native-windows@aaaaaaa (github.com)](link)` + +### Steps to follow + +#### 1. Set up your personal access token + +- Go to GitHub and log in: https://github.com/ +- Click on your profile picture (top-right corner), then click Settings +- On the left sidebar, click Developer settings +- Then click Personal access tokens > Tokens (classic) +- Click Generate new token > Generate new token (classic) +- Give it a name like "Release Notes Script" +- Set an expiration (choose less than 90 days) +- Under Scopes, select the permissions your script needs. For fetching commits and repo info, you typically need: +repo (full control of private repositories) +or at least repo:status, repo_deployment, public_repo (for public repos) +- Click Generate token +- Find the token you're using (whichever token you created). +- You should see a message or option to "Grant access to your organization" or "Authorize SAML SSO" for your token. +- Click that button to authorize the token with the organization. +- Copy the generated token + +#### 2. Set env variables at root of the repo + +``` +set GITHUB_TOKEN= +set RELEASE_TAG=0.79.0 +set START_DATE=2025-05-06 +set END_DATE=2025-05-30 + +``` +#### 3. Run "`yarn release-notes`" at the root of the repo + +#### 4. You will see a release-notes.md file generated at packages\@rnw-scripts\generate-release-notes\release_notes.md which will have all the data you need. \ No newline at end of file diff --git a/packages/@rnw-scripts/generate-release-notes/generate-release-notes.js b/packages/@rnw-scripts/generate-release-notes/generate-release-notes.js new file mode 100644 index 00000000000..af44fc17b0e --- /dev/null +++ b/packages/@rnw-scripts/generate-release-notes/generate-release-notes.js @@ -0,0 +1,209 @@ +import fetch from 'node-fetch'; +import fs from 'fs'; +import process from 'process'; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const REPO = 'microsoft/react-native-windows'; +const RELEASE_TAG = process.env.RELEASE_TAG || 'Unreleased'; +const START_DATE = process.env.START_DATE; +const END_DATE = process.env.END_DATE; + +if (!GITHUB_TOKEN) { + console.error('GITHUB_TOKEN is not set. Please set it before running.'); + process.exit(1); +} +if (!START_DATE || !END_DATE) { + console.error('START_DATE and END_DATE are required.'); + process.exit(1); +} + +console.log(`Generating release notes for ${REPO} from ${START_DATE} to ${END_DATE}...`); + +const HEADERS = { + Authorization: `token ${GITHUB_TOKEN}`, + Accept: 'application/vnd.github+json', +}; + +function parseDate(dateStr) { + return dateStr ? new Date(dateStr) : null; +} + +const START = parseDate(START_DATE); +const END = parseDate(END_DATE); + +function isBotCommit(commit) { + const author = commit.author; + const commitAuthorName = (commit.commit.author.name || '').toLowerCase(); + const authorLogin = (author?.login || '').toLowerCase(); + const botIndicators = ['bot', 'dependabot', 'actions-user']; + const msg = commit.commit.message.toLowerCase(); + + if ( + botIndicators.some( + (bot) => authorLogin.includes(bot) || commitAuthorName.includes(bot) + ) + ) + return true; + if (['bump', 'applying package updates', 'no_ci', 'no ci'].some((k) => msg.includes(k))) + return true; + return false; +} + +function formatDate(date) { + return date ? new Date(date).toLocaleDateString('en-US') : 'N/A'; +} + +async function fetchCommits() { + const commits = []; + let page = 1; + const perPage = 100; + + while (true) { + const url = new URL(`https://api.github.com/repos/${REPO}/commits`); + url.searchParams.set('per_page', perPage); + url.searchParams.set('page', page); + if (START_DATE) url.searchParams.set('since', START_DATE + 'T00:00:00Z'); + if (END_DATE) url.searchParams.set('until', END_DATE + 'T23:59:59Z'); + + console.log(`Fetching commits from: ${url.toString()}`); + + const res = await fetch(url, { headers: HEADERS }); + + if (!res.ok) { + console.error(`GitHub API request failed: ${res.status} ${res.statusText}`); + const errText = await res.text(); + console.error('Response body:', errText); + break; + } + + const data = await res.json(); + + if (!Array.isArray(data)) { + console.error('Unexpected response format:', data); + break; + } + + console.log(`Fetched page ${page} with ${data.length} commits.`); + + if (data.length === 0) break; + + commits.push(...data); + page++; + } + + console.log(`Total commits fetched: ${commits.length}`); + return commits; +} + +function filterCommitsByDate(commits) { + return commits.filter((c) => { + if (isBotCommit(c)) return false; + const commitDate = new Date(c.commit.author.date); + if (START && commitDate < START) return false; + if (END && commitDate > END) return false; + return true; + }); +} + +function categorizeCommits(commits) { +// TODO: Update logic for commits categorisation, refer 'All Commits' section for all the commits. + const categories = { + 'All Commits': [], + 'Breaking Changes': [], + 'New Features': [], + 'Reliability': [], + 'New Architecture-specific changes': [], + Other: [], + }; + + const keywords = { + 'All Commits': [], + 'Breaking Changes': [ + 'break', + 'remove', + 'deprecated', + 'incompatible', + 'remove support', + 'change api', + 'breaking', + ], + 'New Features': ['feature', 'add', 'introduce', 'support', 'enable'], + 'Reliability': ['fix', 'bug', 'error', 'issue', 'crash', 'fault', 'defect', 'patch'], + 'New Architecture-specific changes': [ + 'implement', + 'new', + 'fabric', + 'arch', + 'modal', + 'architecture', + 'refactor', + 'restructure', + 'modularize', + ], + }; + + for (const c of commits) { + const msg = c.commit.message; + const lowerMsg = msg.toLowerCase(); + const sha = c.sha.slice(0, 7); + const url = c.html_url; + const entry = `- ${msg.split('\n')[0]} [${msg.split('\n')[0]} · ${REPO}@${sha} (github.com)](${url})`; + + const matched = Object.keys(keywords).filter((k) => + keywords[k].some((word) => lowerMsg.includes(word)) + ); + const category = matched.includes('Breaking Changes') + ? 'Breaking Changes' + : matched.includes('New Features') + ? 'New Features' + : matched.includes('Reliability') + ? 'Reliability' + : matched.includes('New Architecture-specific changes') + ? 'New Architecture-specific changes' + : 'Other'; + + categories['All Commits'].push(entry); + categories[category].push(entry); + } + + return categories; +} + +function generateReleaseNotes(commits, categories) { + const start = formatDate(START || new Date(commits[0]?.commit.author.date)); + const end = formatDate(END || new Date(commits.at(-1)?.commit.author.date)); + + const lines = []; + lines.push(`${RELEASE_TAG} Release Notes\n`); + lines.push( + `We're excited to release React Native Windows ${RELEASE_TAG} targeting React Native ${RELEASE_TAG}!` + ); + lines.push(`This release includes the commits to React Native Windows from ${start} - ${end}.\n`); + lines.push('## How to upgrade'); + lines.push( + 'You can view the changes made to the default new React Native Windows applications for C++ and C# using React Native Upgrade Helper. See this [document](https://microsoft.github.io/react-native-windows/docs/upgrade-app) for more details.\n' + ); + + for (const [category, entries] of Object.entries(categories)) { + if (entries.length > 0) { + lines.push(`## ${category}`); + lines.push(...entries); + lines.push(''); + } + } + + return lines.join('\n'); +} + +async function main() { + const commits = await fetchCommits(); + const filtered = filterCommitsByDate(commits); + const categories = categorizeCommits(filtered); + const notes = generateReleaseNotes(filtered, categories); + fs.writeFileSync('release_notes.md', notes, 'utf8'); +} + +main().catch((err) => { + console.error('Failed to generate release notes:', err); + process.exit(1); +}); diff --git a/packages/@rnw-scripts/generate-release-notes/package.json b/packages/@rnw-scripts/generate-release-notes/package.json new file mode 100644 index 00000000000..bc274891048 --- /dev/null +++ b/packages/@rnw-scripts/generate-release-notes/package.json @@ -0,0 +1,13 @@ +{ + "name": "@rnw-scripts/generate-release-notes", + "version": "1.0.0", + "description": "Generates release notes for React Native Windows", + "main": "generate-release-notes.js", + "scripts": { + "release-notes": "node generate-release-notes.js" + }, + "dependencies": { + "node-fetch": "^3.3.2" + }, + "type": "module" +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 6c91664ceb3..0a69397f62f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4923,6 +4923,11 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" +data-uri-to-buffer@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" + integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== + data-uri-to-buffer@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz#8a58bb67384b261a38ef18bea1810cb01badd28b" @@ -6017,6 +6022,14 @@ fd-slicer@~1.1.0: dependencies: pend "~1.2.0" +fetch-blob@^3.1.2, fetch-blob@^3.1.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9" + integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== + dependencies: + node-domexception "^1.0.0" + web-streams-polyfill "^3.0.3" + figures@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" @@ -6212,6 +6225,13 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" +formdata-polyfill@^4.0.10: + version "4.0.10" + resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" + integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g== + dependencies: + fetch-blob "^3.1.2" + fp-ts@^2.5.0: version "2.16.10" resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-2.16.10.tgz#829b82a46571c2dc202bed38a9c2eeec603e38c4" @@ -9124,6 +9144,11 @@ node-dir@^0.1.17: dependencies: minimatch "^3.0.2" +node-domexception@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + node-emoji@^1.11.0: version "1.11.0" resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.11.0.tgz#69a0150e6946e2f115e9d7ea4df7971e2628301c" @@ -9138,6 +9163,15 @@ node-fetch@^2.2.0, node-fetch@^2.6.1, node-fetch@^2.6.7: dependencies: whatwg-url "^5.0.0" +node-fetch@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.3.2.tgz#d1e889bacdf733b4ff3b2b243eb7a12866a0b78b" + integrity sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA== + dependencies: + data-uri-to-buffer "^4.0.0" + fetch-blob "^3.1.4" + formdata-polyfill "^4.0.10" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -11850,6 +11884,11 @@ wcwidth@^1.0.1: dependencies: defaults "^1.0.3" +web-streams-polyfill@^3.0.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" + integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== + webdriver@6.12.1: version "6.12.1" resolved "https://registry.yarnpkg.com/webdriver/-/webdriver-6.12.1.tgz#30eee65340ea5124aa564f99a4dbc7d2f965b308"