diff --git a/mgmt_sdk_report.md b/mgmt_sdk_report.md new file mode 100644 index 000000000000..1487c83f4cad --- /dev/null +++ b/mgmt_sdk_report.md @@ -0,0 +1,50 @@ +# Stable Management SDK Report + + +| id | sdk name | api version | beta refresh sdk version | beta refresh pr link | release date | state | stable refresh pr link | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 1 | azure-mgmt-resource | 2025-04-01 | 26.0.0b1 | [#47386](https://github.com/Azure/azure-sdk-for-python/pull/47386) | 2026-06-08 | Done | [47626](https://github.com/Azure/azure-sdk-for-python/pull/47626) | +| 2 | azure-mgmt-loganalytics | 2025-07-01 | 14.0.0b1 | [#47335](https://github.com/Azure/azure-sdk-for-python/pull/47335) | 2026-06-04 | Done | [47629](https://github.com/Azure/azure-sdk-for-python/pull/47629) | +| 3 | azure-mgmt-billing | 2024-04-01 | 8.0.0b1 | [#47277](https://github.com/Azure/azure-sdk-for-python/pull/47277) | 2026-06-02 | Done | [47630](https://github.com/Azure/azure-sdk-for-python/pull/47630) | +| 4 | azure-mgmt-kusto | 2025-02-14 | 4.0.0b1 | [#47278](https://github.com/Azure/azure-sdk-for-python/pull/47278) | 2026-06-02 | Done | [47628](https://github.com/Azure/azure-sdk-for-python/pull/47628) | +| 5 | azure-mgmt-sql | 2025-01-01 | 4.0.0b25 | [#47365](https://github.com/Azure/azure-sdk-for-python/pull/47365) | 2026-06-02 | Done | [47631](https://github.com/Azure/azure-sdk-for-python/pull/47631) | +| 6 | azure-mgmt-datafactory | 2018-06-01 | 10.0.0b1 | [#47198](https://github.com/Azure/azure-sdk-for-python/pull/47198) | 2026-05-28 | Done | [47908](https://github.com/Azure/azure-sdk-for-python/pull/47908) | +| 7 | azure-mgmt-resourcehealth | 2025-05-01 | 1.0.0b7 | [#47187](https://github.com/Azure/azure-sdk-for-python/pull/47187) | 2026-05-28 | Done | [47910](https://github.com/Azure/azure-sdk-for-python/pull/47910) | +| 8 | azure-mgmt-appcontainers | 2026-01-01 | 5.0.0b1 | [#47161](https://github.com/Azure/azure-sdk-for-python/pull/47161) | 2026-05-27 | Done | [47925](https://github.com/Azure/azure-sdk-for-python/pull/47925) | +| 9 | azure-mgmt-databricks | 2026-01-01 | 3.0.0b1 | [#47151](https://github.com/Azure/azure-sdk-for-python/pull/47151) | 2026-05-27 | Done | [47926](https://github.com/Azure/azure-sdk-for-python/pull/47926) | +| 10 | azure-mgmt-storagecache | 2026-01-01 | 4.0.0b1 | [#47154](https://github.com/Azure/azure-sdk-for-python/pull/47154) | 2026-05-27 | Done | [47927](https://github.com/Azure/azure-sdk-for-python/pull/47927) | +| 11 | azure-mgmt-costmanagement | 2025-03-01 | 5.0.0b1 | [#47118](https://github.com/Azure/azure-sdk-for-python/pull/47118) | 2026-05-26 | Done | [47928](https://github.com/Azure/azure-sdk-for-python/pull/47928) | +| 12 | azure-mgmt-databoxedge | 2023-12-01 | 3.0.0b2 | [#47119](https://github.com/Azure/azure-sdk-for-python/pull/47119) | 2026-05-26 | Not Started | | +| 13 | azure-mgmt-redhatopenshift | 2025-07-25 | 4.0.0b1 | [#47124](https://github.com/Azure/azure-sdk-for-python/pull/47124) | 2026-05-26 | Not Started | | +| 14 | azure-mgmt-serialconsole | 2024-07-01 | 1.1.0b2 | [#47125](https://github.com/Azure/azure-sdk-for-python/pull/47125) | 2026-05-26 | Not Started | | +| 15 | azure-mgmt-consumption | 2024-08-01 | 11.0.0b2 | [#47108](https://github.com/Azure/azure-sdk-for-python/pull/47108) | 2026-05-25 | Not Started | | +| 16 | azure-mgmt-cdn | 2025-12-01 | 14.0.0b1 | [#47073](https://github.com/Azure/azure-sdk-for-python/pull/47073) | 2026-05-22 | Not Started | | +| 17 | azure-mgmt-resource-databoundaries | 2024-08-01 | 1.0.0b2 | [#47043](https://github.com/Azure/azure-sdk-for-python/pull/47043) | 2026-05-21 | Not Started | | +| 18 | azure-mgmt-resource-deployments | 2025-04-01 | 1.0.0b2 | [#47044](https://github.com/Azure/azure-sdk-for-python/pull/47044) | 2026-05-21 | Not Started | | +| 19 | azure-mgmt-resource-subscriptions | 2022-12-01 | 1.0.0b2 | [#47042](https://github.com/Azure/azure-sdk-for-python/pull/47042) | 2026-05-21 | Not Started | | +| 20 | azure-mgmt-hybridkubernetes | 2026-05-01 | 2.0.0b1 | [#47018](https://github.com/Azure/azure-sdk-for-python/pull/47018) | 2026-05-20 | Not Started | | +| 21 | azure-mgmt-certificateregistration | 2024-11-01 | 1.0.0b1 | [#46569](https://github.com/Azure/azure-sdk-for-python/pull/46569) | 2026-04-28 | Done | [47764](https://github.com/Azure/azure-sdk-for-python/pull/47764) | +| 22 | azure-mgmt-domainregistration | 2024-11-01 | 1.0.0b1 | [#46570](https://github.com/Azure/azure-sdk-for-python/pull/46570) | 2026-04-28 | Done | [47668](https://github.com/Azure/azure-sdk-for-python/pull/47668) | +| 23 | azure-mgmt-recoveryservicessiterecovery | 2025-08-01 | 2.0.0b1 | [#46275](https://github.com/Azure/azure-sdk-for-python/pull/46275) | 2026-04-27 | Done | [47667](https://github.com/Azure/azure-sdk-for-python/pull/47667) | +| 24 | azure-mgmt-managementgroups | 2023-04-01 | 2.0.0b1 | [#46074](https://github.com/Azure/azure-sdk-for-python/pull/46074) | 2026-04-02 | Done | [47665](https://github.com/Azure/azure-sdk-for-python/pull/47665) | +| 25 | azure-mgmt-networkcloud | 2025-09-01 | 3.0.0b1 | [#46048](https://github.com/Azure/azure-sdk-for-python/pull/46048) | 2026-04-02 | Not Started | | +| 30 | azure-mgmt-sqlvirtualmachine | 2023-10-01 | 1.0.0b7 | | 2026-07-07 | Not Started | | +| 31 | azure-mgmt-servicebus | 2026-01-01 | 10.0.0b2 | | 2026-07-07 | Not Started | | +| 32 | azure-mgmt-peering | 2025-05-01 | 2.0.0b2 | | 2026-07-07 | Done | [#47907](https://github.com/Azure/azure-sdk-for-python/pull/47907) | +| 33 | azure-mgmt-guestconfig | 2024-04-05 | 1.0.0b3 | | 2026-07-07 | Not Started | | +| 34 | azure-mgmt-eventhub | 2026-01-01 | 12.0.0b2 | | 2026-07-07 | Not Started | | +| 35 | azure-mgmt-elastic | 2025-06-01 | 3.0.0b1 | | 2026-07-07 | Not Started | | +| 36 | azure-mgmt-storagesync | 2022-09-01 | 2.0.0b2 | | 2026-07-06 | Not Started | | +| 37 | azure-mgmt-relay | 2024-01-01 | 2.0.0b2 | | 2026-07-06 | Not Started | | +| 38 | azure-mgmt-privatedns | 2024-06-01 | 2.0.0b1 | | 2026-07-06 | Not Started | | +| 39 | azure-mgmt-powerbidedicated | 2021-01-01 | 1.1.0b2 | | 2026-07-06 | Not Started | | +| 40 | azure-mgmt-edgeorder | 2024-02-01 | 3.0.0b1 | | 2026-07-06 | Not Started | | +| 41 | azure-mgmt-dynatrace | 2024-04-24 | 3.0.0b1 | | 2026-07-06 | Not Started | | +| 42 | azure-mgmt-databox | 2025-07-01 | 4.0.0b1 | | 2026-07-06 | Not Started | | +| 43 | azure-mgmt-reservations | 2020-10-25, 2022-11-01 | 3.0.0b1 | | 2026-06-02 | Not Started | | +| 44 | azure-mgmt-automation | 2024-10-23 | 1.1.0b5 | | 2026-05-27 | Not Started | | +| 45 | azure-mgmt-dataprotection | 2026-03-01 | 2.1.0b1 | | 2026-04-07 | Not Started | | +| 46 | azure-mgmt-prometheusrulegroups | 2023-03-01 | 1.0.0b1 | | 2026-07-09 | Not Started | | +| 47 | azure-mgmt-alertprocessingrules | 2021-08-08 | 1.0.0b1 | | 2026-07-09 | Not Started | | +| 48 | azure-mgmt-providerhub | | 1.0.0b1 | | | Not Started | | +| 49 | azure-mgmt-marketplace | | 1.0.0b1 | | | Not Started | | diff --git a/mgmt_stable_sdk_report.py b/mgmt_stable_sdk_report.py new file mode 100644 index 000000000000..fa0ee6ddd282 --- /dev/null +++ b/mgmt_stable_sdk_report.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +"""Append stable-management-SDK refresh status rows to mgmt_sdk_report.md. + +Workflow: + (1) Read management SDK package names from PR 47885's data set. + (2) Keep only packages that exist locally under sdk/*/azure-mgmt-* and have + tsp-location.yaml. + (3) Read apiVersions from _metadata.json. If any api-version contains + "preview", abandon the SDK. + (4) Read the latest SDK version from CHANGELOG.md. + * SDKs with last release dates older than 2026-01-01 are abandoned. + * stable SDK version + stable apiVersions -> already done; do not record. + * beta SDK version + stable apiVersions -> state Not Started. + (5) Append new rows to mgmt_sdk_report.md without editing existing rows. + +Requires the GitHub CLI (gh) to be installed and authenticated. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import re +import shlex +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Sequence + +REPO = "Azure/azure-sdk-for-python" +PR_NUMBER = "47885" +ROOT = Path(__file__).resolve().parent +RELEASE_DATE_CUTOFF = "2026-01-01" +SUPPLEMENTAL_CANDIDATE_SDK_NAMES = ( + "azure-mgmt-domainservices", + "azure-mgmt-alertprocessingrules", + "azure-mgmt-prometheusrulegroups", + "azure-mgmt-providerhub", + "azure-mgmt-marketplace", +) + +SDK_NAME_RE = re.compile(r"\bazure-mgmt-[A-Za-z0-9][A-Za-z0-9_-]*\b") +CHANGELOG_RE = re.compile(r"^##\s+(\S+)\s+\((\d{4}-\d{2}-\d{2})\)") +PR_NUMBER_RE = re.compile(r"\(#(\d+)\)") +REPORT_ROW_RE = re.compile(r"^\|\s*(\d+)\s*\|\s*(azure-mgmt-[^|\s]+)") + + +@dataclass(frozen=True) +class PackageRow: + name: str + api_versions: list[str] + sdk_version: str + release_date: str + state: str + beta_refresh_pr: str = "" + stable_refresh_pr: str = "" + + +def log(message: str) -> None: + print(message, file=sys.stderr, flush=True) + + +def run_command(args: Sequence[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(args, cwd=ROOT, capture_output=True, text=True) + + +def run_gh_json(args: Sequence[str]) -> dict: + result = run_command(["gh", *args]) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or result.stdout.strip()) + return json.loads(result.stdout) + + +def fetch_pr_file(pr_number: str, file_name: str) -> str: + pr_data = run_gh_json(["pr", "view", pr_number, "--repo", REPO, "--json", "headRefName,headRepositoryOwner,headRepository"]) + owner = pr_data["headRepositoryOwner"]["login"] + repo = pr_data["headRepository"]["name"] + ref = pr_data["headRefName"] + contents = run_gh_json(["api", f"repos/{owner}/{repo}/contents/{file_name}?ref={ref}"]) + encoded = "".join(str(contents["content"]).splitlines()) + return base64.b64decode(encoded).decode("utf-8") + + +def sdk_names_from_data(data_text: str) -> list[str]: + names: list[str] = [] + seen: set[str] = set() + for line in data_text.splitlines(): + stripped = line.strip() + if not stripped or ("service" in stripped.lower() and "folder" in stripped.lower()): + continue + for part in shlex.split(stripped): + if SDK_NAME_RE.fullmatch(part) and part not in seen: + seen.add(part) + names.append(part) + return names + + +def sdk_names_from_result(result_text: str) -> list[str]: + names: list[str] = [] + seen: set[str] = set() + for line in result_text.splitlines(): + if not line.startswith("|") or "azure-mgmt" not in line: + continue + cells = [cell.strip() for cell in line.strip().strip("|").split("|")] + if len(cells) < 4: + continue + name = cells[3] + if SDK_NAME_RE.fullmatch(name) and name not in seen: + seen.add(name) + names.append(name) + return names + + +def get_candidate_sdk_names(pr_number: str) -> list[str]: + data_names = sdk_names_from_data(fetch_pr_file(pr_number, "data.txt")) + try: + result_names = sdk_names_from_result(fetch_pr_file(pr_number, "result.md")) + except Exception as ex: # pylint: disable=broad-except + log(f"Could not read result.md from PR {pr_number}; using explicit data.txt names only: {ex}") + result_names = [] + + names: list[str] = [] + seen: set[str] = set() + for name in [*result_names, *data_names, *SUPPLEMENTAL_CANDIDATE_SDK_NAMES]: + if name not in seen: + seen.add(name) + names.append(name) + return names + + +def package_dir(sdk_name: str) -> Path | None: + matches = sorted((ROOT / "sdk").glob(f"*/{sdk_name}")) + for match in matches: + if (match / "tsp-location.yaml").is_file(): + return match + return None + + +def get_api_versions(pkg_dir: Path) -> list[str]: + metadata = pkg_dir / "_metadata.json" + if not metadata.is_file(): + return [] + data = json.loads(metadata.read_text(encoding="utf-8")) + api_versions = data.get("apiVersions") + if isinstance(api_versions, dict): + values = api_versions.values() + elif isinstance(api_versions, list): + values = api_versions + else: + values = [data.get("apiVersion")] + return sorted({str(value) for value in values if value}) + + +def has_preview_api_version(api_versions: Sequence[str]) -> bool: + return any("preview" in version.lower() for version in api_versions) + + +def get_version_and_release_date(pkg_dir: Path) -> tuple[str, str]: + changelog = pkg_dir / "CHANGELOG.md" + if not changelog.is_file(): + return "", "" + for line in changelog.read_text(encoding="utf-8").splitlines(): + match = CHANGELOG_RE.match(line.strip()) + if match: + return match.group(1), match.group(2) + return "", "" + + +def is_beta_version(version: str) -> bool: + return bool(re.search(r"\d+b\d*", version.lower())) + + +def find_refresh_pr(pkg_dir: Path) -> str: + rel_path = pkg_dir.relative_to(ROOT).as_posix() + result = run_command(["git", "log", "-n", "80", "--format=%s", "--", rel_path]) + if result.returncode != 0: + log(f" ! git log failed for {rel_path}: {result.stderr.strip()}") + return "" + + for subject in result.stdout.splitlines(): + match = PR_NUMBER_RE.search(subject) + if match and "refresh" in subject.lower(): + return match.group(1) + return "" + + +def build_rows(sdk_names: Sequence[str], limit: int = 0) -> list[PackageRow]: + rows: list[PackageRow] = [] + processed = 0 + for sdk_name in sdk_names: + if limit and processed >= limit: + break + processed += 1 + log(f"Processing {sdk_name} ...") + + pkg_dir = package_dir(sdk_name) + if not pkg_dir: + log(" - skip: local package with tsp-location.yaml not found") + continue + + api_versions = get_api_versions(pkg_dir) + if not api_versions: + log(" - skip: _metadata.json/apiVersions not found") + continue + if has_preview_api_version(api_versions): + log(f" - skip: preview api-version ({', '.join(api_versions)})") + continue + + sdk_version, release_date = get_version_and_release_date(pkg_dir) + if not sdk_version: + log(" - skip: no version found in CHANGELOG.md") + continue + if release_date and release_date < RELEASE_DATE_CUTOFF: + log(f" - skip: last release date {release_date} is older than {RELEASE_DATE_CUTOFF}") + continue + + if is_beta_version(sdk_version): + rows.append( + PackageRow( + name=sdk_name, + api_versions=api_versions, + sdk_version=sdk_version, + release_date=release_date, + state="Not Started", + ) + ) + log(f" + keep: beta SDK version ({sdk_version}); state=Not Started") + else: + log(f" - skip: stable SDK version ({sdk_version}) is already done") + return rows + + +def existing_report_state(report_path: Path) -> tuple[int, set[str]]: + if not report_path.is_file(): + return 0, set() + max_id = 0 + existing_names: set[str] = set() + for line in report_path.read_text(encoding="utf-8").splitlines(): + match = REPORT_ROW_RE.match(line) + if not match: + continue + max_id = max(max_id, int(match.group(1))) + existing_names.add(match.group(2)) + return max_id, existing_names + + +def markdown_link(pr_number: str) -> str: + if not pr_number: + return "" + return f"[#{pr_number}](https://github.com/{REPO}/pull/{pr_number})" + + +def table_value(value: str) -> str: + return value.replace("|", "\\|") + + +def format_row(row_id: int, row: PackageRow) -> str: + return "| " + " | ".join( + [ + str(row_id), + table_value(row.name), + table_value(", ".join(row.api_versions)), + table_value(row.sdk_version), + markdown_link(row.beta_refresh_pr), + table_value(row.release_date), + table_value(row.state), + markdown_link(row.stable_refresh_pr), + ] + ) + " |" + + +def append_rows(report_path: Path, rows: Sequence[PackageRow], include_existing: bool) -> int: + max_id, existing_names = existing_report_state(report_path) + rows_to_append = [row for row in rows if include_existing or row.name not in existing_names] + if not rows_to_append: + log("No new rows to append.") + return 0 + + existing_text = report_path.read_text(encoding="utf-8") if report_path.is_file() else "" + prefix = "" if not existing_text or existing_text.endswith("\n") else "\n" + lines = [format_row(max_id + index, row) for index, row in enumerate(rows_to_append, start=1)] + with report_path.open("a", encoding="utf-8") as report_file: + report_file.write(prefix + "\n".join(lines) + "\n") + return len(rows_to_append) + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--pr", default=PR_NUMBER, help=f"PR number to read package data from. Defaults to {PR_NUMBER}.") + parser.add_argument("--limit", type=int, default=0, help="Process only the first N candidate SDKs. 0 = all.") + parser.add_argument("--output", type=Path, default=ROOT / "mgmt_sdk_report.md", help="Report file to append to.") + parser.add_argument("--include-existing", action="store_true", help="Append rows even when the SDK name already exists in the report.") + parser.add_argument("--dry-run", action="store_true", help="Print rows to stdout instead of appending to the report.") + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + sdk_names = get_candidate_sdk_names(args.pr) + log(f"Discovered {len(sdk_names)} candidate SDK name(s) from PR {args.pr}.") + rows = build_rows(sdk_names, args.limit) + rows.sort(key=lambda row: (row.release_date, row.name), reverse=True) + + if args.dry_run: + for index, row in enumerate(rows, start=1): + print(format_row(index, row)) + return 0 + + appended = append_rows(args.output, rows, args.include_existing) + log(f"Appended {appended} row(s) to {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test_mgmt_stable_sdk_report.py b/test_mgmt_stable_sdk_report.py new file mode 100644 index 000000000000..e25b3d1503f1 --- /dev/null +++ b/test_mgmt_stable_sdk_report.py @@ -0,0 +1,51 @@ +from pathlib import Path + +import mgmt_stable_sdk_report as report + + +def test_get_candidate_sdk_names_includes_supplemental_candidates(monkeypatch): + files = { + "data.txt": "azure-mgmt-domainservices azure-mgmt-existing\n", + "result.md": "| a | b | c | azure-mgmt-result |\n", + } + + monkeypatch.setattr(report, "fetch_pr_file", lambda pr_number, file_name: files[file_name]) + + names = report.get_candidate_sdk_names("123") + + assert names == [ + "azure-mgmt-result", + "azure-mgmt-domainservices", + "azure-mgmt-existing", + "azure-mgmt-alertprocessingrules", + "azure-mgmt-prometheusrulegroups", + ] + + +def _write_package(root: Path, service: str, name: str, version: str, release_date: str) -> None: + package_dir = root / "sdk" / service / name + package_dir.mkdir(parents=True) + (package_dir / "tsp-location.yaml").write_text("directory: .\n", encoding="utf-8") + (package_dir / "_metadata.json").write_text('{"apiVersions": ["2025-01-01"]}\n', encoding="utf-8") + (package_dir / "CHANGELOG.md").write_text(f"## {version} ({release_date})\n", encoding="utf-8") + + +def test_build_rows_skips_beta_sdk_when_last_release_date_is_before_cutoff(tmp_path, monkeypatch): + monkeypatch.setattr(report, "ROOT", tmp_path) + _write_package(tmp_path, "old", "azure-mgmt-old", "1.0.0b1", "2025-12-31") + _write_package(tmp_path, "current", "azure-mgmt-current", "1.0.0b1", "2026-01-01") + + rows = report.build_rows(["azure-mgmt-old", "azure-mgmt-current"]) + + assert [row.name for row in rows] == ["azure-mgmt-current"] + + +def test_build_rows_skips_stable_sdk_because_stable_release_is_already_done(tmp_path, monkeypatch): + monkeypatch.setattr(report, "ROOT", tmp_path) + monkeypatch.setattr(report, "find_refresh_pr", lambda package_dir: "123") + _write_package(tmp_path, "done", "azure-mgmt-done", "1.0.0", "2026-02-01") + _write_package(tmp_path, "candidate", "azure-mgmt-candidate", "1.0.0b1", "2026-02-01") + + rows = report.build_rows(["azure-mgmt-done", "azure-mgmt-candidate"]) + + assert [row.name for row in rows] == ["azure-mgmt-candidate"] \ No newline at end of file