Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
286 changes: 286 additions & 0 deletions .github/scripts/update_dependency_changes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
import subprocess
import re
import os
import sys
import xml.etree.ElementTree as ET


HEADER = "# Package Version Changes\n"
DOC_PATH = os.environ.get("DOC_PATH", "docs/en/package-version-changes.md")


def get_version():
"""Read the current version from common.props."""
tree = ET.parse("common.props")
root = tree.getroot()
version_elem = root.find(".//Version")
if version_elem is not None:
return version_elem.text
return None


def get_diff(base_ref):
"""Get diff of Directory.Packages.props against the base branch."""
result = subprocess.run(
["git", "diff", f"origin/{base_ref}", "--", "Directory.Packages.props"],
capture_output=True,
text=True,
)
return result.stdout


def get_existing_doc_from_base(base_ref):
"""Read the existing document from the base branch."""
result = subprocess.run(
["git", "show", f"origin/{base_ref}:{DOC_PATH}"],
capture_output=True,
text=True,
)
if result.returncode == 0:
return result.stdout
return ""


def parse_diff_packages(lines, prefix):
"""Parse package versions from diff lines with the given prefix (+ or -)."""
packages = {}
pattern = re.compile(r'Include="([^"]+)".*Version="([^"]+)"')
for line in lines:
if line.startswith(prefix) and "PackageVersion" in line and not line.startswith(prefix * 3):
match = pattern.search(line)
if match:
packages[match.group(1)] = match.group(2)
return packages


def classify_changes(old_packages, new_packages, pr_number):
"""Classify diff into updated, added, and removed with PR attribution."""
updated = {}
added = {}
removed = {}

all_packages = sorted(set(list(old_packages.keys()) + list(new_packages.keys())))

for pkg in all_packages:
if pkg in old_packages and pkg in new_packages:
if old_packages[pkg] != new_packages[pkg]:
updated[pkg] = (old_packages[pkg], new_packages[pkg], pr_number)
elif pkg in new_packages:
added[pkg] = (new_packages[pkg], pr_number)
else:
removed[pkg] = (old_packages[pkg], pr_number)

return updated, added, removed


def parse_existing_section(section_text):
"""Parse an existing markdown section to extract package records with PR info."""
updated = {}
added = {}
removed = {}

mode = "updated"
for line in section_text.split("\n"):
if "**Added:**" in line:
mode = "added"
continue
if "**Removed:**" in line:
mode = "removed"
continue
if not line.startswith("|") or line.startswith("| Package") or line.startswith("|---"):
continue

parts = [p.strip() for p in line.split("|")[1:-1]]
if mode == "updated" and len(parts) >= 3:
pr = parts[3] if len(parts) >= 4 else ""
updated[parts[0]] = (parts[1], parts[2], pr)
elif len(parts) >= 2:
pr = parts[2] if len(parts) >= 3 else ""
if mode == "added":
added[parts[0]] = (parts[1], pr)
else:
removed[parts[0]] = (parts[1], pr)

return updated, added, removed


def merge_prs(existing_pr, new_pr):
"""Merge PR numbers, avoiding duplicates."""
if not existing_pr or not existing_pr.strip():
return new_pr
if not new_pr or not new_pr.strip():
return existing_pr

# Parse existing PRs
existing_prs = [p.strip() for p in existing_pr.split(",") if p.strip()]
# Add new PR if not already present
if new_pr not in existing_prs:
existing_prs.append(new_pr)
return ", ".join(existing_prs)


def merge_changes(existing, new):
"""Merge new changes into existing records for the same version."""
ex_updated, ex_added, ex_removed = existing
new_updated, new_added, new_removed = new

merged_updated = dict(ex_updated)
merged_added = dict(ex_added)
merged_removed = dict(ex_removed)

for pkg, (old_ver, new_ver, pr) in new_updated.items():
if pkg in merged_updated:
existing_old_ver, existing_new_ver, existing_pr = merged_updated[pkg]
merged_pr = merge_prs(existing_pr, pr)
merged_updated[pkg] = (existing_old_ver, new_ver, merged_pr)
elif pkg in merged_added:
existing_ver, existing_pr = merged_added[pkg]
merged_pr = merge_prs(existing_pr, pr)
merged_added[pkg] = (new_ver, merged_pr)
else:
merged_updated[pkg] = (old_ver, new_ver, pr)

for pkg, (ver, pr) in new_added.items():
if pkg in merged_removed:
removed_ver, removed_pr = merged_removed.pop(pkg)
merged_pr = merge_prs(removed_pr, pr)
merged_updated[pkg] = (removed_ver, ver, merged_pr)
elif pkg in merged_added:
existing_ver, existing_pr = merged_added[pkg]
merged_pr = merge_prs(existing_pr, pr)
merged_added[pkg] = (ver, merged_pr)
else:
merged_added[pkg] = (ver, pr)

for pkg, (ver, pr) in new_removed.items():
if pkg in merged_added:
existing_ver, existing_pr = merged_added[pkg]
del merged_added[pkg]
# No need to merge PR here as the package is being removed
elif pkg in merged_updated:
old_ver, new_ver, existing_pr = merged_updated.pop(pkg)
merged_pr = merge_prs(existing_pr, pr)
merged_removed[pkg] = (old_ver, merged_pr)
else:
merged_removed[pkg] = (ver, pr)

merged_updated = {k: v for k, v in merged_updated.items() if v[0] != v[1]}

return merged_updated, merged_added, merged_removed


def render_section(version, updated, added, removed):
"""Render a version section as markdown."""
lines = [f"## {version}\n"]

if updated:
lines.append("| Package | Old Version | New Version | PR |")
lines.append("|---------|-------------|-------------|-----|")
for pkg in sorted(updated):
old_ver, new_ver, pr = updated[pkg]
lines.append(f"| {pkg} | {old_ver} | {new_ver} | {pr} |")
lines.append("")

if added:
lines.append("**Added:**\n")
lines.append("| Package | Version | PR |")
lines.append("|---------|---------|-----|")
for pkg in sorted(added):
ver, pr = added[pkg]
lines.append(f"| {pkg} | {ver} | {pr} |")
lines.append("")

if removed:
lines.append("**Removed:**\n")
lines.append("| Package | Version | PR |")
lines.append("|---------|---------|-----|")
for pkg in sorted(removed):
ver, pr = removed[pkg]
lines.append(f"| {pkg} | {ver} | {pr} |")
lines.append("")

return "\n".join(lines)


def parse_document(content):
"""Split document into a list of (version, section_text) tuples."""
sections = []
current_version = None
current_lines = []

for line in content.split("\n"):
match = re.match(r"^## (.+)$", line)
if match:
if current_version:
sections.append((current_version, "\n".join(current_lines)))
current_version = match.group(1).strip()
current_lines = [line]
elif current_version:
current_lines.append(line)

if current_version:
sections.append((current_version, "\n".join(current_lines)))

return sections


def main():
if len(sys.argv) < 3:
print("Usage: update_dependency_changes.py <base-ref> <pr-number>")
sys.exit(1)

base_ref = sys.argv[1]
pr_number = f"#{sys.argv[2]}"

version = get_version()
if not version:
print("Could not read version from common.props.")
sys.exit(1)

diff = get_diff(base_ref)
if not diff:
print("No diff found for Directory.Packages.props.")
sys.exit(0)

diff_lines = diff.split("\n")
old_packages = parse_diff_packages(diff_lines, "-")
new_packages = parse_diff_packages(diff_lines, "+")

new_updated, new_added, new_removed = classify_changes(old_packages, new_packages, pr_number)

if not new_updated and not new_added and not new_removed:
print("No package version changes detected.")
sys.exit(0)

# Load existing document from the base branch
existing_content = get_existing_doc_from_base(base_ref)
sections = parse_document(existing_content) if existing_content else []

# Find existing section for this version
version_index = None
for i, (v, _) in enumerate(sections):
if v == version:
version_index = i
break

if version_index is not None:
existing = parse_existing_section(sections[version_index][1])
merged = merge_changes(existing, (new_updated, new_added, new_removed))
section_text = render_section(version, *merged)
sections[version_index] = (version, section_text)
else:
section_text = render_section(version, new_updated, new_added, new_removed)
sections.insert(0, (version, section_text))

# Write document
os.makedirs(os.path.dirname(DOC_PATH), exist_ok=True)
with open(DOC_PATH, "w") as f:
f.write(HEADER + "\n")
for _, text in sections:
f.write(text.rstrip("\n") + "\n\n")

print(f"Updated {DOC_PATH} for version {version}")


if __name__ == "__main__":
main()
49 changes: 49 additions & 0 deletions .github/workflows/nuget-packages-version-change-detector.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Nuget Packages Version Change Detector

on:
pull_request:
paths:
- 'Directory.Packages.props'
types:
- opened
- synchronize
- reopened
- ready_for_review

permissions:
contents: read

jobs:
label:
if: ${{ !github.event.pull_request.draft && !startsWith(github.head_ref, 'auto-merge/') }}
permissions:
contents: write
pull-requests: write
runs-on: ubuntu-latest
env:
DOC_PATH: docs/en/package-version-changes.md
steps:
- run: gh pr edit "$PR_NUMBER" --add-label "dependency-change"
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ secrets.BOT_SECRET }}
GH_REPO: ${{ github.repository }}

- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0

- uses: actions/setup-python@v5
with:
python-version: '3.x'

- run: python .github/scripts/update_dependency_changes.py ${{ github.event.pull_request.base.ref }} ${{ github.event.pull_request.number }}

- name: Commit changes
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add "$DOC_PATH"
git diff --staged --quiet || git commit -m "docs: update package version changes"
git push
Loading