-
Notifications
You must be signed in to change notification settings - Fork 3.7k
feat: add Nuget Packages Version Change Detector workflow #24805
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
Merged
Merged
Changes from 1 commit
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
f0f770a
feat: add Nuget Packages Version Change Detector workflow
maliming d5ac962
fix: improve merge_changes logic for handling added and removed packages
maliming 7e6bdb6
fix: enhance error handling and improve document generation in Nuget …
maliming 980ff08
fix: improve error handling and validation in update_dependency_chang…
maliming File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
maliming marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| return None | ||
maliming marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
|
|
||
| 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, | ||
| ) | ||
maliming marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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="([^"]+)"') | ||
maliming marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| for line in lines: | ||
| if line.startswith(prefix) and "PackageVersion" in line and not line.startswith(prefix * 3): | ||
maliming marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| match = pattern.search(line) | ||
| if match: | ||
| packages[match.group(1)] = match.group(2) | ||
maliming marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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 | ||
maliming marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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]} | ||
maliming marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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]}" | ||
maliming marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| 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) | ||
maliming marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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
49
.github/workflows/nuget-packages-version-change-detector.yml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| name: Nuget Packages Version Change Detector | ||
maliming marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| on: | ||
| pull_request: | ||
| paths: | ||
| - 'Directory.Packages.props' | ||
| types: | ||
| - opened | ||
| - synchronize | ||
| - reopened | ||
| - ready_for_review | ||
maliming marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| label: | ||
| if: ${{ !github.event.pull_request.draft && !startsWith(github.head_ref, 'auto-merge/') }} | ||
maliming marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| permissions: | ||
| contents: write | ||
| pull-requests: write | ||
maliming marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 }} | ||
maliming marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| GH_REPO: ${{ github.repository }} | ||
maliming marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| - uses: actions/checkout@v4 | ||
| with: | ||
| ref: ${{ github.event.pull_request.head.ref }} | ||
maliming marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| fetch-depth: 0 | ||
maliming marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
maliming marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| - 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 | ||
maliming marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
maliming marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.