tools/release-notes-automator/generate_release_notes.py (301 lines of code) (raw):
import requests
from datetime import datetime
import os
# List of GitHub repositories to pull release notes from.
# Format: "owner/repo"
REPOS = [
"microsoft/azure_arc"
]
# Static filter values for GitHub API queries
LABELS = ["Issue-Addressed", "Bug-Issue"] # Add more labels as needed
MILESTONE_TITLE = datetime.now().strftime("%B %Y") # Current month/year as milestone title, e.g., "April 2025"
# Optional: Add GitHub token for higher rate limits.
# You can set the token via the GITHUB_TOKEN environment variable.
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN")
HEADERS = {"Authorization": f"token {GITHUB_TOKEN}"} if GITHUB_TOKEN else {}
# Mapping of label keywords to human-readable category headers for release notes
CATEGORY_LABELS = {
"ArcBox": "Jumpstart ArcBox",
"HCIBox": "Jumpstart HCIBox",
"Agora": "Jumpstart Agora"
}
def safe_github_request(*args, **kwargs):
"""
Wrapper for requests.get that handles GitHub rate limiting gracefully.
Prints a clear error if rate limit is exceeded.
"""
try:
response = requests.get(*args, **kwargs)
response.raise_for_status()
return response
except requests.exceptions.HTTPError as e:
# If rate limit is exceeded, print a helpful message and exit
if hasattr(e.response, "status_code") and e.response.status_code == 403 and "rate limit" in e.response.text.lower():
print("\033[91mERROR: GitHub API rate limit exceeded. Please set a GITHUB_TOKEN environment variable for higher limits.\033[0m")
print("See: https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting")
exit(1)
raise
def get_repo_milestone_number(repo, title):
"""
Fetch the milestone number for a given repo and milestone title.
Returns the milestone number if found, else None.
"""
url = f"https://api.github.com/repos/{repo}/milestones"
response = safe_github_request(url, headers=HEADERS)
for milestone in response.json():
if milestone["title"] == title:
return milestone["number"]
return None
def get_closed_issues(repo, milestone_number):
"""
Retrieve all closed issues for a given repo and milestone number,
filtered by the static labels. Handles pagination.
Returns a list of issue objects.
"""
issues = []
page = 1
labels_param = ",".join(LABELS)
while True:
url = f"https://api.github.com/repos/{repo}/issues"
params = {
"state": "closed",
"milestone": milestone_number,
"labels": labels_param,
"per_page": 100,
"page": page
}
response = safe_github_request(url, headers=HEADERS, params=params)
data = response.json()
if not data:
break
# Ensure all labels are present (GitHub API 'labels' param is AND for issues, but double-check)
for issue in data:
if all(label in [lbl["name"] for lbl in issue.get("labels", [])] for label in LABELS):
issues.append(issue)
page += 1
return issues
def has_linked_pr(repo, issue_number):
"""
Check if a given issue has a linked pull request.
Looks for 'connected' events without a commit_id (indicating a PR link).
Returns True if a linked PR is found, else False.
"""
url = f"https://api.github.com/repos/{repo}/issues/{issue_number}/events"
response = safe_github_request(url, headers=HEADERS)
events = response.json()
for event in events:
if event["event"] == "connected" and event.get("commit_id") is None:
# "connected" event with no commit_id = likely a PR, not a commit link
return True
return False
def format_issue(issue):
"""
Format an issue as a Markdown list item with a link.
Example: - [Issue title #123](https://github.com/owner/repo/issues/123)
"""
title = issue["title"].strip()
number = issue["number"]
url = issue["html_url"]
return f"- [{title} #{number}]({url})"
def get_all_closed_issues(repo):
"""
Retrieve all closed issues for a given repo, regardless of milestone or label.
Used to find issues that are not included in the release notes.
"""
issues = []
page = 1
while True:
url = f"https://api.github.com/repos/{repo}/issues"
params = {
"state": "closed",
"per_page": 100,
"page": page
}
response = safe_github_request(url, headers=HEADERS, params=params)
data = response.json()
if not data:
break
issues.extend(data)
page += 1
return issues
def categorize_issue(issue):
"""
Returns the category header for the issue based on its labels.
If no category label is found, returns None.
"""
issue_labels = [lbl["name"] for lbl in issue.get("labels", [])]
for label, header in CATEGORY_LABELS.items():
if label in issue_labels:
return header
return None
def get_closed_prs(repo, milestone_number):
"""
Retrieve all closed PRs for a given repo and milestone number,
filtered by the 'Release-Candidate' label. Handles pagination.
Only returns PRs that are:
- Closed and merged
- Not linked to any issue
- Belong to the current milestone
- Have the "Release-Candidate" label
"""
prs = []
page = 1
while True:
url = f"https://api.github.com/repos/{repo}/pulls"
params = {
"state": "closed",
"per_page": 100,
"page": page
}
response = safe_github_request(url, headers=HEADERS, params=params)
data = response.json()
if not data:
break
for pr in data:
# Only consider PRs that are closed and merged
if pr.get("state") != "closed" or not pr.get("merged_at"):
continue
# Must have milestone and match the current milestone number
pr_milestone = pr.get("milestone")
if not pr_milestone or pr_milestone.get("number") != milestone_number:
continue
# Must have the "Release-Candidate" label
pr_labels = [lbl["name"] for lbl in pr.get("labels", [])]
if "Release-Candidate" not in pr_labels:
continue
# Check for linked issues via timeline events
pr_number = pr["number"]
events_url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/events"
events_resp = safe_github_request(events_url, headers=HEADERS)
events = events_resp.json()
linked_issue = any(
e["event"] == "connected" and e.get("commit_id") is None and e.get("source", {}).get("type") == "issue"
for e in events
)
if not linked_issue:
prs.append(pr)
page += 1
return prs
def format_pr(pr):
"""
Format a PR as a Markdown list item with a link.
Example: - [PR title #123](https://github.com/owner/repo/pull/123)
"""
title = pr["title"].strip()
number = pr["number"]
url = pr["html_url"]
return f"- [PR: {title} #{number}]({url})"
def main():
"""
Main function to generate release notes for the current milestone.
Prints the formatted release notes and a summary.
Also lists issues not included in the release notes, with reasons.
"""
# ANSI color codes for pretty console output
COLOR_RESET = "\033[0m"
COLOR_GREEN = "\033[92m"
COLOR_YELLOW = "\033[93m"
COLOR_RED = "\033[91m"
COLOR_CYAN = "\033[96m"
COLOR_BOLD = "\033[1m"
# Initialize log and output containers
log_lines = [] # Collect log output (without color codes)
month_heading = f"## {MILESTONE_TITLE}\n"
output_lines = [f"\033[1m## {MILESTONE_TITLE}\033[0m\n"] # colored for console
log_output_lines = [month_heading] # plain for log file
total_issues = 0
total_prs = 0 # <-- FIX: Initialize total_prs before use
excluded_issues = [] # List of (issue, [reasons]) tuples
# For categorized markdown output
categorized_issues = {header: [] for header in CATEGORY_LABELS.values()}
uncategorized_issues = []
categorized_prs = [] # For PRs matching the new criteria
print(f"\033[96m🔎 Generating release notes for: \033[1m{MILESTONE_TITLE}\033[0m\n")
log_lines.append(f"🔎 Generating release notes for: {MILESTONE_TITLE}\n")
# Remove per-repo "✅ {issue_count} issues added." output and log
for repo in REPOS:
print(f"\033[96m➡️ Checking `{repo}`...\033[0m")
log_lines.append(f"➡️ Checking `{repo}`...")
milestone_number = get_repo_milestone_number(repo, MILESTONE_TITLE)
if not milestone_number:
print(f"\033[93m ⚠️ No milestone '{MILESTONE_TITLE}' found.\033[0m")
log_lines.append(f" ⚠️ No milestone '{MILESTONE_TITLE}' found.")
continue
# Get all closed issues for this repo (for summary)
all_closed_issues = get_all_closed_issues(repo)
included_issues = get_closed_issues(repo, milestone_number)
included_issue_numbers = set(issue["number"] for issue in included_issues)
issue_count = 0
for issue in included_issues:
reasons = []
if "pull_request" in issue:
reasons.append("Is a pull request, not an issue")
if not has_linked_pr(repo, issue["number"]):
reasons.append("No linked pull request")
if reasons:
excluded_issues.append((issue, reasons))
continue
# Categorize for markdown output
category = categorize_issue(issue)
formatted = format_issue(issue)
if category:
categorized_issues[category].append(formatted)
else:
uncategorized_issues.append(formatted)
output_lines.append(formatted)
log_output_lines.append(formatted)
issue_count += 1
total_issues += issue_count
# Find other closed issues not included in the release notes
for issue in all_closed_issues:
# Skip PRs
if "pull_request" in issue:
continue
# Already processed above
if issue["number"] in included_issue_numbers:
continue
reasons = []
if not issue.get("milestone"):
reasons.append("Issue does not have a milestone")
elif issue.get("milestone", {}).get("title") != MILESTONE_TITLE:
reasons.append(f"Issue milestone is '{issue['milestone']['title']}', not '{MILESTONE_TITLE}'")
missing_labels = [label for label in LABELS if label not in [lbl["name"] for lbl in issue.get("labels", [])]]
if missing_labels:
reasons.append(f"Issue does not have label(s): {', '.join(missing_labels)}")
if not has_linked_pr(repo, issue["number"]):
reasons.append("No linked pull request")
if not reasons:
reasons.append("Unknown exclusion reason")
excluded_issues.append((issue, reasons))
# PRs: Only "Release-Candidate" label, current milestone, not linked to any issue
closed_prs = get_closed_prs(repo, milestone_number)
for pr in closed_prs:
categorized_prs.append(format_pr(pr))
total_prs += len(closed_prs)
# Output categorized release notes to console and log
print(f"\n{COLOR_BOLD}📝 Release Notes Output (by Category):{COLOR_RESET}\n")
log_lines.append("\n📝 Release Notes Output (by Category):\n")
print(f"# Release Notes\n")
log_lines.append("# Release Notes\n")
print(f"## {MILESTONE_TITLE}\n")
log_lines.append(f"## {MILESTONE_TITLE}\n")
for header in CATEGORY_LABELS.values():
print(f"### {header}\n")
log_lines.append(f"### {header}\n")
if categorized_issues[header]:
for line in categorized_issues[header]:
print(line)
log_lines.append(line)
else:
print("_No issues in this category._")
log_lines.append("_No issues in this category._")
print()
log_lines.append("")
print("### Uncategorized\n")
log_lines.append("### Uncategorized\n")
if uncategorized_issues:
for line in uncategorized_issues:
print(line)
log_lines.append(line)
else:
print("_No issues in this category._")
log_lines.append("_No issues in this category._")
print()
log_lines.append("")
# Add PRs section to both output and log
print("### Release Candidate Pull Requests with no linked issue\n")
log_lines.append("### Release Candidate Pull Requests with no linked issue\n")
if categorized_prs:
for line in categorized_prs:
print(line)
log_lines.append(line)
else:
print("_No PRs in this category._")
log_lines.append("_No PRs in this category._")
print()
log_lines.append("")
# Output summary
print(f"\n{COLOR_BOLD}📊 Summary:{COLOR_RESET}")
print(f" {COLOR_GREEN}🐞 Total issues added: {total_issues}{COLOR_RESET}")
print(f" {COLOR_GREEN}🔀 Total PRs added: {total_prs}{COLOR_RESET}")
log_lines.append("\n📊 Summary:")
log_lines.append(f" 🐞 Total issues added: {total_issues}")
log_lines.append(f" 🔀 Total PRs added: {total_prs}")
if total_issues > 0 or total_prs > 0:
print(f" {COLOR_GREEN}🚀 Release notes generated successfully!{COLOR_RESET}")
log_lines.append(" 🚀 Release notes generated successfully!")
else:
print(f" {COLOR_YELLOW}⚠️ No matching issues or PRs found for this milestone.{COLOR_RESET}")
log_lines.append(" ⚠️ No matching issues or PRs found for this milestone.")
# Output excluded issues summary (regression fix: always show this)
if excluded_issues:
print(f"\n{COLOR_YELLOW}🔎 Issues not added to release notes and ready for your review. These closed issues not included in the release notes. Reason(s) for exclusion are provided:{COLOR_RESET}\n")
log_lines.append("\n🔎 Issues not added to release notes and ready for your review. These closed issues not included in the release notes. Reason(s) for exclusion are provided:\n")
for issue, reasons in excluded_issues:
print(f" - {COLOR_BOLD}[{issue['title'].strip()} #{issue['number']}]({issue['html_url']}){COLOR_RESET} — {COLOR_RED}{'; '.join(reasons)}{COLOR_RESET}")
log_lines.append(f" - [{issue['title'].strip()} #{issue['number']}]({issue['html_url']}) — {'; '.join(reasons)}")
else:
print(f"\n{COLOR_GREEN}No excluded issues!{COLOR_RESET}")
log_lines.append("\nNo excluded issues!")
# Write log file
script_dir = os.path.dirname(os.path.abspath(__file__))
logs_dir = os.path.join(script_dir, "logs")
os.makedirs(logs_dir, exist_ok=True) # <-- fix: use exist_ok instead of exist_okay
script_name = os.path.splitext(os.path.basename(__file__))[0]
log_path = os.path.join(logs_dir, f"{script_name}.log")
with open(log_path, "w", encoding="utf-8") as log_file:
log_file.write("\n".join(log_lines))
print(f"\n\033[96m📝 Log file written to: {log_path}\033[0m")
# Write categorized markdown to _index_dummy.md
dummy_md_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_index_dummy.md")
with open(dummy_md_path, "w", encoding="utf-8") as f:
f.write(f"# Release Notes\n\n")
f.write(f"## {MILESTONE_TITLE}\n\n")
for header in CATEGORY_LABELS.values():
f.write(f"### {header}\n\n")
if categorized_issues[header]:
for line in categorized_issues[header]:
f.write(f"{line}\n")
else:
f.write("_No issues in this category._\n")
f.write("\n")
f.write("### Uncategorized\n\n")
if uncategorized_issues:
for line in uncategorized_issues:
f.write(f"{line}\n")
else:
f.write("_No issues in this category._\n")
f.write("\n")
f.write("### Release Candidate Pull Requests with no linked issue\n\n")
if categorized_prs:
for line in categorized_prs:
f.write(f"{line}\n")
else:
f.write("_No PRs in this category._\n")
f.write("\n")
print(f"\n\033[96m📄 Categorized markdown written to: {dummy_md_path}\033[0m")
if __name__ == "__main__":
main()