mirror of https://github.com/abpframework/abp.git
committed by
GitHub
19 changed files with 882 additions and 83 deletions
@ -0,0 +1,406 @@ |
|||||
|
#!/usr/bin/env python3 |
||||
|
""" |
||||
|
Comprehensive test suite for update_dependency_changes.py |
||||
|
|
||||
|
Tests cover: |
||||
|
- Basic update/add/remove scenarios |
||||
|
- Version revert scenarios |
||||
|
- Complex multi-step change sequences |
||||
|
- Edge cases and duplicate operations |
||||
|
- Document format validation |
||||
|
""" |
||||
|
|
||||
|
import sys |
||||
|
import os |
||||
|
sys.path.insert(0, os.path.dirname(__file__)) |
||||
|
|
||||
|
from update_dependency_changes import merge_changes, render_section |
||||
|
|
||||
|
|
||||
|
def test_update_then_revert(): |
||||
|
"""Test: PR1 updates A->B, PR2 reverts B->A. Should be removed.""" |
||||
|
print("Test 1: Update then revert") |
||||
|
existing = ( |
||||
|
{"PackageA": ("1.0.0", "2.0.0", "#1")}, # updated |
||||
|
{}, # added |
||||
|
{} # removed |
||||
|
) |
||||
|
new = ( |
||||
|
{"PackageA": ("2.0.0", "1.0.0", "#2")}, # updated back |
||||
|
{}, |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageA" not in updated, f"Expected PackageA removed, got: {updated}" |
||||
|
assert len(added) == 0 and len(removed) == 0 |
||||
|
print("✓ Passed: Package correctly removed from updates\n") |
||||
|
|
||||
|
|
||||
|
def test_add_then_remove_same_version(): |
||||
|
"""Test: PR1 adds v1.0, PR2 removes v1.0. Should be completely removed.""" |
||||
|
print("Test 2: Add then remove same version") |
||||
|
existing = ( |
||||
|
{}, |
||||
|
{"PackageB": ("1.0.0", "#1")}, # added |
||||
|
{} |
||||
|
) |
||||
|
new = ( |
||||
|
{}, |
||||
|
{}, |
||||
|
{"PackageB": ("1.0.0", "#2")} # removed |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageB" not in added, f"Expected PackageB removed from added, got: {added}" |
||||
|
assert "PackageB" not in removed, f"Expected PackageB removed from removed, got: {removed}" |
||||
|
assert "PackageB" not in updated |
||||
|
print("✓ Passed: Package correctly removed from all sections\n") |
||||
|
|
||||
|
|
||||
|
def test_remove_then_add_same_version(): |
||||
|
"""Test: PR1 removes v1.0, PR2 adds v1.0. Should be removed.""" |
||||
|
print("Test 3: Remove then add same version") |
||||
|
existing = ( |
||||
|
{}, |
||||
|
{}, |
||||
|
{"PackageC": ("1.0.0", "#1")} # removed |
||||
|
) |
||||
|
new = ( |
||||
|
{}, |
||||
|
{"PackageC": ("1.0.0", "#2")}, # added back |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageC" not in updated, f"Expected PackageC removed from updated, got: {updated}" |
||||
|
assert "PackageC" not in added, f"Expected PackageC removed from added, got: {added}" |
||||
|
assert "PackageC" not in removed, f"Expected PackageC removed from removed, got: {removed}" |
||||
|
print("✓ Passed: Package correctly removed from all sections\n") |
||||
|
|
||||
|
|
||||
|
def test_add_then_remove_different_version(): |
||||
|
"""Test: PR1 adds v1.0, PR2 removes v2.0. Should show as removed v2.0.""" |
||||
|
print("Test 4: Add then remove different version") |
||||
|
existing = ( |
||||
|
{}, |
||||
|
{"PackageD": ("1.0.0", "#1")}, # added |
||||
|
{} |
||||
|
) |
||||
|
new = ( |
||||
|
{}, |
||||
|
{}, |
||||
|
{"PackageD": ("2.0.0", "#2")} # removed different version |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageD" not in added, f"Expected PackageD removed from added, got: {added}" |
||||
|
assert "PackageD" in removed, f"Expected PackageD in removed, got: {removed}" |
||||
|
assert removed["PackageD"][0] == "2.0.0", f"Expected version 2.0.0, got: {removed['PackageD']}" |
||||
|
print(f"✓ Passed: Package correctly tracked as removed with version {removed['PackageD'][0]}\n") |
||||
|
|
||||
|
|
||||
|
def test_update_in_added(): |
||||
|
"""Test: PR1 adds v1.0, PR2 updates to v2.0. Should show as updated 1.0->2.0.""" |
||||
|
print("Test 5: Update a package that was added") |
||||
|
existing = ( |
||||
|
{}, |
||||
|
{"PackageE": ("1.0.0", "#1")}, # added |
||||
|
{} |
||||
|
) |
||||
|
new = ( |
||||
|
{"PackageE": ("1.0.0", "2.0.0", "#2")}, # updated |
||||
|
{}, |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageE" not in added, f"Expected PackageE removed from added, got: {added}" |
||||
|
assert "PackageE" in updated, f"Expected PackageE in updated, got: {updated}" |
||||
|
assert updated["PackageE"] == ("1.0.0", "2.0.0", "#1, #2"), \ |
||||
|
f"Expected ('1.0.0', '2.0.0', '#1, #2'), got: {updated['PackageE']}" |
||||
|
print(f"✓ Passed: Package correctly converted to updated: {updated['PackageE']}\n") |
||||
|
|
||||
|
|
||||
|
def test_multiple_updates(): |
||||
|
"""Test: PR1 updates A->B, PR2 updates B->C. Should show A->C.""" |
||||
|
print("Test 6: Multiple updates") |
||||
|
existing = ( |
||||
|
{"PackageF": ("1.0.0", "2.0.0", "#1")}, # updated |
||||
|
{}, |
||||
|
{} |
||||
|
) |
||||
|
new = ( |
||||
|
{"PackageF": ("2.0.0", "3.0.0", "#2")}, # updated again |
||||
|
{}, |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageF" in updated |
||||
|
assert updated["PackageF"] == ("1.0.0", "3.0.0", "#1, #2"), \ |
||||
|
f"Expected ('1.0.0', '3.0.0', '#1, #2'), got: {updated['PackageF']}" |
||||
|
print(f"✓ Passed: Package correctly shows full range: {updated['PackageF']}\n") |
||||
|
|
||||
|
|
||||
|
def test_multiple_updates_back_to_original(): |
||||
|
"""Test: PR1 updates 1->2, PR2 updates 2->3, PR3 updates 3->1. Should be removed.""" |
||||
|
print("Test 7: Multiple updates ending back at original version") |
||||
|
# Simulate PR1 and PR2 already merged |
||||
|
existing = ( |
||||
|
{"PackageG": ("1.0.0", "3.0.0", "#1, #2")}, # updated through PR1 and PR2 |
||||
|
{}, |
||||
|
{} |
||||
|
) |
||||
|
# PR3 changes back to 1.0.0 |
||||
|
new = ( |
||||
|
{"PackageG": ("3.0.0", "1.0.0", "#3")}, # updated back to original |
||||
|
{}, |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageG" not in updated, f"Expected PackageG removed, got: {updated}" |
||||
|
assert len(added) == 0 and len(removed) == 0 |
||||
|
print("✓ Passed: Package correctly removed (version returned to original)\n") |
||||
|
|
||||
|
|
||||
|
def test_update_remove_add_same_version(): |
||||
|
"""Test: PR1 updates 1->2, PR2 updates 2->3, PR3 removes, PR4 adds v3. Should show updated 1->3.""" |
||||
|
print("Test 8: Update-Update-Remove-Add same version") |
||||
|
# After PR1, PR2, PR3 |
||||
|
existing = ( |
||||
|
{}, |
||||
|
{}, |
||||
|
{"PackageH": ("1.0.0", "#1, #2, #3")} # removed (original was 1.0.0) |
||||
|
) |
||||
|
# PR4 adds back the same version that was removed |
||||
|
new = ( |
||||
|
{}, |
||||
|
{"PackageH": ("3.0.0", "#4")}, # added |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageH" in updated, f"Expected PackageH in updated, got: updated={updated}, added={added}, removed={removed}" |
||||
|
assert updated["PackageH"] == ("1.0.0", "3.0.0", "#1, #2, #3, #4"), \ |
||||
|
f"Expected ('1.0.0', '3.0.0', '#1, #2, #3, #4'), got: {updated['PackageH']}" |
||||
|
print(f"✓ Passed: Package correctly shows as updated: {updated['PackageH']}\n") |
||||
|
|
||||
|
|
||||
|
def test_update_remove_add_original_version(): |
||||
|
"""Test: PR1 updates 1->2, PR2 updates 2->3, PR3 removes, PR4 adds v1. Should be removed.""" |
||||
|
print("Test 9: Update-Update-Remove-Add original version") |
||||
|
# After PR1, PR2, PR3 |
||||
|
existing = ( |
||||
|
{}, |
||||
|
{}, |
||||
|
{"PackageI": ("1.0.0", "#1, #2, #3")} # removed (original was 1.0.0) |
||||
|
) |
||||
|
# PR4 adds back the original version |
||||
|
new = ( |
||||
|
{}, |
||||
|
{"PackageI": ("1.0.0", "#4")}, # added back to original |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageI" not in updated, f"Expected PackageI removed, got: updated={updated}" |
||||
|
assert "PackageI" not in added, f"Expected PackageI removed, got: added={added}" |
||||
|
assert "PackageI" not in removed, f"Expected PackageI removed, got: removed={removed}" |
||||
|
print("✓ Passed: Package correctly removed (added back to original version)\n") |
||||
|
|
||||
|
|
||||
|
def test_update_remove_add_different_version(): |
||||
|
"""Test: PR1 updates 1->2, PR2 updates 2->3, PR3 removes, PR4 adds v4. Should show updated 1->4.""" |
||||
|
print("Test 10: Update-Update-Remove-Add different version") |
||||
|
# After PR1, PR2, PR3 |
||||
|
existing = ( |
||||
|
{}, |
||||
|
{}, |
||||
|
{"PackageJ": ("1.0.0", "#1, #2, #3")} # removed (original was 1.0.0) |
||||
|
) |
||||
|
# PR4 adds a completely different version |
||||
|
new = ( |
||||
|
{}, |
||||
|
{"PackageJ": ("4.0.0", "#4")}, # added new version |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageJ" in updated, f"Expected PackageJ in updated, got: updated={updated}, added={added}, removed={removed}" |
||||
|
assert updated["PackageJ"] == ("1.0.0", "4.0.0", "#1, #2, #3, #4"), \ |
||||
|
f"Expected ('1.0.0', '4.0.0', '#1, #2, #3, #4'), got: {updated['PackageJ']}" |
||||
|
print(f"✓ Passed: Package correctly shows as updated: {updated['PackageJ']}\n") |
||||
|
|
||||
|
|
||||
|
def test_add_update_remove(): |
||||
|
"""Test: PR1 adds v1, PR2 updates to v2, PR3 removes v2. Should be completely removed.""" |
||||
|
print("Test 11: Add-Update-Remove") |
||||
|
# After PR1 and PR2 |
||||
|
existing = ( |
||||
|
{"PackageK": ("1.0.0", "2.0.0", "#1, #2")}, # updated (was added in PR1, updated in PR2) |
||||
|
{}, |
||||
|
{} |
||||
|
) |
||||
|
# PR3 removes v2 |
||||
|
new = ( |
||||
|
{}, |
||||
|
{}, |
||||
|
{"PackageK": ("2.0.0", "#3")} # removed |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageK" not in updated, f"Expected PackageK removed from updated, got: {updated}" |
||||
|
assert "PackageK" not in added, f"Expected PackageK removed from added, got: {added}" |
||||
|
assert "PackageK" in removed, f"Expected PackageK in removed, got: {removed}" |
||||
|
# The removed should track from the original first version |
||||
|
assert removed["PackageK"][0] == "1.0.0", f"Expected removed from 1.0.0, got: {removed['PackageK']}" |
||||
|
print(f"✓ Passed: Package correctly shows as removed from original: {removed['PackageK']}\n") |
||||
|
|
||||
|
|
||||
|
def test_add_remove_add_same_version(): |
||||
|
"""Test: PR1 adds v1, PR2 removes v1, PR3 adds v1 again. Should show as added v1.""" |
||||
|
print("Test 12: Add-Remove-Add same version") |
||||
|
# After PR1 and PR2 (added then removed) |
||||
|
existing = ( |
||||
|
{}, |
||||
|
{}, |
||||
|
{} # Completely removed after PR2 |
||||
|
) |
||||
|
# PR3 adds v1 again |
||||
|
new = ( |
||||
|
{}, |
||||
|
{"PackageL": ("1.0.0", "#3")}, # added |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageL" in added, f"Expected PackageL in added, got: added={added}" |
||||
|
assert added["PackageL"] == ("1.0.0", "#3"), f"Expected ('1.0.0', '#3'), got: {added['PackageL']}" |
||||
|
print(f"✓ Passed: Package correctly shows as added: {added['PackageL']}\n") |
||||
|
|
||||
|
|
||||
|
def test_update_remove_remove(): |
||||
|
"""Test: PR1 updates 1->2, PR2 removes v2, PR3 tries to remove again. Should show removed from v1.""" |
||||
|
print("Test 13: Update-Remove (duplicate remove)") |
||||
|
# After PR1 and PR2 |
||||
|
existing = ( |
||||
|
{}, |
||||
|
{}, |
||||
|
{"PackageM": ("1.0.0", "#1, #2")} # removed (original was 1.0.0) |
||||
|
) |
||||
|
# PR3 tries to remove again (edge case, might not happen in practice) |
||||
|
new = ( |
||||
|
{}, |
||||
|
{}, |
||||
|
{"PackageM": ("1.0.0", "#3")} # removed again |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageM" in removed, f"Expected PackageM in removed, got: {removed}" |
||||
|
# Should keep the original information |
||||
|
assert removed["PackageM"][0] == "1.0.0", f"Expected removed from 1.0.0, got: {removed['PackageM']}" |
||||
|
print(f"✓ Passed: Package correctly maintains removed state: {removed['PackageM']}\n") |
||||
|
|
||||
|
|
||||
|
def test_add_add(): |
||||
|
"""Test: PR1 adds v1, PR2 adds v2 (version changed externally). Should show added v2.""" |
||||
|
print("Test 14: Add-Add (version changed between PRs)") |
||||
|
# After PR1 |
||||
|
existing = ( |
||||
|
{}, |
||||
|
{"PackageN": ("1.0.0", "#1")}, # added |
||||
|
{} |
||||
|
) |
||||
|
# PR2 adds different version (edge case) |
||||
|
new = ( |
||||
|
{}, |
||||
|
{"PackageN": ("2.0.0", "#2")}, # added different version |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageN" in added, f"Expected PackageN in added, got: {added}" |
||||
|
assert added["PackageN"][0] == "2.0.0", f"Expected version 2.0.0, got: {added['PackageN']}" |
||||
|
print(f"✓ Passed: Package correctly shows latest added version: {added['PackageN']}\n") |
||||
|
|
||||
|
|
||||
|
def test_complex_chain_ending_in_original(): |
||||
|
"""Test: Complex chain - Add v1, Update to v2, Remove, Add v2, Update to v1. Should be removed.""" |
||||
|
print("Test 15: Complex chain ending at nothing changed") |
||||
|
# After PR1 (add), PR2 (update), PR3 (remove), PR4 (add back) |
||||
|
existing = ( |
||||
|
{"PackageO": ("1.0.0", "2.0.0", "#1, #2, #3, #4")}, # Complex history |
||||
|
{}, |
||||
|
{} |
||||
|
) |
||||
|
# PR5 updates back to v1 (original from perspective of first state) |
||||
|
new = ( |
||||
|
{"PackageO": ("2.0.0", "1.0.0", "#5")}, # back to start |
||||
|
{}, |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageO" not in updated, f"Expected PackageO removed, got: {updated}" |
||||
|
print(f"✓ Passed: Complex chain correctly removed when ending at original\n") |
||||
|
|
||||
|
|
||||
|
def test_document_format(): |
||||
|
"""Test: Verify the document rendering format.""" |
||||
|
print("Test 16: Document format validation") |
||||
|
|
||||
|
updated = { |
||||
|
"Microsoft.Extensions.Logging": ("8.0.0", "8.0.1", "#123"), |
||||
|
"Newtonsoft.Json": ("13.0.1", "13.0.3", "#456, #789"), |
||||
|
} |
||||
|
|
||||
|
added = { |
||||
|
"Azure.Identity": ("1.10.0", "#567"), |
||||
|
} |
||||
|
|
||||
|
removed = { |
||||
|
"System.Text.Json": ("7.0.0", "#890"), |
||||
|
} |
||||
|
|
||||
|
document = render_section("9.0.0", updated, added, removed) |
||||
|
|
||||
|
# Verify document structure |
||||
|
assert "## 9.0.0" in document, "Version header missing" |
||||
|
assert "| Package | Old Version | New Version | PR |" in document, "Updated table header missing" |
||||
|
assert "Microsoft.Extensions.Logging" in document, "Updated package missing" |
||||
|
assert "**Added:**" in document, "Added section missing" |
||||
|
assert "Azure.Identity" in document, "Added package missing" |
||||
|
assert "**Removed:**" in document, "Removed section missing" |
||||
|
assert "System.Text.Json" in document, "Removed package missing" |
||||
|
|
||||
|
print("✓ Passed: Document format is correct") |
||||
|
print("\nSample output:") |
||||
|
print("-" * 60) |
||||
|
print(document) |
||||
|
print("-" * 60 + "\n") |
||||
|
|
||||
|
|
||||
|
def run_all_tests(): |
||||
|
"""Run all test cases.""" |
||||
|
print("=" * 70) |
||||
|
print("Testing update_dependency_changes.py") |
||||
|
print("=" * 70 + "\n") |
||||
|
|
||||
|
test_update_then_revert() |
||||
|
test_add_then_remove_same_version() |
||||
|
test_remove_then_add_same_version() |
||||
|
test_add_then_remove_different_version() |
||||
|
test_update_in_added() |
||||
|
test_multiple_updates() |
||||
|
test_multiple_updates_back_to_original() |
||||
|
test_update_remove_add_same_version() |
||||
|
test_update_remove_add_original_version() |
||||
|
test_update_remove_add_different_version() |
||||
|
test_add_update_remove() |
||||
|
test_add_remove_add_same_version() |
||||
|
test_update_remove_remove() |
||||
|
test_add_add() |
||||
|
test_complex_chain_ending_in_original() |
||||
|
test_document_format() |
||||
|
|
||||
|
print("=" * 70) |
||||
|
print("All 16 tests passed! ✓") |
||||
|
print("=" * 70) |
||||
|
print("\nTest coverage summary:") |
||||
|
print(" ✓ Basic scenarios (update, add, remove)") |
||||
|
print(" ✓ Version revert handling") |
||||
|
print(" ✓ Complex multi-step sequences") |
||||
|
print(" ✓ Edge cases and duplicates") |
||||
|
print(" ✓ Document format validation") |
||||
|
print("=" * 70) |
||||
|
|
||||
|
|
||||
|
if __name__ == "__main__": |
||||
|
run_all_tests() |
||||
@ -0,0 +1,331 @@ |
|||||
|
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.""" |
||||
|
try: |
||||
|
tree = ET.parse("common.props") |
||||
|
root = tree.getroot() |
||||
|
version_elem = root.find(".//Version") |
||||
|
if version_elem is not None: |
||||
|
return version_elem.text |
||||
|
except FileNotFoundError: |
||||
|
print("Error: 'common.props' file not found.", file=sys.stderr) |
||||
|
except ET.ParseError as ex: |
||||
|
print(f"Error: Failed to parse 'common.props': {ex}", file=sys.stderr) |
||||
|
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, |
||||
|
) |
||||
|
if result.returncode != 0: |
||||
|
raise RuntimeError( |
||||
|
f"Failed to get diff for base ref 'origin/{base_ref}': {result.stderr}" |
||||
|
) |
||||
|
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 = {} |
||||
|
# Use separate patterns to handle different attribute orders |
||||
|
include_pattern = re.compile(r'Include="([^"]+)"') |
||||
|
version_pattern = re.compile(r'Version="([^"]+)"') |
||||
|
for line in lines: |
||||
|
if line.startswith(prefix) and "PackageVersion" in line and not line.startswith(prefix * 3): |
||||
|
include_match = include_pattern.search(line) |
||||
|
version_match = version_pattern.search(line) |
||||
|
if include_match and version_match: |
||||
|
packages[include_match.group(1)] = version_match.group(1) |
||||
|
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) |
||||
|
# Convert added to updated since the version changed again |
||||
|
del merged_added[pkg] |
||||
|
merged_updated[pkg] = (existing_ver, 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] |
||||
|
# Only delete if versions match (added then removed the same version) |
||||
|
if existing_ver == ver: |
||||
|
del merged_added[pkg] |
||||
|
else: |
||||
|
# Version changed between add and remove, convert to updated then removed |
||||
|
del merged_added[pkg] |
||||
|
merged_removed[pkg] = (ver, merge_prs(existing_pr, pr)) |
||||
|
elif pkg in merged_updated: |
||||
|
old_ver, new_ver, existing_pr = merged_updated.pop(pkg) |
||||
|
merged_pr = merge_prs(existing_pr, pr) |
||||
|
# Only keep as removed if the final state is different from original |
||||
|
merged_removed[pkg] = (old_ver, merged_pr) |
||||
|
else: |
||||
|
merged_removed[pkg] = (ver, pr) |
||||
|
|
||||
|
# Remove updated entries where old and new versions are the same |
||||
|
merged_updated = {k: v for k, v in merged_updated.items() if v[0] != v[1]} |
||||
|
|
||||
|
# Remove added entries that are also in removed with the same version |
||||
|
for pkg in list(merged_added.keys()): |
||||
|
if pkg in merged_removed: |
||||
|
added_ver, added_pr = merged_added[pkg] |
||||
|
removed_ver, removed_pr = merged_removed[pkg] |
||||
|
if added_ver == removed_ver: |
||||
|
# Package was added and removed at the same version, cancel out |
||||
|
del merged_added[pkg] |
||||
|
del merged_removed[pkg] |
||||
|
|
||||
|
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_arg = sys.argv[2] |
||||
|
|
||||
|
# Validate PR number is numeric |
||||
|
if not re.fullmatch(r"\d+", pr_arg): |
||||
|
print("Invalid PR number; must be numeric.") |
||||
|
sys.exit(1) |
||||
|
|
||||
|
# Validate base_ref doesn't contain dangerous characters |
||||
|
if not re.fullmatch(r"[a-zA-Z0-9/_.-]+", base_ref): |
||||
|
print("Invalid base ref; contains invalid characters.") |
||||
|
sys.exit(1) |
||||
|
|
||||
|
pr_number = f"#{pr_arg}" |
||||
|
|
||||
|
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 |
||||
|
doc_dir = os.path.dirname(DOC_PATH) |
||||
|
if doc_dir: |
||||
|
os.makedirs(doc_dir, 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() |
||||
@ -0,0 +1,71 @@ |
|||||
|
# Automatically detects and documents NuGet package version changes in PRs. |
||||
|
# Triggers on changes to Directory.Packages.props and: |
||||
|
# - Adds 'dependency-change' label to the PR |
||||
|
# - Updates docs/en/package-version-changes.md with version changes |
||||
|
# - Commits the documentation back to the PR branch |
||||
|
# Note: Only runs for PRs from the same repository (not forks) to ensure write permissions. |
||||
|
name: Nuget Packages Version Change Detector |
||||
|
|
||||
|
on: |
||||
|
pull_request: |
||||
|
paths: |
||||
|
- 'Directory.Packages.props' |
||||
|
types: |
||||
|
- opened |
||||
|
- synchronize |
||||
|
- reopened |
||||
|
- ready_for_review |
||||
|
|
||||
|
permissions: |
||||
|
contents: read |
||||
|
|
||||
|
concurrency: |
||||
|
group: dependency-changes-${{ github.event.pull_request.number }} |
||||
|
cancel-in-progress: false |
||||
|
|
||||
|
jobs: |
||||
|
label: |
||||
|
if: ${{ !github.event.pull_request.draft && !startsWith(github.head_ref, 'auto-merge/') && github.event.pull_request.head.repo.full_name == github.repository && !contains(github.event.head_commit.message, '[skip ci]') }} |
||||
|
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: 1 |
||||
|
|
||||
|
- name: Fetch base branch |
||||
|
run: git fetch origin ${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }} --depth=1 |
||||
|
|
||||
|
- 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: | |
||||
|
set -e |
||||
|
git config user.name "github-actions[bot]" |
||||
|
git config user.email "github-actions[bot]@users.noreply.github.com" |
||||
|
git add "$DOC_PATH" |
||||
|
if git diff --staged --quiet; then |
||||
|
echo "No changes to commit." |
||||
|
else |
||||
|
git commit -m "docs: update package version changes [skip ci]" |
||||
|
if ! git push; then |
||||
|
echo "Error: Failed to push changes. This may be due to conflicts or permission issues." |
||||
|
exit 1 |
||||
|
fi |
||||
|
echo "Successfully committed and pushed documentation changes." |
||||
|
fi |
||||
Loading…
Reference in new issue