|
3 | 3 | Commands: |
4 | 4 | sync Download and upload all (or one) package(s) defined in a packages YAML file. |
5 | 5 | register Add a new package or version(s) to a packages YAML file. |
| 6 | + upgrade Query Azure DevOps for new versions of all listed packages and register them. |
| 7 | + sort Sort and deduplicate versions in a packages YAML file. |
6 | 8 | download Download a single package version directly from Azure DevOps. |
7 | 9 |
|
8 | 10 | Environment variables: |
@@ -307,6 +309,119 @@ def sync_packages(self) -> None: |
307 | 309 | # Helpers used by the register command |
308 | 310 | # --------------------------------------------------------------------------- |
309 | 311 |
|
| 312 | +def _version_key(v: str) -> tuple[int, ...]: |
| 313 | + """Return a tuple of ints for semver-aware sorting. |
| 314 | +
|
| 315 | + Non-numeric segments (e.g. prerelease labels) fall back to zero so they |
| 316 | + sort below their numeric counterparts. |
| 317 | +
|
| 318 | + Args: |
| 319 | + v: A version string such as ``8.2.3`` or ``1.0.0``. |
| 320 | +
|
| 321 | + Returns: |
| 322 | + A tuple of integers suitable for use as a sort key. |
| 323 | + """ |
| 324 | + parts: list[int] = [] |
| 325 | + for segment in v.split("."): |
| 326 | + try: |
| 327 | + parts.append(int(segment)) |
| 328 | + except ValueError: |
| 329 | + parts.append(0) |
| 330 | + return tuple(parts) |
| 331 | + |
| 332 | + |
| 333 | +def _sort_versions_in_file(packages_file: str) -> dict[str, int]: |
| 334 | + """Sort the versions for every package in *packages_file* in ascending semver order. |
| 335 | +
|
| 336 | + Edits the file in-place using line-based manipulation so that comments and |
| 337 | + overall formatting are preserved. Inline comments on version lines (e.g. |
| 338 | + ``- 1.0.0.1 # unusual release``) travel with their version entry. |
| 339 | +
|
| 340 | + Args: |
| 341 | + packages_file: Path to the packages YAML file. |
| 342 | +
|
| 343 | + Returns: |
| 344 | + A mapping of ``{package_name: version_count}`` for each package whose |
| 345 | + versions were reordered. Packages that were already sorted are omitted. |
| 346 | + """ |
| 347 | + with open(packages_file, "r") as f: |
| 348 | + lines = f.readlines() |
| 349 | + |
| 350 | + changed: dict[str, int] = {} |
| 351 | + current_pkg: Optional[str] = None |
| 352 | + in_versions: bool = False |
| 353 | + ver_indices: list[int] = [] |
| 354 | + |
| 355 | + def _flush() -> None: |
| 356 | + nonlocal ver_indices, in_versions |
| 357 | + if ver_indices and current_pkg is not None: |
| 358 | + def _ver_str(idx: int) -> str: |
| 359 | + raw = lines[idx].strip() |
| 360 | + return raw[2:].split("#")[0].strip() if raw.startswith("- ") else raw |
| 361 | + |
| 362 | + # Deduplicate (first occurrence wins) then sort |
| 363 | + unique: dict[str, str] = {} # version_str -> original line content |
| 364 | + for idx in ver_indices: |
| 365 | + v = _ver_str(idx) |
| 366 | + if v not in unique: |
| 367 | + unique[v] = lines[idx] |
| 368 | + |
| 369 | + sorted_lines = [unique[v] for v in sorted(unique, key=_version_key)] |
| 370 | + original_lines = [lines[i] for i in ver_indices] |
| 371 | + |
| 372 | + # Write sorted lines back; blank any slots freed by deduplication |
| 373 | + for pos, idx in enumerate(ver_indices): |
| 374 | + lines[idx] = sorted_lines[pos] if pos < len(sorted_lines) else "" |
| 375 | + |
| 376 | + if [lines[i] for i in ver_indices] != original_lines: |
| 377 | + changed[current_pkg] = len(sorted_lines) |
| 378 | + ver_indices.clear() |
| 379 | + in_versions = False |
| 380 | + |
| 381 | + for i, line in enumerate(lines): |
| 382 | + stripped = line.strip() |
| 383 | + if stripped.startswith("# "): |
| 384 | + continue |
| 385 | + if stripped.startswith("- name:") or (not stripped.startswith("-") and stripped.startswith("name:")): |
| 386 | + _flush() |
| 387 | + current_pkg = stripped.split("name:", 1)[1].strip() |
| 388 | + elif stripped == "versions:" and current_pkg is not None: |
| 389 | + in_versions = True |
| 390 | + elif in_versions: |
| 391 | + if stripped.startswith("- ") and not stripped.startswith("- name:"): |
| 392 | + ver_indices.append(i) |
| 393 | + elif stripped and not stripped.startswith("#"): |
| 394 | + _flush() |
| 395 | + |
| 396 | + _flush() |
| 397 | + |
| 398 | + with open(packages_file, "w") as f: |
| 399 | + f.writelines(lines) |
| 400 | + |
| 401 | + return changed |
| 402 | + |
| 403 | + |
| 404 | +def _get_azdo_versions(name: str, az_pat: str) -> set[str]: |
| 405 | + """Return all versions available for *name* in the Azure DevOps feed. |
| 406 | +
|
| 407 | + Args: |
| 408 | + name: The NuGet package ID. |
| 409 | + az_pat: Azure DevOps PAT used for authentication. |
| 410 | +
|
| 411 | + Returns: |
| 412 | + A set of version strings available in the feed, or an empty set if the |
| 413 | + package is not found or the request fails. |
| 414 | + """ |
| 415 | + resp = requests.get( |
| 416 | + f"{_AZDO_FEED_BASE}/{name.lower()}/index.json", |
| 417 | + auth=("any", az_pat), |
| 418 | + timeout=15, |
| 419 | + ) |
| 420 | + if resp.status_code != 200: |
| 421 | + return set() |
| 422 | + return set(resp.json().get("versions", [])) |
| 423 | + |
| 424 | + |
310 | 425 | def _validate_versions(name: str, versions: tuple[str, ...], az_pat: str) -> None: |
311 | 426 | """Verify that each requested version exists in the Azure DevOps feed. |
312 | 427 |
|
@@ -372,7 +487,7 @@ def _write_versions_to_file( |
372 | 487 | existing = next((p for p in packages if p.get('name', '').lower() == name.lower()), None) |
373 | 488 |
|
374 | 489 | already_present: set[str] = {str(v) for v in existing.get('versions', [])} if existing else set() |
375 | | - to_add = [v for v in versions if v not in already_present] |
| 490 | + to_add = sorted([v for v in versions if v not in already_present], key=_version_key) |
376 | 491 | skipped = [v for v in versions if v in already_present] |
377 | 492 |
|
378 | 493 | if not to_add: |
@@ -483,6 +598,84 @@ def register( |
483 | 598 | click.echo("Nothing to register.") |
484 | 599 |
|
485 | 600 |
|
| 601 | +@cli.command() |
| 602 | +@click.argument("packages_file", type=click.Path(exists=True, dir_okay=False)) |
| 603 | +@click.option("--package", default=None, |
| 604 | + help="Check only this package name (must exist in the packages file).") |
| 605 | +@click.option("--dry-run", is_flag=True, default=False, |
| 606 | + help="Print new versions that would be registered without writing to the file.") |
| 607 | +@click.option("--include-prerelease", is_flag=True, default=False, |
| 608 | + help="Include prerelease versions (excluded by default).") |
| 609 | +def upgrade(packages_file: str, package: Optional[str], dry_run: bool, include_prerelease: bool) -> None: |
| 610 | + """Check Azure DevOps for new versions of packages listed in PACKAGES_FILE and register them. |
| 611 | +
|
| 612 | + Stable versions only by default; use --include-prerelease to also pick up |
| 613 | + prerelease versions. Requires AZ_DEVOPS_PAT env var. |
| 614 | +
|
| 615 | + After running upgrade, use the sync command to push the new versions to the |
| 616 | + GitHub Package Registry. |
| 617 | + """ |
| 618 | + az_pat = os.getenv("AZ_DEVOPS_PAT") |
| 619 | + if not az_pat: |
| 620 | + raise click.ClickException("AZ_DEVOPS_PAT env var is required.") |
| 621 | + |
| 622 | + with open(packages_file, "r") as f: |
| 623 | + data = yaml.safe_load(f) |
| 624 | + packages: list[dict] = data.get("packages") or [] |
| 625 | + |
| 626 | + if package: |
| 627 | + packages = [p for p in packages if p.get("name", "").lower() == package.lower()] |
| 628 | + if not packages: |
| 629 | + raise click.BadParameter(f"Package '{package}' not found in {packages_file}.") |
| 630 | + |
| 631 | + total_added: list[tuple[str, str]] = [] |
| 632 | + |
| 633 | + for pkg in packages: |
| 634 | + name: str = pkg.get("name", "") |
| 635 | + current: set[str] = {str(v) for v in pkg.get("versions") or []} |
| 636 | + |
| 637 | + click.echo(f"Checking {name}...") |
| 638 | + available = _get_azdo_versions(name, az_pat) |
| 639 | + if not available: |
| 640 | + click.echo(" Not found in Azure DevOps feed — skipping.") |
| 641 | + continue |
| 642 | + |
| 643 | + candidates = available if include_prerelease else {v for v in available if "PRERELEASE" not in v.upper()} |
| 644 | + max_current = max(current, key=_version_key) if current else None |
| 645 | + new_versions = sorted( |
| 646 | + (v for v in candidates - current if max_current is None or _version_key(v) > _version_key(max_current)), |
| 647 | + key=_version_key, |
| 648 | + ) |
| 649 | + if not new_versions: |
| 650 | + click.echo(f" Up to date.") |
| 651 | + continue |
| 652 | + |
| 653 | + click.echo(f" New versions: {', '.join(new_versions)}") |
| 654 | + if not dry_run: |
| 655 | + _write_versions_to_file(packages_file, name, tuple(new_versions)) |
| 656 | + for v in new_versions: |
| 657 | + total_added.append((name, v)) |
| 658 | + |
| 659 | + if dry_run: |
| 660 | + click.echo("\n(Dry run — no changes written.)") |
| 661 | + else: |
| 662 | + click.echo(f"\nUpgrade complete. {len(total_added)} new version(s) registered.") |
| 663 | + if total_added: |
| 664 | + click.echo("Run 'sync' to push them to the GitHub Package Registry.") |
| 665 | + |
| 666 | + |
| 667 | +@cli.command(name="sort") |
| 668 | +@click.argument("packages_file", type=click.Path(exists=True, dir_okay=False)) |
| 669 | +def sort_cmd(packages_file: str) -> None: |
| 670 | + """Sort versions for all packages in PACKAGES_FILE in ascending semver order.""" |
| 671 | + changed = _sort_versions_in_file(packages_file) |
| 672 | + if changed: |
| 673 | + for name, count in changed.items(): |
| 674 | + click.echo(f"Sorted {count} version(s) for {name}") |
| 675 | + else: |
| 676 | + click.echo("All versions already in order.") |
| 677 | + |
| 678 | + |
486 | 679 | @cli.command() |
487 | 680 | @click.argument("name") |
488 | 681 | @click.argument("version") |
|
0 commit comments