From fd2ab3d6cb70875d39bcdde60cce90f5dea8b275 Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Thu, 28 May 2026 09:44:03 -0700 Subject: [PATCH 01/33] Add generate_api_text.py script. --- scripts/generate_api_text.py | 189 +++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 scripts/generate_api_text.py diff --git a/scripts/generate_api_text.py b/scripts/generate_api_text.py new file mode 100644 index 000000000000..456308ae49f1 --- /dev/null +++ b/scripts/generate_api_text.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python +"""Generate API.md for an Azure SDK package. + +Usage: + python scripts/generate_api_text.py azure-ai-projects +""" + +import argparse +import glob +import os +import shutil +import subprocess +import sys +import tempfile + + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +APIVIEW_REQS = os.path.join(REPO_ROOT, "eng", "apiview_reqs.txt") +AZURE_SDK_INDEX = "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-python/pypi/simple/" +EXPORT_SCRIPT = os.path.join(REPO_ROOT, "eng", "common", "scripts", "Export-APIViewMarkdown.ps1") + + +def find_package_dir(package_name: str) -> str: + """Find the package directory under sdk/*/{package_name}/.""" + pattern = os.path.join(REPO_ROOT, "sdk", "*", package_name) + matches = glob.glob(pattern) + # Filter to directories that contain a pyproject.toml or setup.py + valid = [ + m for m in matches + if os.path.isdir(m) and ( + os.path.exists(os.path.join(m, "pyproject.toml")) + or os.path.exists(os.path.join(m, "setup.py")) + ) + ] + if not valid: + raise FileNotFoundError(f"Package '{package_name}' not found under sdk/*/") + if len(valid) > 1: + raise ValueError(f"Multiple matches for '{package_name}': {valid}") + return valid[0] + + +def get_installed_version(package: str) -> str | None: + """Get the currently installed version of a package, or None.""" + try: + result = subprocess.run( + [sys.executable, "-m", "pip", "show", package], + capture_output=True, text=True, check=True, + ) + for line in result.stdout.splitlines(): + if line.startswith("Version:"): + return line.split(":", 1)[1].strip() + except subprocess.CalledProcessError: + pass + return None + + +def get_latest_version(package: str) -> str | None: + """Query the Azure SDK feed for the latest version of a package.""" + try: + result = subprocess.run( + [sys.executable, "-m", "pip", "index", "versions", package, + "--index-url", AZURE_SDK_INDEX], + capture_output=True, text=True, check=True, + ) + # Output format: "apiview-stub-generator (0.3.28)" + for line in result.stdout.splitlines(): + if package in line and "(" in line: + version = line.split("(")[1].split(")")[0].strip() + return version + except subprocess.CalledProcessError: + pass + return None + + +def ensure_latest_apiview_stub_generator(): + """Ensure the latest apiview-stub-generator is installed from the Azure SDK feed.""" + installed = get_installed_version("apiview-stub-generator") + latest = get_latest_version("apiview-stub-generator") + + print(f"apiview-stub-generator: installed={installed}, latest={latest}") + + if installed and latest and installed == latest: + print("Already at latest version.") + return + + # Install from apiview_reqs.txt first (gets dependencies right) + print("Installing apiview_reqs.txt...") + subprocess.run( + [sys.executable, "-m", "pip", "install", "-r", APIVIEW_REQS, + f"--index-url={AZURE_SDK_INDEX}"], + check=True, + ) + + # Override with latest version (not the pinned one) + print("Upgrading apiview-stub-generator to latest...") + subprocess.run( + [sys.executable, "-m", "pip", "install", "--upgrade", "apiview-stub-generator", + f"--index-url={AZURE_SDK_INDEX}"], + check=True, + ) + + new_version = get_installed_version("apiview-stub-generator") + print(f"apiview-stub-generator now at version {new_version}") + + +def build_wheel(package_dir: str, output_dir: str) -> str: + """Build a wheel for the package and return the path to the .whl file.""" + subprocess.run( + [sys.executable, "-m", "pip", "wheel", package_dir, "--no-deps", "-w", output_dir], + check=True, + ) + whls = glob.glob(os.path.join(output_dir, "*.whl")) + if not whls: + raise FileNotFoundError(f"No .whl file found in {output_dir}") + return whls[0] + + +def run_apistub(whl_path: str, out_path: str): + """Run apiview-stub-generator on the wheel.""" + subprocess.run( + [sys.executable, "-m", "apistub", + "--pkg-path", whl_path, + "--out-path", out_path, + "--skip-pylint"], + check=True, + ) + + +def export_api_markdown(token_json_path: str, output_path: str): + """Run the Export-APIViewMarkdown.ps1 script to convert token JSON to API.md.""" + subprocess.run( + ["pwsh", EXPORT_SCRIPT, "-TokenJsonPath", token_json_path, "-OutputPath", output_path], + check=True, + ) + + +def main(): + parser = argparse.ArgumentParser(description="Generate API.md for an Azure SDK package.") + parser.add_argument("package", help="Package name (e.g. azure-ai-projects)") + args = parser.parse_args() + + package_name = args.package + print(f"Generating API.md for {package_name}...") + + # Find the package + package_dir = find_package_dir(package_name) + print(f"Found package at: {package_dir}") + + # Ensure latest apiview-stub-generator + ensure_latest_apiview_stub_generator() + + # Build wheel in a temp directory + tmp_dir = tempfile.mkdtemp(prefix="apistub_") + try: + print("Building wheel...") + whl_path = build_wheel(package_dir, tmp_dir) + print(f"Built: {os.path.basename(whl_path)}") + + # Run apiview-stub-generator + print("Running apiview-stub-generator...") + run_apistub(whl_path, tmp_dir) + + # Find the generated token JSON + token_json = os.path.join(tmp_dir, f"{package_name}_python.json") + if not os.path.exists(token_json): + # Try with underscores (package name normalization) + normalized = package_name.replace("-", "_") + token_json = os.path.join(tmp_dir, f"{normalized}_python.json") + if not os.path.exists(token_json): + # Find any json file + jsons = glob.glob(os.path.join(tmp_dir, "*_python.json")) + if jsons: + token_json = jsons[0] + else: + raise FileNotFoundError(f"No token JSON found in {tmp_dir}") + + # Export to API.md + api_md_path = os.path.join(package_dir, "API.md") + print("Exporting API.md...") + export_api_markdown(token_json, api_md_path) + print(f"Generated: {api_md_path}") + + finally: + # Clean up temp directory (wheel + token json) + shutil.rmtree(tmp_dir, ignore_errors=True) + + +if __name__ == "__main__": + main() From ebb3870d4ec352cb1293edd53a5a26350fcc4f89 Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Fri, 29 May 2026 12:27:01 -0700 Subject: [PATCH 02/33] Add PR creation script. --- scripts/create_api_review_pr.py | 421 ++++++++++++++++++++++++++++++++ scripts/generate_api_text.py | 4 +- 2 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 scripts/create_api_review_pr.py diff --git a/scripts/create_api_review_pr.py b/scripts/create_api_review_pr.py new file mode 100644 index 000000000000..8a13a387f2ac --- /dev/null +++ b/scripts/create_api_review_pr.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python +"""Create an API review PR for an Azure SDK Python package. + +Workflow: + 1. Validate that ``--package-name`` exists under ``sdk/*/``. + 2. Build the BASE branch (``base_{package}_{base_version}``): + - If ``--base`` is a tag (e.g. ``azure-ai-projects_1.0.0b1``): check out + the tag, generate API.md, then create the base branch off the latest + ``origin/main`` and commit the captured API.md onto it. + - If ``--base`` is omitted: create the base branch off ``origin/main`` + and delete any existing API.md for the package (no-op if absent). + 3. Build the REVIEW branch (``review_{package}_{target_version}``): + - If ``--target`` is omitted: use the latest ``origin/main``. + - Otherwise: check out the given branch. + Generate API.md on that ref, then commit it on a branch created off + the base branch. + 4. Push both branches to ``origin`` and open a PR with title: + ``[API Review] {package} {target_version} (base {base_version})`` + +Usage:: + + python scripts/create_api_review_pr.py --package-name azure-ai-projects + python scripts/create_api_review_pr.py --package-name azure-ai-projects \\ + --base azure-ai-projects_1.0.0b1 + python scripts/create_api_review_pr.py --package-name azure-ai-projects \\ + --base azure-ai-projects_1.0.0b1 --target my-feature-branch + +Requires ``gh`` (GitHub CLI) authenticated against the repository, plus push +access on the ``origin`` remote. +""" + +from __future__ import annotations + +import argparse +import glob +import os +import re +import shutil +import subprocess +import sys +import tempfile +from typing import Optional + + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +GENERATE_SCRIPT = os.path.join(REPO_ROOT, "scripts", "generate_api_text.py") +EXPORT_SCRIPT = os.path.join(REPO_ROOT, "eng", "common", "scripts", "Export-APIViewMarkdown.ps1") +REMOTE = "origin" +MAIN_REF = f"{REMOTE}/main" + + +# --------------------------------------------------------------------------- +# Shell helpers +# --------------------------------------------------------------------------- + +def run(cmd, *, cwd: str = REPO_ROOT, check: bool = True, capture: bool = False, env: Optional[dict] = None) -> subprocess.CompletedProcess: + """Run a command, echoing it first.""" + printable = " ".join(cmd) if isinstance(cmd, list) else cmd + print(f"$ {printable}") + return subprocess.run( + cmd, + cwd=cwd, + check=check, + text=True, + capture_output=capture, + env=env, + ) + + +def git(*args: str, capture: bool = False, check: bool = True) -> subprocess.CompletedProcess: + return run(["git", *args], capture=capture, check=check) + + +def git_out(*args: str) -> str: + return git(*args, capture=True).stdout.strip() + + +# --------------------------------------------------------------------------- +# Package + ref helpers +# --------------------------------------------------------------------------- + +def find_package_dir(package_name: str) -> str: + """Locate ``sdk/*/{package_name}`` containing a pyproject.toml or setup.py.""" + pattern = os.path.join(REPO_ROOT, "sdk", "*", package_name) + matches = [ + m for m in glob.glob(pattern) + if os.path.isdir(m) + and ( + os.path.exists(os.path.join(m, "pyproject.toml")) + or os.path.exists(os.path.join(m, "setup.py")) + ) + ] + if not matches: + raise SystemExit(f"ERROR: package '{package_name}' not found under sdk/*/") + if len(matches) > 1: + raise SystemExit(f"ERROR: multiple matches for '{package_name}': {matches}") + return matches[0] + + +def package_rel_dir(package_dir: str) -> str: + """Repo-relative POSIX path for the package directory.""" + return os.path.relpath(package_dir, REPO_ROOT).replace(os.sep, "/") + + +def api_md_path(package_dir: str) -> str: + return os.path.join(package_dir, "API.md") + + +def api_md_rel(package_dir: str) -> str: + return f"{package_rel_dir(package_dir)}/API.md" + + +_VERSION_RE = re.compile(r"""^\s*VERSION\s*[:=]\s*["']([^"']+)["']""", re.MULTILINE) + + +def read_version(package_dir: str) -> str: + """Find and parse a ``_version.py`` (or ``version.py``) inside ``package_dir``.""" + candidates = [] + candidates.extend(glob.glob(os.path.join(package_dir, "**", "_version.py"), recursive=True)) + candidates.extend(glob.glob(os.path.join(package_dir, "**", "version.py"), recursive=True)) + for path in candidates: + try: + text = open(path, "r", encoding="utf-8").read() + except OSError: + continue + m = _VERSION_RE.search(text) + if m: + return m.group(1) + raise SystemExit(f"ERROR: could not find a version string in {package_dir}") + + +def tag_exists(tag: str) -> bool: + result = git("rev-parse", "--verify", "--quiet", f"refs/tags/{tag}", capture=True, check=False) + return result.returncode == 0 + + +def ensure_clean_worktree() -> None: + status = git_out("status", "--porcelain") + if status: + raise SystemExit( + "ERROR: working tree is not clean. Commit or stash changes before running.\n" + + status + ) + + +def current_branch() -> str: + return git_out("rev-parse", "--abbrev-ref", "HEAD") + + +def remote_branch_ref(branch: str) -> str: + """Return the ref name for a branch on ``REMOTE``, fetching it first.""" + git("fetch", REMOTE, branch) + return f"{REMOTE}/{branch}" + + +# --------------------------------------------------------------------------- +# API.md generation +# --------------------------------------------------------------------------- + +def generate_api_md(package_name: str, package_dir: str) -> bytes: + """Run ``generate_api_text.py`` for the package and return the bytes of the + resulting API.md. The file is also left on disk at its canonical location. + """ + print(f"--- Generating API.md for {package_name} on {current_branch_or_sha()} ---") + run([sys.executable, GENERATE_SCRIPT, package_name]) + path = api_md_path(package_dir) + if not os.path.exists(path): + raise SystemExit(f"ERROR: generate_api_text.py did not produce {path}") + with open(path, "rb") as f: + return f.read() + + +def current_branch_or_sha() -> str: + name = git_out("rev-parse", "--abbrev-ref", "HEAD") + if name == "HEAD": + return git_out("rev-parse", "--short", "HEAD") + return name + + +# --------------------------------------------------------------------------- +# Main workflow +# --------------------------------------------------------------------------- + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("--package-name", required=True, + help="Package directory name under sdk/*/ (e.g. azure-ai-projects)") + p.add_argument("--base", default=None, + help="Tag to use as the API.md baseline, formatted as " + "'{package-name}_{version}'. Omit to make the baseline empty.") + p.add_argument("--target", default=None, + help="Branch containing the API to review. Omit to use the latest origin/main.") + return p.parse_args() + + +def validate_base_tag(package_name: str, base: str) -> str: + """Validate the ``--base`` tag format/existence and return the version.""" + if not base.startswith(f"{package_name}_"): + raise SystemExit( + f"ERROR: --base tag '{base}' must start with '{package_name}_'." + ) + version = base[len(package_name) + 1:] + if not version: + raise SystemExit(f"ERROR: --base tag '{base}' is missing the version suffix.") + if not tag_exists(base): + raise SystemExit(f"ERROR: tag '{base}' does not exist in this repository.") + return version + + +def write_bytes(path: str, data: bytes) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as f: + f.write(data) + + +def main() -> int: + args = parse_args() + package_name = args.package_name + + package_dir = find_package_dir(package_name) + print(f"Found package at: {package_dir}") + + ensure_clean_worktree() + original_branch = current_branch() + if original_branch == "HEAD": + raise SystemExit("ERROR: refusing to run from a detached HEAD.") + + # Always fetch main once up-front. + git("fetch", REMOTE, "main") + + # ---- Validate inputs -------------------------------------------------- + base_version = "none" + if args.base is not None: + base_version = validate_base_tag(package_name, args.base) + + target_ref: str + if args.target is None: + target_ref = MAIN_REF + else: + target_ref = remote_branch_ref(args.target) + + # Cache the generate + export scripts (they may not exist on older refs we check out). + tmp_script_dir = tempfile.mkdtemp(prefix="apirev_script_") + cached_script = os.path.join(tmp_script_dir, "generate_api_text.py") + cached_export = os.path.join(tmp_script_dir, "Export-APIViewMarkdown.ps1") + shutil.copy2(GENERATE_SCRIPT, cached_script) + shutil.copy2(EXPORT_SCRIPT, cached_export) + + try: + # ---- Step 1: capture base API.md content (if base is a tag) ------ + base_api_bytes: Optional[bytes] = None + if args.base is not None: + print(f"\n=== Capturing baseline API.md from tag {args.base} ===") + git("checkout", "--detach", args.base) + base_api_bytes = _generate_with_cached_script( + cached_script, cached_export, package_name, package_dir + ) + + # ---- Step 2: capture target API.md content ----------------------- + print(f"\n=== Capturing target API.md from {target_ref} ===") + git("checkout", "--detach", target_ref) + target_version = read_version(package_dir) + target_api_bytes = _generate_with_cached_script( + cached_script, cached_export, package_name, package_dir + ) + + # ---- Step 3: build base branch off origin/main ------------------- + base_branch = f"base_{package_name}_{base_version}" + review_branch = f"review_{package_name}_{target_version}" + + print(f"\n=== Creating base branch {base_branch} ===") + git("checkout", "-B", base_branch, MAIN_REF) + + api_path = api_md_path(package_dir) + api_relative = api_md_rel(package_dir) + + if base_api_bytes is not None: + write_bytes(api_path, base_api_bytes) + git("add", api_relative) + git("commit", "-m", + f"[API Review] Baseline API.md for {package_name} {base_version}") + else: + # Is the file tracked in the branch we just created? (Not "is it on disk?" -- + # generate_api_text.py from a previous step may have left an untracked copy.) + tracked = git("ls-files", "--error-unmatch", api_relative, + capture=True, check=False) + if tracked.returncode == 0: + git("rm", api_relative) + git("commit", "-m", + f"[API Review] Remove API.md for {package_name} (empty baseline)") + else: + # Ensure no stray untracked copy is left in the working tree. + if os.path.exists(api_path): + os.remove(api_path) + git("commit", "--allow-empty", "-m", + f"[API Review] Empty baseline for {package_name}") + + git("push", "--force-with-lease", REMOTE, base_branch) + + # ---- Step 4: build review branch off base branch ----------------- + print(f"\n=== Creating review branch {review_branch} ===") + git("checkout", "-B", review_branch, base_branch) + write_bytes(api_path, target_api_bytes) + git("add", api_relative) + # If the bytes happen to be identical to the base, commit empty so we + # still have something to PR. + diff = git("diff", "--cached", "--quiet", capture=True, check=False) + if diff.returncode == 0: + git("commit", "--allow-empty", "-m", + f"[API Review] API.md for {package_name} {target_version} (no diff vs baseline)") + else: + git("commit", "-m", + f"[API Review] API.md for {package_name} {target_version}") + + git("push", "--force-with-lease", REMOTE, review_branch) + + # ---- Step 5: open PR -------------------------------------------- + title = f"[API Review] {package_name} {target_version} (base {base_version})" + body_lines = [ + f"Automated API review PR for `{package_name}`.", + "", + f"- **Target:** `{args.target or 'origin/main'}` (version `{target_version}`)", + f"- **Baseline:** {'tag `' + args.base + '`' if args.base else '_empty_'} " + f"(version `{base_version}`)", + "", + "Generated by `scripts/create_api_review_pr.py`.", + ] + body = "\n".join(body_lines) + + print(f"\n=== Opening PR ===") + compare_url = ( + f"https://github.com/Azure/azure-sdk-for-python/compare/" + f"{base_branch}...{review_branch}?expand=1" + ) + pr_result = run([ + "gh", "pr", "create", + "--repo", "Azure/azure-sdk-for-python", + "--base", base_branch, + "--head", review_branch, + "--title", title, + "--body", body, + "--draft", + ], check=False, env=_env_with_real_git()) + if pr_result.returncode != 0: + print( + "\nWARNING: `gh pr create` failed. Both branches were pushed " + "successfully -- open the PR manually here:\n" + f" {compare_url}\n" + f" Title: {title}" + ) + + return 0 + + finally: + # Restore the user's original branch. + try: + git("checkout", original_branch, check=False) + finally: + shutil.rmtree(tmp_script_dir, ignore_errors=True) + + +def _find_real_git_exe() -> Optional[str]: + """Locate the real ``git.exe`` (skipping any .cmd/.bat shims on PATH). + + On Windows, some environments install a ``git.cmd`` wrapper in front of + the real ``git.exe`` (e.g. ``C:\\Windows\\System32\\git.cmd``). ``gh`` + spawns ``git`` as a child process and is sensitive to argument quoting + when the resolved binary is a ``.cmd`` shim -- subcommands like + ``git merge-base`` get mangled into ``merge``. We search PATH for an + actual ``git.exe`` so we can prefer it. + """ + if os.name != "nt": + return None + seen = set() + for entry in os.environ.get("PATH", "").split(os.pathsep): + entry = entry.strip('"') + if not entry or entry.lower() in seen: + continue + seen.add(entry.lower()) + candidate = os.path.join(entry, "git.exe") + if os.path.isfile(candidate): + return candidate + # Fallback: common install location. + fallback = r"C:\Program Files\Git\cmd\git.exe" + return fallback if os.path.isfile(fallback) else None + + +def _env_with_real_git() -> dict: + """Return a copy of os.environ with the real git.exe directory pushed to + the front of PATH (no-op on non-Windows or if no .exe is found).""" + env = os.environ.copy() + real_git = _find_real_git_exe() + if not real_git: + return env + git_dir = os.path.dirname(real_git) + current_path = env.get("PATH", "") + # Only prepend if it isn't already first. + parts = current_path.split(os.pathsep) + if not parts or parts[0].rstrip("\\").lower() != git_dir.rstrip("\\").lower(): + env["PATH"] = git_dir + os.pathsep + current_path + print(f"(prepending real git to PATH for gh: {git_dir})") + return env + + +def _generate_with_cached_script(cached_script: str, cached_export: str, package_name: str, package_dir: str) -> bytes: + """Run the cached copy of generate_api_text.py against the currently + checked-out ref and return the bytes of the resulting API.md.""" + print(f"--- Generating API.md on {current_branch_or_sha()} ---") + env = os.environ.copy() + env["AZSDK_REPO_ROOT"] = REPO_ROOT + env["AZSDK_EXPORT_SCRIPT"] = cached_export + run([sys.executable, cached_script, package_name], env=env) + path = api_md_path(package_dir) + if not os.path.exists(path): + raise SystemExit(f"ERROR: did not produce {path}") + with open(path, "rb") as f: + return f.read() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/generate_api_text.py b/scripts/generate_api_text.py index 456308ae49f1..6ea05a5f944c 100644 --- a/scripts/generate_api_text.py +++ b/scripts/generate_api_text.py @@ -14,10 +14,10 @@ import tempfile -REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +REPO_ROOT = os.environ.get("AZSDK_REPO_ROOT") or os.path.dirname(os.path.dirname(os.path.abspath(__file__))) APIVIEW_REQS = os.path.join(REPO_ROOT, "eng", "apiview_reqs.txt") AZURE_SDK_INDEX = "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-python/pypi/simple/" -EXPORT_SCRIPT = os.path.join(REPO_ROOT, "eng", "common", "scripts", "Export-APIViewMarkdown.ps1") +EXPORT_SCRIPT = os.environ.get("AZSDK_EXPORT_SCRIPT") or os.path.join(REPO_ROOT, "eng", "common", "scripts", "Export-APIViewMarkdown.ps1") def find_package_dir(package_name: str) -> str: From 2d24fa9420b063d7b7a67ae54c9787236cc4b479 Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Fri, 29 May 2026 14:14:19 -0700 Subject: [PATCH 03/33] Review feedback. --- scripts/create_api_review_pr.py | 168 +++++++++++++++++++++++++++++++- scripts/generate_api_text.py | 86 +++++++++++----- 2 files changed, 228 insertions(+), 26 deletions(-) diff --git a/scripts/create_api_review_pr.py b/scripts/create_api_review_pr.py index 8a13a387f2ac..e00368b07f19 100644 --- a/scripts/create_api_review_pr.py +++ b/scripts/create_api_review_pr.py @@ -1,4 +1,10 @@ #!/usr/bin/env python + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + """Create an API review PR for an Azure SDK Python package. Workflow: @@ -32,6 +38,7 @@ from __future__ import annotations import argparse +import json import glob import os import re @@ -40,6 +47,7 @@ import sys import tempfile from typing import Optional +from urllib.parse import quote REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -153,6 +161,28 @@ def remote_branch_ref(branch: str) -> str: return f"{REMOTE}/{branch}" +def resolve_target_ref(target: str) -> str: + """Resolve ``--target`` to a checkoutable ref. + + Supports both: + - ``branch``: fetched from ``origin`` and returned as ``origin/branch`` + - ``owner:branch``: fetched from ``https://github.com/{owner}/azure-sdk-for-python.git`` + and returned as ``FETCH_HEAD`` + """ + if ":" not in target: + return remote_branch_ref(target) + + owner, branch = target.split(":", 1) + if not owner or not branch: + raise SystemExit( + f"ERROR: invalid --target '{target}'. Expected either 'branch' or 'owner:branch'." + ) + + fork_url = f"https://github.com/{owner}/azure-sdk-for-python.git" + git("fetch", fork_url, branch) + return "FETCH_HEAD" + + # --------------------------------------------------------------------------- # API.md generation # --------------------------------------------------------------------------- @@ -182,14 +212,15 @@ def current_branch_or_sha() -> str: # --------------------------------------------------------------------------- def parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + doc = __doc__ or "Create an API review PR" + p = argparse.ArgumentParser(description=doc.splitlines()[0]) p.add_argument("--package-name", required=True, help="Package directory name under sdk/*/ (e.g. azure-ai-projects)") p.add_argument("--base", default=None, help="Tag to use as the API.md baseline, formatted as " "'{package-name}_{version}'. Omit to make the baseline empty.") p.add_argument("--target", default=None, - help="Branch containing the API to review. Omit to use the latest origin/main.") + help="Branch containing the API to review. Supports 'branch' or 'owner:branch'. Omit to use the latest origin/main.") return p.parse_args() @@ -237,7 +268,7 @@ def main() -> int: if args.target is None: target_ref = MAIN_REF else: - target_ref = remote_branch_ref(args.target) + target_ref = resolve_target_ref(args.target) # Cache the generate + export scripts (they may not exist on older refs we check out). tmp_script_dir = tempfile.mkdtemp(prefix="apirev_script_") @@ -316,9 +347,12 @@ def main() -> int: # ---- Step 5: open PR -------------------------------------------- title = f"[API Review] {package_name} {target_version} (base {base_version})" + working_selector = args.target or original_branch + working_ref = _working_reference_markdown(working_selector) body_lines = [ f"Automated API review PR for `{package_name}`.", "", + f"- **Working branch:** {working_ref}", f"- **Target:** `{args.target or 'origin/main'}` (version `{target_version}`)", f"- **Baseline:** {'tag `' + args.base + '`' if args.base else '_empty_'} " f"(version `{base_version}`)", @@ -402,6 +436,134 @@ def _env_with_real_git() -> dict: return env +def _find_open_pr_for_head(head_selector: str) -> Optional[dict]: + """Return best PR metadata for a head selector, or None when no PR exists. + + ``head_selector`` supports both ``branch`` and ``owner:branch``. + Preference order: + 1) Open PRs + 2) Most recently updated PR (if only closed/merged PRs exist) + """ + + def _parse_prs(output: str) -> Optional[list]: + try: + prs = json.loads(output or "[]") + except json.JSONDecodeError: + return None + if not isinstance(prs, list): + return None + return prs + + def _select_best(prs: list) -> Optional[dict]: + candidates = [ + pr + for pr in prs + if isinstance(pr, dict) + and "number" in pr + and "url" in pr + and "state" in pr + and "updatedAt" in pr + ] + if not candidates: + return None + + open_prs = [pr for pr in candidates if str(pr.get("state", "")).lower() == "open"] + pool = open_prs or candidates + # ISO-8601 timestamps sort correctly lexicographically. + pool.sort(key=lambda pr: str(pr.get("updatedAt", "")), reverse=True) + return pool[0] + + env = _env_with_real_git() + selectors = [head_selector] + if ":" in head_selector: + _, branch_only = head_selector.split(":", 1) + if branch_only and branch_only not in selectors: + selectors.append(branch_only) + + all_prs = [] + + # First attempt: native head filter for each selector form. + for selector in selectors: + direct = run( + [ + "gh", + "pr", + "list", + "--repo", + "Azure/azure-sdk-for-python", + "--head", + selector, + "--state", + "all", + "--json", + "number,url,state,updatedAt", + "--limit", + "50", + ], + check=False, + capture=True, + env=env, + ) + if direct.returncode == 0: + direct_prs = _parse_prs(direct.stdout) + if direct_prs: + all_prs.extend(direct_prs) + + # Fallback: search filter for each selector form. + for selector in selectors: + search_query = f"repo:Azure/azure-sdk-for-python head:{selector}" + search = run( + [ + "gh", + "pr", + "list", + "--repo", + "Azure/azure-sdk-for-python", + "--search", + search_query, + "--state", + "all", + "--json", + "number,url,state,updatedAt", + "--limit", + "50", + ], + check=False, + capture=True, + env=env, + ) + if search.returncode == 0: + search_prs = _parse_prs(search.stdout) + if search_prs: + all_prs.extend(search_prs) + + if not all_prs: + return None + + # De-duplicate by PR number. + deduped = {} + for pr in all_prs: + if isinstance(pr, dict) and "number" in pr: + deduped[pr["number"]] = pr + + return _select_best(list(deduped.values())) + + +def _working_reference_markdown(head_selector: str) -> str: + """Build markdown for a working head selector, preferring an open PR link.""" + pr = _find_open_pr_for_head(head_selector) + if pr: + return f"[PR #{pr['number']}]({pr['url']})" + + if ":" in head_selector: + owner, branch = head_selector.split(":", 1) + branch_url = f"https://github.com/{owner}/azure-sdk-for-python/tree/{quote(branch, safe='')}" + return f"[branch `{head_selector}`]({branch_url})" + + branch_url = f"https://github.com/Azure/azure-sdk-for-python/tree/{quote(head_selector, safe='')}" + return f"[branch `{head_selector}`]({branch_url})" + + def _generate_with_cached_script(cached_script: str, cached_export: str, package_name: str, package_dir: str) -> bytes: """Run the cached copy of generate_api_text.py against the currently checked-out ref and return the bytes of the resulting API.md.""" diff --git a/scripts/generate_api_text.py b/scripts/generate_api_text.py index 6ea05a5f944c..d11e2e2bff08 100644 --- a/scripts/generate_api_text.py +++ b/scripts/generate_api_text.py @@ -1,4 +1,10 @@ #!/usr/bin/env python + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + """Generate API.md for an Azure SDK package. Usage: @@ -17,7 +23,9 @@ REPO_ROOT = os.environ.get("AZSDK_REPO_ROOT") or os.path.dirname(os.path.dirname(os.path.abspath(__file__))) APIVIEW_REQS = os.path.join(REPO_ROOT, "eng", "apiview_reqs.txt") AZURE_SDK_INDEX = "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-python/pypi/simple/" -EXPORT_SCRIPT = os.environ.get("AZSDK_EXPORT_SCRIPT") or os.path.join(REPO_ROOT, "eng", "common", "scripts", "Export-APIViewMarkdown.ps1") +EXPORT_SCRIPT = os.environ.get("AZSDK_EXPORT_SCRIPT") or os.path.join( + REPO_ROOT, "eng", "common", "scripts", "Export-APIViewMarkdown.ps1" +) def find_package_dir(package_name: str) -> str: @@ -26,11 +34,10 @@ def find_package_dir(package_name: str) -> str: matches = glob.glob(pattern) # Filter to directories that contain a pyproject.toml or setup.py valid = [ - m for m in matches - if os.path.isdir(m) and ( - os.path.exists(os.path.join(m, "pyproject.toml")) - or os.path.exists(os.path.join(m, "setup.py")) - ) + m + for m in matches + if os.path.isdir(m) + and (os.path.exists(os.path.join(m, "pyproject.toml")) or os.path.exists(os.path.join(m, "setup.py"))) ] if not valid: raise FileNotFoundError(f"Package '{package_name}' not found under sdk/*/") @@ -44,7 +51,9 @@ def get_installed_version(package: str) -> str | None: try: result = subprocess.run( [sys.executable, "-m", "pip", "show", package], - capture_output=True, text=True, check=True, + capture_output=True, + text=True, + check=True, ) for line in result.stdout.splitlines(): if line.startswith("Version:"): @@ -58,9 +67,10 @@ def get_latest_version(package: str) -> str | None: """Query the Azure SDK feed for the latest version of a package.""" try: result = subprocess.run( - [sys.executable, "-m", "pip", "index", "versions", package, - "--index-url", AZURE_SDK_INDEX], - capture_output=True, text=True, check=True, + [sys.executable, "-m", "pip", "index", "versions", package, "--index-url", AZURE_SDK_INDEX], + capture_output=True, + text=True, + check=True, ) # Output format: "apiview-stub-generator (0.3.28)" for line in result.stdout.splitlines(): @@ -73,7 +83,11 @@ def get_latest_version(package: str) -> str | None: def ensure_latest_apiview_stub_generator(): - """Ensure the latest apiview-stub-generator is installed from the Azure SDK feed.""" + """Ensure the latest apiview-stub-generator is installed from the Azure SDK feed. + + If we cannot determine the latest version (e.g. the feed query fails), + fail fast rather than proceeding with an unknown potentially stale version. + """ installed = get_installed_version("apiview-stub-generator") latest = get_latest_version("apiview-stub-generator") @@ -83,19 +97,31 @@ def ensure_latest_apiview_stub_generator(): print("Already at latest version.") return + if not latest: + raise RuntimeError( + "Could not determine the latest apiview-stub-generator from the " + "Azure SDK feed. Failing to avoid using an unknown local version." + ) + # Install from apiview_reqs.txt first (gets dependencies right) print("Installing apiview_reqs.txt...") subprocess.run( - [sys.executable, "-m", "pip", "install", "-r", APIVIEW_REQS, - f"--index-url={AZURE_SDK_INDEX}"], + [sys.executable, "-m", "pip", "install", "-r", APIVIEW_REQS, f"--index-url={AZURE_SDK_INDEX}"], check=True, ) # Override with latest version (not the pinned one) print("Upgrading apiview-stub-generator to latest...") subprocess.run( - [sys.executable, "-m", "pip", "install", "--upgrade", "apiview-stub-generator", - f"--index-url={AZURE_SDK_INDEX}"], + [ + sys.executable, + "-m", + "pip", + "install", + "--upgrade", + "apiview-stub-generator", + f"--index-url={AZURE_SDK_INDEX}", + ], check=True, ) @@ -118,20 +144,34 @@ def build_wheel(package_dir: str, output_dir: str) -> str: def run_apistub(whl_path: str, out_path: str): """Run apiview-stub-generator on the wheel.""" subprocess.run( - [sys.executable, "-m", "apistub", - "--pkg-path", whl_path, - "--out-path", out_path, - "--skip-pylint"], + [ + sys.executable, + "-m", + "apistub", + "--pkg-path", + whl_path, + "--out-path", + out_path, + "--skip-pylint", + ], check=True, ) def export_api_markdown(token_json_path: str, output_path: str): """Run the Export-APIViewMarkdown.ps1 script to convert token JSON to API.md.""" - subprocess.run( - ["pwsh", EXPORT_SCRIPT, "-TokenJsonPath", token_json_path, "-OutputPath", output_path], - check=True, - ) + try: + subprocess.run( + ["pwsh", EXPORT_SCRIPT, "-TokenJsonPath", token_json_path, "-OutputPath", output_path], + check=True, + ) + except FileNotFoundError as exc: + raise RuntimeError( + "PowerShell 7 (pwsh) is required to export API markdown but was not found on PATH. " + "Install PowerShell from " + "https://learn.microsoft.com/powershell/scripting/install/installing-powershell " + "and restart your terminal/IDE." + ) from exc def main(): From e4b2876b759f0003d542941761ba6e5145e7a3ba Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Tue, 2 Jun 2026 08:48:01 -0700 Subject: [PATCH 04/33] Add scripts for syncing api.md --- .github/workflows/commenter.yml | 36 ++++ .github/workflows/consistency.yml | 203 ++++++++++++++++++ scripts/api_md_workflow/README.md | 183 ++++++++++++++++ .../build_apply_result_payload.py | 52 +++++ .../api_md_workflow/build_comment_payload.py | 66 ++++++ scripts/api_md_workflow/common.py | 95 ++++++++ .../create_api_review_pr.py | 10 +- scripts/api_md_workflow/find_affected.py | 37 ++++ scripts/api_md_workflow/find_mismatches.py | 40 ++++ scripts/api_md_workflow/post_comment.py | 54 +++++ scripts/api_md_workflow/regenerate.py | 26 +++ 11 files changed, 797 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/commenter.yml create mode 100644 .github/workflows/consistency.yml create mode 100644 scripts/api_md_workflow/README.md create mode 100644 scripts/api_md_workflow/build_apply_result_payload.py create mode 100644 scripts/api_md_workflow/build_comment_payload.py create mode 100644 scripts/api_md_workflow/common.py rename scripts/{ => api_md_workflow}/create_api_review_pr.py (97%) create mode 100644 scripts/api_md_workflow/find_affected.py create mode 100644 scripts/api_md_workflow/find_mismatches.py create mode 100644 scripts/api_md_workflow/post_comment.py create mode 100644 scripts/api_md_workflow/regenerate.py diff --git a/.github/workflows/commenter.yml b/.github/workflows/commenter.yml new file mode 100644 index 000000000000..2f8402d9d27f --- /dev/null +++ b/.github/workflows/commenter.yml @@ -0,0 +1,36 @@ +name: API.md Consistency Commenter + +on: + workflow_run: + workflows: ["API.md Consistency"] + types: [completed] + +permissions: + actions: read + pull-requests: write + +jobs: + commenter: + if: ${{ github.event.workflow_run.event == 'pull_request' }} + runs-on: ubuntu-latest + steps: + - name: Prepare artifact directory + run: mkdir -p "${{ runner.temp }}/api-md-comment" + + - name: Download comment artifact + uses: actions/download-artifact@v8 + with: + name: api-md-comment + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: ${{ runner.temp }}/api-md-comment + + - name: Create or update PR comment + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMENT_FILE: ${{ runner.temp }}/api-md-comment/comment.json + DEFAULT_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} + shell: bash + run: | + set -euo pipefail + python scripts/api_md_workflow/post_comment.py diff --git a/.github/workflows/consistency.yml b/.github/workflows/consistency.yml new file mode 100644 index 000000000000..4dc2eea06d7b --- /dev/null +++ b/.github/workflows/consistency.yml @@ -0,0 +1,203 @@ +name: API.md Consistency + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - "sdk/**" + +permissions: + contents: read + +jobs: + consistency: + if: ${{ !github.event.pull_request.draft }} + runs-on: ubuntu-latest + outputs: + changed_count: ${{ steps.changed.outputs.count || '0' }} + mismatch_count: ${{ steps.consistency.outputs.mismatch_count || '0' }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Find changed SDK packages + id: changed + env: + API_MD_BASE_REF: ${{ github.event.pull_request.base.ref }} + API_MD_PACKAGES_FILE: .artifacts/affected_package_dirs.txt + API_MD_CHANGED_FILE: .artifacts/changed_package_dirs.txt + shell: bash + run: | + set -euo pipefail + python scripts/api_md_workflow/find_affected.py + + - name: Generate API.md for affected packages + if: ${{ steps.changed.outputs.count != '0' }} + env: + API_MD_PACKAGES_FILE: .artifacts/affected_package_dirs.txt + shell: bash + run: | + set -euo pipefail + python -m pip install --upgrade pip + python scripts/api_md_workflow/regenerate.py + + - name: Check API.md consistency + id: consistency + env: + API_MD_PACKAGES_FILE: .artifacts/affected_package_dirs.txt + API_MD_MISMATCHES_FILE: .artifacts/mismatched_api_files.txt + shell: bash + run: | + set -euo pipefail + if [ "${{ steps.changed.outputs.count }}" = "0" ]; then + echo "mismatch_count=0" >> "$GITHUB_OUTPUT" + exit 0 + fi + + python scripts/api_md_workflow/find_mismatches.py + + - name: Upload apply context artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: api-md-context + path: | + .artifacts/affected_package_dirs.txt + .artifacts/mismatched_api_files.txt + retention-days: 1 + + - name: Build PR comment payload + if: always() + shell: bash + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + REPOSITORY: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + MISMATCH_COUNT: ${{ steps.consistency.outputs.mismatch_count || '0' }} + CHANGED_COUNT: ${{ steps.changed.outputs.count || '0' }} + API_MD_MISMATCHES_FILE: .artifacts/mismatched_api_files.txt + API_MD_COMMENT_FILE: .artifacts/comment/comment.json + run: | + set -euo pipefail + python scripts/api_md_workflow/build_comment_payload.py + + - name: Upload comment artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: api-md-comment + path: .artifacts/comment/comment.json + retention-days: 1 + + - name: Fail when API.md is out of date + if: ${{ steps.changed.outputs.count != '0' && steps.consistency.outputs.mismatch_count != '0' }} + shell: bash + run: | + echo "Generated API.md does not match committed API.md, or API.md is missing, for one or more affected packages." + echo "Re-run this workflow to apply and commit updates to the same PR branch." + exit 1 + + apply-updates: + name: Apply API.md updates on rerun + needs: consistency + if: > + github.run_attempt > 1 && + needs.consistency.outputs.mismatch_count != '0' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout PR head branch + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Download apply context + uses: actions/download-artifact@v8 + with: + name: api-md-context + path: .artifacts + + - name: Regenerate API.md for affected packages + env: + API_MD_PACKAGES_FILE: .artifacts/affected_package_dirs.txt + shell: bash + run: | + set -euo pipefail + + if [ ! -s .artifacts/affected_package_dirs.txt ]; then + echo "No affected packages found; nothing to apply." + exit 0 + fi + + python -m pip install --upgrade pip + python scripts/api_md_workflow/regenerate.py + + - name: Commit and push API.md updates + id: commit + shell: bash + run: | + set -euo pipefail + + if [ ! -f .artifacts/mismatched_api_files.txt ] || [ ! -s .artifacts/mismatched_api_files.txt ]; then + echo "created=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + while IFS= read -r api_file; do + if [ -f "${api_file}" ] && ! git diff --quiet -- "${api_file}"; then + git add "${api_file}" + fi + done < .artifacts/mismatched_api_files.txt + + if git diff --cached --quiet; then + echo "created=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git commit -m "Update API.md for PR #${{ github.event.pull_request.number }}" + git push origin "HEAD:${{ github.event.pull_request.head.ref }}" + + echo "created=true" >> "$GITHUB_OUTPUT" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Build apply result payload + shell: bash + env: + COMMIT_CREATED: ${{ steps.commit.outputs.created || 'false' }} + COMMIT_SHA: ${{ steps.commit.outputs.sha || '' }} + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + API_MD_APPLY_RESULT_FILE: .artifacts/comment/apply-result.json + run: | + set -euo pipefail + python scripts/api_md_workflow/build_apply_result_payload.py + + - name: Post apply result comment + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMENT_FILE: .artifacts/comment/apply-result.json + shell: bash + run: | + set -euo pipefail + python scripts/api_md_workflow/post_comment.py diff --git a/scripts/api_md_workflow/README.md b/scripts/api_md_workflow/README.md new file mode 100644 index 000000000000..946e9d6b8eb8 --- /dev/null +++ b/scripts/api_md_workflow/README.md @@ -0,0 +1,183 @@ +# API.md Workflow Helpers + +This folder contains the helper scripts used by the GitHub Actions workflows that validate and update `API.md` files for changed SDK packages. + +## Purpose + +The workflow has two goals: + +1. Detect when a pull request changes one or more SDK packages and the committed `API.md` files are out of date. +2. Allow the PR author or maintainer to re-run the workflow so the regenerated `API.md` files are committed back to the PR branch. + +The logic is split between GitHub workflow YAML files and these Python helper scripts. + +## Workflow Files + +### `.github/workflows/consistency.yml` + +This is the main workflow. + +It runs on pull requests for changes under `sdk/**` and contains two jobs: + +1. `consistency` + - Detects affected package directories from the PR diff. + - Regenerates `API.md` for those packages. + - Fails if the generated files differ from the committed files. + - Fails if an affected package does not have a committed `API.md`. + - Uploads two artifacts: + - `api-md-context`: package paths and mismatched files for rerun/apply + - `api-md-comment`: JSON payload used by the commenter workflow + +2. `apply-updates` + - Only runs on reruns (`github.run_attempt > 1`). + - Downloads `api-md-context` from the earlier job. + - Regenerates `API.md` again. + - Commits and pushes the updated files back to the same PR branch. + - Posts a follow-up result comment to the PR. + +### `.github/workflows/commenter.yml` + +This is the trusted follow-up workflow. + +It runs on `workflow_run` for `API.md Consistency` and does not build or regenerate code. It only: + +1. Downloads the `api-md-comment` artifact from the completed consistency run. +2. Creates or updates a single PR comment using a marker-based upsert. + +This separate workflow keeps comment publishing isolated from the PR execution context. + +## Script Layout + +### `common.py` + +Shared helpers used by the other scripts: + +- repository root resolution +- subprocess execution +- reading/writing line-based artifact files +- writing GitHub Actions outputs +- GitHub REST API helpers for listing/updating comments + +### `find_affected.py` + +Used by the `consistency` job. + +Reads `API_MD_BASE_REF`, compares the PR branch to `origin/`, and writes: + +- changed package directories to `API_MD_CHANGED_FILE` +- valid package roots to `API_MD_PACKAGES_FILE` + +Also writes `count=` to `GITHUB_OUTPUT`. + +### `regenerate.py` + +Reads package directories from `API_MD_PACKAGES_FILE` and runs `scripts/generate_api_text.py` for each package. + +This script is used in both: + +- the initial consistency check +- the rerun apply step + +### `find_mismatches.py` + +Reads package directories from `API_MD_PACKAGES_FILE`, checks whether `/API.md` is missing, untracked, or differs from git, and writes the mismatched file list to `API_MD_MISMATCHES_FILE`. + +Also writes `mismatch_count=` to `GITHUB_OUTPUT`. + +### `build_comment_payload.py` + +Builds the JSON payload consumed by `commenter.yml`. + +Inputs come from environment variables such as: + +- `PR_NUMBER` +- `REPOSITORY` +- `RUN_ID` +- `RUN_ATTEMPT` +- `CHANGED_COUNT` +- `MISMATCH_COUNT` + +It writes the final comment JSON to `API_MD_COMMENT_FILE`. + +The payload includes: + +- a stable marker comment +- the PR number +- the rendered markdown body + +When drift is found on the initial run, the body tells the user to open the workflow run and click `Re-run all jobs`. + +### `build_apply_result_payload.py` + +Builds the JSON payload for the follow-up comment after `apply-updates` runs. + +It uses environment variables such as: + +- `COMMIT_CREATED` +- `PR_NUMBER` +- `HEAD_REF` +- `RUN_URL` +- `COMMIT_SHA` + +It writes the result payload to `API_MD_APPLY_RESULT_FILE`. + +### `post_comment.py` + +Posts or updates the PR comment from a payload file. + +Inputs: + +- `COMMENT_FILE` +- `GITHUB_TOKEN` +- `GITHUB_REPOSITORY` +- optional `DEFAULT_PR_NUMBER` +- optional `DEFAULT_MARKER` + +Behavior: + +1. Reads the JSON payload. +2. Finds an existing bot comment containing the same marker. +3. Updates that comment if found, otherwise creates a new one. + +This is used by: + +- `commenter.yml` for the main consistency comment +- `consistency.yml` for the apply result comment + +## Environment Variables Used + +The scripts are intentionally simple and read inputs from environment variables set by the workflow steps. + +Common variables include: + +- `API_MD_BASE_REF` +- `API_MD_PACKAGES_FILE` +- `API_MD_CHANGED_FILE` +- `API_MD_MISMATCHES_FILE` +- `API_MD_COMMENT_FILE` +- `API_MD_APPLY_RESULT_FILE` +- `PR_NUMBER` +- `REPOSITORY` +- `RUN_ID` +- `RUN_ATTEMPT` +- `GITHUB_TOKEN` +- `GITHUB_REPOSITORY` + +## End-to-End Flow + +1. A PR changes files under `sdk/**`. +2. `consistency.yml` runs. +3. `find_affected.py` determines which packages were touched. +4. `regenerate.py` rebuilds `API.md` for those packages. +5. `find_mismatches.py` records any `API.md` drift, including missing or untracked `API.md` files. +6. `build_comment_payload.py` creates a comment artifact. +7. `commenter.yml` downloads that artifact and runs `post_comment.py`. +8. The PR comment tells the user to rerun the workflow if they want the fixes applied. +9. On rerun, the `apply-updates` job in `consistency.yml` runs. +10. It regenerates the files again, commits them, and posts a result comment via `build_apply_result_payload.py` and `post_comment.py`. + +## Maintenance Notes + +- Keep comment rendering logic in the Python scripts, not inline in the YAML. +- Keep workflow YAML focused on orchestration: checkout, setup, artifacts, and calling scripts. +- If the comment format changes, update the marker handling carefully so existing PR comments continue to be updated instead of duplicated. diff --git a/scripts/api_md_workflow/build_apply_result_payload.py b/scripts/api_md_workflow/build_apply_result_payload.py new file mode 100644 index 000000000000..1647aa7e4886 --- /dev/null +++ b/scripts/api_md_workflow/build_apply_result_payload.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python + +from __future__ import annotations + +import json +import os + +from common import DEFAULT_APPLY_MARKER, env_path, require_env + + +def main() -> int: + created = require_env("COMMIT_CREATED").lower() == "true" + pr_number = int(require_env("PR_NUMBER")) + head_ref = require_env("HEAD_REF") + run_url = require_env("RUN_URL") + commit_sha = os.environ.get("COMMIT_SHA", "") + marker = os.environ.get("API_MD_APPLY_MARKER", DEFAULT_APPLY_MARKER) + out_file = env_path("API_MD_APPLY_RESULT_FILE", ".artifacts/comment/apply-result.json") + + if created: + body = "\n".join( + [ + "API.md updates have been committed to this PR branch.", + "", + f"- Branch: `{head_ref}`", + f"- Commit: `{commit_sha}`", + f"- Run: {run_url}", + ] + ) + else: + body = "\n".join( + [ + "No API.md changes were required after regeneration.", + "", + f"- Branch: `{head_ref}`", + f"- Run: {run_url}", + ] + ) + + payload = { + "marker": marker, + "pr_number": pr_number, + "body": body, + } + + out_file.parent.mkdir(parents=True, exist_ok=True) + out_file.write_text(json.dumps(payload, indent=2), encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/api_md_workflow/build_comment_payload.py b/scripts/api_md_workflow/build_comment_payload.py new file mode 100644 index 000000000000..0f8f6962eafb --- /dev/null +++ b/scripts/api_md_workflow/build_comment_payload.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python + +from __future__ import annotations + +import json +import os + +from common import DEFAULT_CONSISTENCY_MARKER, env_path, read_lines, require_env + + +def main() -> int: + marker = os.environ.get("API_MD_COMMENT_MARKER", DEFAULT_CONSISTENCY_MARKER) + pr_number = int(require_env("PR_NUMBER")) + repository = require_env("REPOSITORY") + run_id = require_env("RUN_ID") + run_attempt = int(require_env("RUN_ATTEMPT")) + mismatch_count = int(require_env("MISMATCH_COUNT")) + changed_count = int(require_env("CHANGED_COUNT")) + mismatches_file = env_path("API_MD_MISMATCHES_FILE", ".artifacts/mismatched_api_files.txt") + out_file = env_path("API_MD_COMMENT_FILE", ".artifacts/comment/comment.json") + + mismatches = read_lines(mismatches_file) + run_url = f"https://github.com/{repository}/actions/runs/{run_id}/attempts/{run_attempt}" + + if changed_count == 0: + body = ( + "## API.md consistency\n\n" + "No SDK package changes were detected in this PR." + ) + elif mismatch_count == 0: + body = ( + "## API.md consistency\n\n" + f"Checked {changed_count} affected package(s). All generated API.md files are up to date." + ) + elif run_attempt == 1: + lines = "\n".join(f"- `{path}`" for path in mismatches) + body = ( + "## API.md consistency\n\n" + "Generated API.md differs from files in this branch.\n\n" + "### Files that need update\n" + f"{lines}\n\n" + "### Apply updates to this same PR\n" + f"- Click [this workflow run]({run_url}).\n" + "- In the Actions UI, click `Re-run all jobs`.\n" + "- On rerun, this workflow will commit regenerated API.md files directly to this PR branch.\n\n" + "No PR number input is required." + ) + else: + body = ( + "## API.md consistency\n\n" + "API.md updates are being applied for this PR rerun." + ) + + payload = { + "marker": marker, + "pr_number": pr_number, + "body": body, + } + + out_file.parent.mkdir(parents=True, exist_ok=True) + out_file.write_text(json.dumps(payload, indent=2), encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/api_md_workflow/common.py b/scripts/api_md_workflow/common.py new file mode 100644 index 000000000000..cbd997bbc51d --- /dev/null +++ b/scripts/api_md_workflow/common.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path +from typing import Any +from urllib import request + + +REPO_ROOT = Path(__file__).resolve().parents[2] +GENERATE_API_SCRIPT = REPO_ROOT / "scripts" / "generate_api_text.py" +DEFAULT_CONSISTENCY_MARKER = "" +DEFAULT_APPLY_MARKER = "" + + +def run(cmd: list[str], *, check: bool = True, capture: bool = False) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, check=check, text=True, capture_output=capture) + + +def read_lines(path: Path) -> list[str]: + if not path.exists(): + return [] + return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + + +def write_lines(path: Path, lines: list[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if not lines: + path.write_text("", encoding="utf-8") + return + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def append_github_output(key: str, value: str | int) -> None: + output_path = os.environ.get("GITHUB_OUTPUT") + if not output_path: + return + with open(output_path, "a", encoding="utf-8") as handle: + handle.write(f"{key}={value}\n") + + +def env_path(name: str, default: str) -> Path: + return Path(os.environ.get(name, default)) + + +def require_env(name: str) -> str: + value = os.environ.get(name) + if not value: + raise ValueError(f"Environment variable {name} is required") + return value + + +def github_request(method: str, url: str, token: str, data: dict[str, Any] | None = None) -> Any: + payload = None + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + } + if data is not None: + payload = json.dumps(data).encode("utf-8") + headers["Content-Type"] = "application/json" + + req = request.Request(url, method=method, data=payload, headers=headers) + with request.urlopen(req) as response: + text = response.read().decode("utf-8") + if not text: + return None + return json.loads(text) + + +def list_comments(owner: str, repo: str, pr_number: int, token: str) -> list[dict[str, Any]]: + comments: list[dict[str, Any]] = [] + page = 1 + while True: + url = ( + f"https://api.github.com/repos/{owner}/{repo}/issues/{pr_number}/comments" + f"?per_page=100&page={page}" + ) + batch = github_request("GET", url, token) + if not isinstance(batch, list) or not batch: + break + comments.extend(batch) + if len(batch) < 100: + break + page += 1 + return comments diff --git a/scripts/create_api_review_pr.py b/scripts/api_md_workflow/create_api_review_pr.py similarity index 97% rename from scripts/create_api_review_pr.py rename to scripts/api_md_workflow/create_api_review_pr.py index e00368b07f19..50db854b0af8 100644 --- a/scripts/create_api_review_pr.py +++ b/scripts/api_md_workflow/create_api_review_pr.py @@ -25,10 +25,10 @@ Usage:: - python scripts/create_api_review_pr.py --package-name azure-ai-projects - python scripts/create_api_review_pr.py --package-name azure-ai-projects \\ + python scripts/api_md_workflow/create_api_review_pr.py --package-name azure-ai-projects + python scripts/api_md_workflow/create_api_review_pr.py --package-name azure-ai-projects \\ --base azure-ai-projects_1.0.0b1 - python scripts/create_api_review_pr.py --package-name azure-ai-projects \\ + python scripts/api_md_workflow/create_api_review_pr.py --package-name azure-ai-projects \\ --base azure-ai-projects_1.0.0b1 --target my-feature-branch Requires ``gh`` (GitHub CLI) authenticated against the repository, plus push @@ -50,7 +50,7 @@ from urllib.parse import quote -REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) GENERATE_SCRIPT = os.path.join(REPO_ROOT, "scripts", "generate_api_text.py") EXPORT_SCRIPT = os.path.join(REPO_ROOT, "eng", "common", "scripts", "Export-APIViewMarkdown.ps1") REMOTE = "origin" @@ -357,7 +357,7 @@ def main() -> int: f"- **Baseline:** {'tag `' + args.base + '`' if args.base else '_empty_'} " f"(version `{base_version}`)", "", - "Generated by `scripts/create_api_review_pr.py`.", + "Generated by `scripts/api_md_workflow/create_api_review_pr.py`.", ] body = "\n".join(body_lines) diff --git a/scripts/api_md_workflow/find_affected.py b/scripts/api_md_workflow/find_affected.py new file mode 100644 index 000000000000..dfad9272397b --- /dev/null +++ b/scripts/api_md_workflow/find_affected.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python + +from __future__ import annotations + +from common import append_github_output, env_path, require_env, run, write_lines, REPO_ROOT + + +def main() -> int: + base_ref = require_env("API_MD_BASE_REF") + packages_file = env_path("API_MD_PACKAGES_FILE", ".artifacts/affected_package_dirs.txt") + changed_file = env_path("API_MD_CHANGED_FILE", ".artifacts/changed_package_dirs.txt") + + run(["git", "fetch", "--no-tags", "--depth=1", "origin", base_ref]) + diff = run(["git", "diff", "--name-only", f"origin/{base_ref}...HEAD"], capture=True).stdout + + changed_dirs: set[str] = set() + for file_path in diff.splitlines(): + parts = file_path.strip().split("/") + if len(parts) < 3 or parts[0] != "sdk": + continue + changed_dirs.add("/".join(parts[:3])) + + write_lines(changed_file, sorted(changed_dirs)) + + affected: list[str] = [] + for package_dir in sorted(changed_dirs): + pkg_path = REPO_ROOT / package_dir + if (pkg_path / "pyproject.toml").exists() or (pkg_path / "setup.py").exists(): + affected.append(package_dir) + + write_lines(packages_file, affected) + append_github_output("count", len(affected)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/api_md_workflow/find_mismatches.py b/scripts/api_md_workflow/find_mismatches.py new file mode 100644 index 000000000000..e4ad385889af --- /dev/null +++ b/scripts/api_md_workflow/find_mismatches.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python + +from __future__ import annotations + +from pathlib import Path + +from common import append_github_output, env_path, read_lines, run, write_lines + + +def main() -> int: + packages_file = env_path("API_MD_PACKAGES_FILE", ".artifacts/affected_package_dirs.txt") + out_file = env_path("API_MD_MISMATCHES_FILE", ".artifacts/mismatched_api_files.txt") + packages = read_lines(packages_file) + + mismatches: list[str] = [] + for pkg_dir in packages: + api_file = f"{pkg_dir}/API.md" + api_path = Path(api_file) + + # Enforce that each affected package has a committed API.md file. + if not api_path.is_file(): + mismatches.append(api_file) + continue + + tracked_result = run(["git", "ls-files", "--error-unmatch", "--", api_file], check=False) + if tracked_result.returncode != 0: + mismatches.append(api_file) + continue + + diff_result = run(["git", "diff", "--quiet", "--", api_file], check=False) + if diff_result.returncode != 0: + mismatches.append(api_file) + + write_lines(out_file, mismatches) + append_github_output("mismatch_count", len(mismatches)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/api_md_workflow/post_comment.py b/scripts/api_md_workflow/post_comment.py new file mode 100644 index 000000000000..50d51b18bd16 --- /dev/null +++ b/scripts/api_md_workflow/post_comment.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python + +from __future__ import annotations + +import json +import os + +from common import DEFAULT_CONSISTENCY_MARKER, env_path, github_request, list_comments + + +def main() -> int: + comment_file = env_path("COMMENT_FILE", ".artifacts/comment/comment.json") + payload = json.loads(comment_file.read_text(encoding="utf-8")) + + marker = payload.get("marker") or os.environ.get("DEFAULT_MARKER") or DEFAULT_CONSISTENCY_MARKER + default_pr = int(os.environ.get("DEFAULT_PR_NUMBER", "0") or "0") + pr_number = int(payload.get("pr_number") or default_pr) + if pr_number <= 0: + raise ValueError("No PR number available for comment publishing.") + + token = os.environ.get("GITHUB_TOKEN") + if not token: + raise ValueError("GITHUB_TOKEN is required to post comments.") + + repo_full_name = os.environ.get("GITHUB_REPOSITORY", "") + if "/" not in repo_full_name: + raise ValueError("GITHUB_REPOSITORY is missing or invalid.") + owner, repo = repo_full_name.split("/", 1) + + body = f"{marker}\n{payload.get('body') or 'API.md consistency finished.'}" + + comments = list_comments(owner, repo, pr_number, token) + existing = None + for comment in comments: + user = comment.get("user") or {} + comment_body = comment.get("body") or "" + if user.get("type") == "Bot" and marker in comment_body: + existing = comment + break + + if existing: + update_url = f"https://api.github.com/repos/{owner}/{repo}/issues/comments/{existing['id']}" + github_request("PATCH", update_url, token, {"body": body}) + print(f"Updated comment on PR #{pr_number}") + else: + create_url = f"https://api.github.com/repos/{owner}/{repo}/issues/{pr_number}/comments" + github_request("POST", create_url, token, {"body": body}) + print(f"Created comment on PR #{pr_number}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/api_md_workflow/regenerate.py b/scripts/api_md_workflow/regenerate.py new file mode 100644 index 000000000000..0b956ab376b4 --- /dev/null +++ b/scripts/api_md_workflow/regenerate.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python + +from __future__ import annotations + +import sys +from pathlib import Path + +from common import GENERATE_API_SCRIPT, env_path, read_lines, run + + +def main() -> int: + packages_file = env_path("API_MD_PACKAGES_FILE", ".artifacts/affected_package_dirs.txt") + packages = read_lines(packages_file) + if not packages: + return 0 + + for pkg_dir in packages: + package_name = Path(pkg_dir).name + print(f"Generating API.md for {package_name}") + run([sys.executable, str(GENERATE_API_SCRIPT), package_name]) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From dd1b565d80a30c7b18462918b025fdf060568932 Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Tue, 2 Jun 2026 13:13:34 -0700 Subject: [PATCH 05/33] Refactor create_api_review_pr from Python to JS in a module way. --- scripts/api_md_workflow/adapters/python.js | 131 ++++ .../api_md_workflow/create_api_review_pr.js | 515 ++++++++++++++++ .../api_md_workflow/create_api_review_pr.py | 583 ------------------ 3 files changed, 646 insertions(+), 583 deletions(-) create mode 100644 scripts/api_md_workflow/adapters/python.js create mode 100644 scripts/api_md_workflow/create_api_review_pr.js delete mode 100644 scripts/api_md_workflow/create_api_review_pr.py diff --git a/scripts/api_md_workflow/adapters/python.js b/scripts/api_md_workflow/adapters/python.js new file mode 100644 index 000000000000..7e29d5bb7d12 --- /dev/null +++ b/scripts/api_md_workflow/adapters/python.js @@ -0,0 +1,131 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +function run(cmd, args, options = {}) { + const printable = [cmd, ...args].join(" "); + console.log(`$ ${printable}`); + const result = spawnSync(cmd, args, { + cwd: options.cwd, + env: options.env, + encoding: "utf-8", + stdio: options.capture ? "pipe" : "inherit", + }); + + if ((options.check ?? true) && result.status !== 0) { + throw new Error(`Command failed (${result.status}): ${printable}`); + } + + return result; +} + +function findPackageDir(repoRoot, packageName) { + const sdkDir = path.join(repoRoot, "sdk"); + const serviceDirs = fs.readdirSync(sdkDir, { withFileTypes: true }); + const matches = []; + + for (const serviceDir of serviceDirs) { + if (!serviceDir.isDirectory()) { + continue; + } + + const candidate = path.join(sdkDir, serviceDir.name, packageName); + if (!fs.existsSync(candidate) || !fs.statSync(candidate).isDirectory()) { + continue; + } + + const hasBuildFile = fs.existsSync(path.join(candidate, "pyproject.toml")) || fs.existsSync(path.join(candidate, "setup.py")); + if (hasBuildFile) { + matches.push(candidate); + } + } + + if (matches.length === 0) { + throw new Error(`ERROR: package '${packageName}' not found under sdk/*/`); + } + + if (matches.length > 1) { + throw new Error(`ERROR: multiple matches for '${packageName}': ${matches.join(", ")}`); + } + + return matches[0]; +} + +function* walkFiles(startDir) { + const entries = fs.readdirSync(startDir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(startDir, entry.name); + if (entry.isDirectory()) { + yield* walkFiles(fullPath); + } else { + yield fullPath; + } + } +} + +function readVersion(packageDir) { + const versionRegex = /^\s*VERSION\s*[:=]\s*["']([^"']+)["']/m; + const candidates = []; + + for (const file of walkFiles(packageDir)) { + const name = path.basename(file); + if (name === "_version.py" || name === "version.py") { + candidates.push(file); + } + } + + for (const candidate of candidates) { + let text; + try { + text = fs.readFileSync(candidate, "utf-8"); + } catch { + continue; + } + + const match = text.match(versionRegex); + if (match) { + return match[1]; + } + } + + throw new Error(`ERROR: could not find a version string in ${packageDir}`); +} + +function generateApiMdBytes({ + repoRoot, + packageName, + packageDir, + generateScriptPath, + exportScriptPath, + pythonExecutable, + refLabel, +}) { + console.log(`--- Generating API.md on ${refLabel} ---`); + const env = { + ...process.env, + AZSDK_REPO_ROOT: repoRoot, + AZSDK_EXPORT_SCRIPT: exportScriptPath, + }; + + run(pythonExecutable, [generateScriptPath, packageName], { + cwd: repoRoot, + env, + check: true, + }); + + const apiMdPath = path.join(packageDir, "API.md"); + if (!fs.existsSync(apiMdPath)) { + throw new Error(`ERROR: did not produce ${apiMdPath}`); + } + + return fs.readFileSync(apiMdPath); +} + +module.exports = { + name: "python", + findPackageDir, + readVersion, + generateApiMdBytes, +}; diff --git a/scripts/api_md_workflow/create_api_review_pr.js b/scripts/api_md_workflow/create_api_review_pr.js new file mode 100644 index 000000000000..9a4d60fdcd0f --- /dev/null +++ b/scripts/api_md_workflow/create_api_review_pr.js @@ -0,0 +1,515 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const REMOTE = "origin"; +const MAIN_REF = `${REMOTE}/main`; + +function parseArgs(argv) { + const args = { + packageName: null, + base: null, + target: null, + adapter: process.env.API_REVIEW_ADAPTER || "python", + pythonExecutable: process.env.PYTHON || "python", + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith("--")) { + throw new Error(`Unexpected argument: ${arg}`); + } + + const key = arg.slice(2); + const value = argv[i + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`Missing value for --${key}`); + } + + i += 1; + if (key === "package-name") { + args.packageName = value; + } else if (key === "base") { + args.base = value; + } else if (key === "target") { + args.target = value; + } else if (key === "adapter") { + args.adapter = value; + } else if (key === "python") { + args.pythonExecutable = value; + } else { + throw new Error(`Unknown option: --${key}`); + } + } + + if (!args.packageName) { + throw new Error("Missing required --package-name"); + } + + return args; +} + +function run(cmd, args, options = {}) { + const printable = [cmd, ...args].join(" "); + console.log(`$ ${printable}`); + const result = spawnSync(cmd, args, { + cwd: options.cwd ?? REPO_ROOT, + env: options.env, + encoding: "utf-8", + stdio: options.capture ? "pipe" : "inherit", + }); + + if ((options.check ?? true) && result.status !== 0) { + throw new Error(`Command failed (${result.status}): ${printable}`); + } + + return result; +} + +function git(args, options = {}) { + return run("git", args, options); +} + +function gitOut(args) { + return git(args, { capture: true }).stdout.trim(); +} + +function ensureCleanWorktree() { + const status = gitOut(["status", "--porcelain"]); + if (status) { + throw new Error(`ERROR: working tree is not clean. Commit or stash changes before running.\n${status}`); + } +} + +function currentBranch() { + return gitOut(["rev-parse", "--abbrev-ref", "HEAD"]); +} + +function currentBranchOrSha() { + const name = currentBranch(); + if (name === "HEAD") { + return gitOut(["rev-parse", "--short", "HEAD"]); + } + return name; +} + +function tagExists(tag) { + const result = git(["rev-parse", "--verify", "--quiet", `refs/tags/${tag}`], { + capture: true, + check: false, + }); + return result.status === 0; +} + +function validateBaseTag(packageName, baseTag) { + if (!baseTag.startsWith(`${packageName}_`)) { + throw new Error(`ERROR: --base tag '${baseTag}' must start with '${packageName}_'.`); + } + + const version = baseTag.slice(packageName.length + 1); + if (!version) { + throw new Error(`ERROR: --base tag '${baseTag}' is missing the version suffix.`); + } + + if (!tagExists(baseTag)) { + throw new Error(`ERROR: tag '${baseTag}' does not exist in this repository.`); + } + + return version; +} + +function remoteBranchRef(branch) { + git(["fetch", REMOTE, branch]); + return `${REMOTE}/${branch}`; +} + +function resolveTargetRef(target) { + if (!target.includes(":")) { + return remoteBranchRef(target); + } + + const [owner, branch] = target.split(":", 2); + if (!owner || !branch) { + throw new Error(`ERROR: invalid --target '${target}'. Expected either 'branch' or 'owner:branch'.`); + } + + const forkUrl = `https://github.com/${owner}/azure-sdk-for-python.git`; + git(["fetch", forkUrl, branch]); + return "FETCH_HEAD"; +} + +function packageRelDir(packageDir) { + return path.relative(REPO_ROOT, packageDir).split(path.sep).join("/"); +} + +function apiMdPath(packageDir) { + return path.join(packageDir, "API.md"); +} + +function apiMdRel(packageDir) { + return `${packageRelDir(packageDir)}/API.md`; +} + +function findRealGitExe() { + if (process.platform !== "win32") { + return null; + } + + const seen = new Set(); + const entries = (process.env.PATH || "").split(path.delimiter); + for (const rawEntry of entries) { + const entry = rawEntry.replace(/^"|"$/g, ""); + if (!entry) { + continue; + } + + const key = entry.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + + const candidate = path.join(entry, "git.exe"); + if (fs.existsSync(candidate)) { + return candidate; + } + } + + const fallback = "C:\\Program Files\\Git\\cmd\\git.exe"; + return fs.existsSync(fallback) ? fallback : null; +} + +function envWithRealGit() { + const env = { ...process.env }; + const realGit = findRealGitExe(); + if (!realGit) { + return env; + } + + const gitDir = path.dirname(realGit); + const current = env.PATH || ""; + const parts = current.split(path.delimiter); + const first = parts[0] || ""; + if (first.replace(/\\+$/, "").toLowerCase() !== gitDir.replace(/\\+$/, "").toLowerCase()) { + env.PATH = `${gitDir}${path.delimiter}${current}`; + console.log(`(prepending real git to PATH for gh: ${gitDir})`); + } + + return env; +} + +function parseJsonOrNull(text) { + try { + const value = JSON.parse(text || "[]"); + return Array.isArray(value) ? value : null; + } catch { + return null; + } +} + +function selectBestPr(prs) { + const candidates = prs.filter((pr) => + pr && typeof pr === "object" && "number" in pr && "url" in pr && "state" in pr && "updatedAt" in pr, + ); + if (candidates.length === 0) { + return null; + } + + const openPrs = candidates.filter((pr) => String(pr.state || "").toLowerCase() === "open"); + const pool = openPrs.length ? openPrs : candidates; + pool.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""))); + return pool[0]; +} + +function findOpenPrForHead(headSelector) { + const env = envWithRealGit(); + const selectors = [headSelector]; + if (headSelector.includes(":")) { + const branchOnly = headSelector.split(":", 2)[1]; + if (branchOnly && !selectors.includes(branchOnly)) { + selectors.push(branchOnly); + } + } + + const allPrs = []; + for (const selector of selectors) { + const direct = run( + "gh", + [ + "pr", + "list", + "--repo", + "Azure/azure-sdk-for-python", + "--head", + selector, + "--state", + "all", + "--json", + "number,url,state,updatedAt", + "--limit", + "50", + ], + { check: false, capture: true, env }, + ); + + if (direct.status === 0) { + const prs = parseJsonOrNull(direct.stdout); + if (prs) { + allPrs.push(...prs); + } + } + } + + for (const selector of selectors) { + const searchQuery = `repo:Azure/azure-sdk-for-python head:${selector}`; + const search = run( + "gh", + [ + "pr", + "list", + "--repo", + "Azure/azure-sdk-for-python", + "--search", + searchQuery, + "--state", + "all", + "--json", + "number,url,state,updatedAt", + "--limit", + "50", + ], + { check: false, capture: true, env }, + ); + + if (search.status === 0) { + const prs = parseJsonOrNull(search.stdout); + if (prs) { + allPrs.push(...prs); + } + } + } + + if (allPrs.length === 0) { + return null; + } + + const deduped = new Map(); + for (const pr of allPrs) { + if (pr && typeof pr === "object" && "number" in pr) { + deduped.set(pr.number, pr); + } + } + + return selectBestPr([...deduped.values()]); +} + +function workingReferenceMarkdown(headSelector) { + const pr = findOpenPrForHead(headSelector); + if (pr) { + return `[PR #${pr.number}](${pr.url})`; + } + + if (headSelector.includes(":")) { + const [owner, branch] = headSelector.split(":", 2); + const branchUrl = `https://github.com/${owner}/azure-sdk-for-python/tree/${encodeURIComponent(branch)}`; + return `[branch \`${headSelector}\`](${branchUrl})`; + } + + const branchUrl = `https://github.com/Azure/azure-sdk-for-python/tree/${encodeURIComponent(headSelector)}`; + return `[branch \`${headSelector}\`](${branchUrl})`; +} + +function writeBytes(filePath, bytes) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, bytes); +} + +function loadAdapter(name) { + const adapterPath = path.join(__dirname, "adapters", `${name}.js`); + if (!fs.existsSync(adapterPath)) { + throw new Error(`ERROR: adapter '${name}' not found at ${adapterPath}`); + } + // eslint-disable-next-line global-require, import/no-dynamic-require + return require(adapterPath); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const adapter = loadAdapter(args.adapter); + + const packageDir = adapter.findPackageDir(REPO_ROOT, args.packageName); + console.log(`Found package at: ${packageDir}`); + + ensureCleanWorktree(); + const originalBranch = currentBranch(); + if (originalBranch === "HEAD") { + throw new Error("ERROR: refusing to run from a detached HEAD."); + } + + git(["fetch", REMOTE, "main"]); + + let baseVersion = "none"; + if (args.base) { + baseVersion = validateBaseTag(args.packageName, args.base); + } + + const targetRef = args.target ? resolveTargetRef(args.target) : MAIN_REF; + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "apirev_script_")); + const cachedScript = path.join(tempDir, "generate_api_text.py"); + const cachedExport = path.join(tempDir, "Export-APIViewMarkdown.ps1"); + fs.copyFileSync(path.join(REPO_ROOT, "scripts", "generate_api_text.py"), cachedScript); + fs.copyFileSync(path.join(REPO_ROOT, "eng", "common", "scripts", "Export-APIViewMarkdown.ps1"), cachedExport); + + try { + let baseApiBytes = null; + if (args.base) { + console.log(`\n=== Capturing baseline API.md from tag ${args.base} ===`); + git(["checkout", "--detach", args.base]); + baseApiBytes = adapter.generateApiMdBytes({ + repoRoot: REPO_ROOT, + packageName: args.packageName, + packageDir, + generateScriptPath: cachedScript, + exportScriptPath: cachedExport, + pythonExecutable: args.pythonExecutable, + refLabel: currentBranchOrSha(), + }); + } + + console.log(`\n=== Capturing target API.md from ${targetRef} ===`); + git(["checkout", "--detach", targetRef]); + const targetVersion = adapter.readVersion(packageDir); + const targetApiBytes = adapter.generateApiMdBytes({ + repoRoot: REPO_ROOT, + packageName: args.packageName, + packageDir, + generateScriptPath: cachedScript, + exportScriptPath: cachedExport, + pythonExecutable: args.pythonExecutable, + refLabel: currentBranchOrSha(), + }); + + const baseBranch = `base_${args.packageName}_${baseVersion}`; + const reviewBranch = `review_${args.packageName}_${targetVersion}`; + + console.log(`\n=== Creating base branch ${baseBranch} ===`); + git(["checkout", "-B", baseBranch, MAIN_REF]); + + const apiPath = apiMdPath(packageDir); + const apiRelative = apiMdRel(packageDir); + + if (baseApiBytes !== null) { + writeBytes(apiPath, baseApiBytes); + git(["add", apiRelative]); + git(["commit", "-m", `[API Review] Baseline API.md for ${args.packageName} ${baseVersion}`]); + } else { + const tracked = git(["ls-files", "--error-unmatch", apiRelative], { + capture: true, + check: false, + }); + + if (tracked.status === 0) { + git(["rm", apiRelative]); + git(["commit", "-m", `[API Review] Remove API.md for ${args.packageName} (empty baseline)`]); + } else { + if (fs.existsSync(apiPath)) { + fs.unlinkSync(apiPath); + } + git(["commit", "--allow-empty", "-m", `[API Review] Empty baseline for ${args.packageName}`]); + } + } + + git(["push", "--force-with-lease", REMOTE, baseBranch]); + + console.log(`\n=== Creating review branch ${reviewBranch} ===`); + git(["checkout", "-B", reviewBranch, baseBranch]); + writeBytes(apiPath, targetApiBytes); + git(["add", apiRelative]); + + const diff = git(["diff", "--cached", "--quiet"], { + capture: true, + check: false, + }); + + if (diff.status === 0) { + git([ + "commit", + "--allow-empty", + "-m", + `[API Review] API.md for ${args.packageName} ${targetVersion} (no diff vs baseline)`, + ]); + } else { + git(["commit", "-m", `[API Review] API.md for ${args.packageName} ${targetVersion}`]); + } + + git(["push", "--force-with-lease", REMOTE, reviewBranch]); + + const title = `[API Review] ${args.packageName} ${targetVersion} (base ${baseVersion})`; + const workingSelector = args.target || originalBranch; + const workingRef = workingReferenceMarkdown(workingSelector); + const baselineDescription = args.base + ? `tag \`${args.base}\`` + : "_empty_"; + + const body = [ + `Automated API review PR for \`${args.packageName}\`.`, + "", + `- **Working branch:** ${workingRef}`, + `- **Target:** \`${args.target || "origin/main"}\` (version \`${targetVersion}\`)`, + `- **Baseline:** ${baselineDescription} (version \`${baseVersion}\`)`, + "", + "Generated by `scripts/api_md_workflow/create_api_review_pr.js`.", + ].join("\n"); + + console.log("\n=== Opening PR ==="); + const compareUrl = `https://github.com/Azure/azure-sdk-for-python/compare/${baseBranch}...${reviewBranch}?expand=1`; + const prCreate = run( + "gh", + [ + "pr", + "create", + "--repo", + "Azure/azure-sdk-for-python", + "--base", + baseBranch, + "--head", + reviewBranch, + "--title", + title, + "--body", + body, + "--draft", + ], + { check: false, env: envWithRealGit() }, + ); + + if (prCreate.status !== 0) { + console.log( + "\nWARNING: `gh pr create` failed. Both branches were pushed successfully -- open the PR manually here:\n" + + ` ${compareUrl}\n` + + ` Title: ${title}`, + ); + } + + return 0; + } finally { + try { + git(["checkout", originalBranch], { check: false }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } +} + +try { + process.exit(main()); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(message); + process.exit(1); +} diff --git a/scripts/api_md_workflow/create_api_review_pr.py b/scripts/api_md_workflow/create_api_review_pr.py deleted file mode 100644 index 50db854b0af8..000000000000 --- a/scripts/api_md_workflow/create_api_review_pr.py +++ /dev/null @@ -1,583 +0,0 @@ -#!/usr/bin/env python - -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -"""Create an API review PR for an Azure SDK Python package. - -Workflow: - 1. Validate that ``--package-name`` exists under ``sdk/*/``. - 2. Build the BASE branch (``base_{package}_{base_version}``): - - If ``--base`` is a tag (e.g. ``azure-ai-projects_1.0.0b1``): check out - the tag, generate API.md, then create the base branch off the latest - ``origin/main`` and commit the captured API.md onto it. - - If ``--base`` is omitted: create the base branch off ``origin/main`` - and delete any existing API.md for the package (no-op if absent). - 3. Build the REVIEW branch (``review_{package}_{target_version}``): - - If ``--target`` is omitted: use the latest ``origin/main``. - - Otherwise: check out the given branch. - Generate API.md on that ref, then commit it on a branch created off - the base branch. - 4. Push both branches to ``origin`` and open a PR with title: - ``[API Review] {package} {target_version} (base {base_version})`` - -Usage:: - - python scripts/api_md_workflow/create_api_review_pr.py --package-name azure-ai-projects - python scripts/api_md_workflow/create_api_review_pr.py --package-name azure-ai-projects \\ - --base azure-ai-projects_1.0.0b1 - python scripts/api_md_workflow/create_api_review_pr.py --package-name azure-ai-projects \\ - --base azure-ai-projects_1.0.0b1 --target my-feature-branch - -Requires ``gh`` (GitHub CLI) authenticated against the repository, plus push -access on the ``origin`` remote. -""" - -from __future__ import annotations - -import argparse -import json -import glob -import os -import re -import shutil -import subprocess -import sys -import tempfile -from typing import Optional -from urllib.parse import quote - - -REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -GENERATE_SCRIPT = os.path.join(REPO_ROOT, "scripts", "generate_api_text.py") -EXPORT_SCRIPT = os.path.join(REPO_ROOT, "eng", "common", "scripts", "Export-APIViewMarkdown.ps1") -REMOTE = "origin" -MAIN_REF = f"{REMOTE}/main" - - -# --------------------------------------------------------------------------- -# Shell helpers -# --------------------------------------------------------------------------- - -def run(cmd, *, cwd: str = REPO_ROOT, check: bool = True, capture: bool = False, env: Optional[dict] = None) -> subprocess.CompletedProcess: - """Run a command, echoing it first.""" - printable = " ".join(cmd) if isinstance(cmd, list) else cmd - print(f"$ {printable}") - return subprocess.run( - cmd, - cwd=cwd, - check=check, - text=True, - capture_output=capture, - env=env, - ) - - -def git(*args: str, capture: bool = False, check: bool = True) -> subprocess.CompletedProcess: - return run(["git", *args], capture=capture, check=check) - - -def git_out(*args: str) -> str: - return git(*args, capture=True).stdout.strip() - - -# --------------------------------------------------------------------------- -# Package + ref helpers -# --------------------------------------------------------------------------- - -def find_package_dir(package_name: str) -> str: - """Locate ``sdk/*/{package_name}`` containing a pyproject.toml or setup.py.""" - pattern = os.path.join(REPO_ROOT, "sdk", "*", package_name) - matches = [ - m for m in glob.glob(pattern) - if os.path.isdir(m) - and ( - os.path.exists(os.path.join(m, "pyproject.toml")) - or os.path.exists(os.path.join(m, "setup.py")) - ) - ] - if not matches: - raise SystemExit(f"ERROR: package '{package_name}' not found under sdk/*/") - if len(matches) > 1: - raise SystemExit(f"ERROR: multiple matches for '{package_name}': {matches}") - return matches[0] - - -def package_rel_dir(package_dir: str) -> str: - """Repo-relative POSIX path for the package directory.""" - return os.path.relpath(package_dir, REPO_ROOT).replace(os.sep, "/") - - -def api_md_path(package_dir: str) -> str: - return os.path.join(package_dir, "API.md") - - -def api_md_rel(package_dir: str) -> str: - return f"{package_rel_dir(package_dir)}/API.md" - - -_VERSION_RE = re.compile(r"""^\s*VERSION\s*[:=]\s*["']([^"']+)["']""", re.MULTILINE) - - -def read_version(package_dir: str) -> str: - """Find and parse a ``_version.py`` (or ``version.py``) inside ``package_dir``.""" - candidates = [] - candidates.extend(glob.glob(os.path.join(package_dir, "**", "_version.py"), recursive=True)) - candidates.extend(glob.glob(os.path.join(package_dir, "**", "version.py"), recursive=True)) - for path in candidates: - try: - text = open(path, "r", encoding="utf-8").read() - except OSError: - continue - m = _VERSION_RE.search(text) - if m: - return m.group(1) - raise SystemExit(f"ERROR: could not find a version string in {package_dir}") - - -def tag_exists(tag: str) -> bool: - result = git("rev-parse", "--verify", "--quiet", f"refs/tags/{tag}", capture=True, check=False) - return result.returncode == 0 - - -def ensure_clean_worktree() -> None: - status = git_out("status", "--porcelain") - if status: - raise SystemExit( - "ERROR: working tree is not clean. Commit or stash changes before running.\n" - + status - ) - - -def current_branch() -> str: - return git_out("rev-parse", "--abbrev-ref", "HEAD") - - -def remote_branch_ref(branch: str) -> str: - """Return the ref name for a branch on ``REMOTE``, fetching it first.""" - git("fetch", REMOTE, branch) - return f"{REMOTE}/{branch}" - - -def resolve_target_ref(target: str) -> str: - """Resolve ``--target`` to a checkoutable ref. - - Supports both: - - ``branch``: fetched from ``origin`` and returned as ``origin/branch`` - - ``owner:branch``: fetched from ``https://github.com/{owner}/azure-sdk-for-python.git`` - and returned as ``FETCH_HEAD`` - """ - if ":" not in target: - return remote_branch_ref(target) - - owner, branch = target.split(":", 1) - if not owner or not branch: - raise SystemExit( - f"ERROR: invalid --target '{target}'. Expected either 'branch' or 'owner:branch'." - ) - - fork_url = f"https://github.com/{owner}/azure-sdk-for-python.git" - git("fetch", fork_url, branch) - return "FETCH_HEAD" - - -# --------------------------------------------------------------------------- -# API.md generation -# --------------------------------------------------------------------------- - -def generate_api_md(package_name: str, package_dir: str) -> bytes: - """Run ``generate_api_text.py`` for the package and return the bytes of the - resulting API.md. The file is also left on disk at its canonical location. - """ - print(f"--- Generating API.md for {package_name} on {current_branch_or_sha()} ---") - run([sys.executable, GENERATE_SCRIPT, package_name]) - path = api_md_path(package_dir) - if not os.path.exists(path): - raise SystemExit(f"ERROR: generate_api_text.py did not produce {path}") - with open(path, "rb") as f: - return f.read() - - -def current_branch_or_sha() -> str: - name = git_out("rev-parse", "--abbrev-ref", "HEAD") - if name == "HEAD": - return git_out("rev-parse", "--short", "HEAD") - return name - - -# --------------------------------------------------------------------------- -# Main workflow -# --------------------------------------------------------------------------- - -def parse_args() -> argparse.Namespace: - doc = __doc__ or "Create an API review PR" - p = argparse.ArgumentParser(description=doc.splitlines()[0]) - p.add_argument("--package-name", required=True, - help="Package directory name under sdk/*/ (e.g. azure-ai-projects)") - p.add_argument("--base", default=None, - help="Tag to use as the API.md baseline, formatted as " - "'{package-name}_{version}'. Omit to make the baseline empty.") - p.add_argument("--target", default=None, - help="Branch containing the API to review. Supports 'branch' or 'owner:branch'. Omit to use the latest origin/main.") - return p.parse_args() - - -def validate_base_tag(package_name: str, base: str) -> str: - """Validate the ``--base`` tag format/existence and return the version.""" - if not base.startswith(f"{package_name}_"): - raise SystemExit( - f"ERROR: --base tag '{base}' must start with '{package_name}_'." - ) - version = base[len(package_name) + 1:] - if not version: - raise SystemExit(f"ERROR: --base tag '{base}' is missing the version suffix.") - if not tag_exists(base): - raise SystemExit(f"ERROR: tag '{base}' does not exist in this repository.") - return version - - -def write_bytes(path: str, data: bytes) -> None: - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "wb") as f: - f.write(data) - - -def main() -> int: - args = parse_args() - package_name = args.package_name - - package_dir = find_package_dir(package_name) - print(f"Found package at: {package_dir}") - - ensure_clean_worktree() - original_branch = current_branch() - if original_branch == "HEAD": - raise SystemExit("ERROR: refusing to run from a detached HEAD.") - - # Always fetch main once up-front. - git("fetch", REMOTE, "main") - - # ---- Validate inputs -------------------------------------------------- - base_version = "none" - if args.base is not None: - base_version = validate_base_tag(package_name, args.base) - - target_ref: str - if args.target is None: - target_ref = MAIN_REF - else: - target_ref = resolve_target_ref(args.target) - - # Cache the generate + export scripts (they may not exist on older refs we check out). - tmp_script_dir = tempfile.mkdtemp(prefix="apirev_script_") - cached_script = os.path.join(tmp_script_dir, "generate_api_text.py") - cached_export = os.path.join(tmp_script_dir, "Export-APIViewMarkdown.ps1") - shutil.copy2(GENERATE_SCRIPT, cached_script) - shutil.copy2(EXPORT_SCRIPT, cached_export) - - try: - # ---- Step 1: capture base API.md content (if base is a tag) ------ - base_api_bytes: Optional[bytes] = None - if args.base is not None: - print(f"\n=== Capturing baseline API.md from tag {args.base} ===") - git("checkout", "--detach", args.base) - base_api_bytes = _generate_with_cached_script( - cached_script, cached_export, package_name, package_dir - ) - - # ---- Step 2: capture target API.md content ----------------------- - print(f"\n=== Capturing target API.md from {target_ref} ===") - git("checkout", "--detach", target_ref) - target_version = read_version(package_dir) - target_api_bytes = _generate_with_cached_script( - cached_script, cached_export, package_name, package_dir - ) - - # ---- Step 3: build base branch off origin/main ------------------- - base_branch = f"base_{package_name}_{base_version}" - review_branch = f"review_{package_name}_{target_version}" - - print(f"\n=== Creating base branch {base_branch} ===") - git("checkout", "-B", base_branch, MAIN_REF) - - api_path = api_md_path(package_dir) - api_relative = api_md_rel(package_dir) - - if base_api_bytes is not None: - write_bytes(api_path, base_api_bytes) - git("add", api_relative) - git("commit", "-m", - f"[API Review] Baseline API.md for {package_name} {base_version}") - else: - # Is the file tracked in the branch we just created? (Not "is it on disk?" -- - # generate_api_text.py from a previous step may have left an untracked copy.) - tracked = git("ls-files", "--error-unmatch", api_relative, - capture=True, check=False) - if tracked.returncode == 0: - git("rm", api_relative) - git("commit", "-m", - f"[API Review] Remove API.md for {package_name} (empty baseline)") - else: - # Ensure no stray untracked copy is left in the working tree. - if os.path.exists(api_path): - os.remove(api_path) - git("commit", "--allow-empty", "-m", - f"[API Review] Empty baseline for {package_name}") - - git("push", "--force-with-lease", REMOTE, base_branch) - - # ---- Step 4: build review branch off base branch ----------------- - print(f"\n=== Creating review branch {review_branch} ===") - git("checkout", "-B", review_branch, base_branch) - write_bytes(api_path, target_api_bytes) - git("add", api_relative) - # If the bytes happen to be identical to the base, commit empty so we - # still have something to PR. - diff = git("diff", "--cached", "--quiet", capture=True, check=False) - if diff.returncode == 0: - git("commit", "--allow-empty", "-m", - f"[API Review] API.md for {package_name} {target_version} (no diff vs baseline)") - else: - git("commit", "-m", - f"[API Review] API.md for {package_name} {target_version}") - - git("push", "--force-with-lease", REMOTE, review_branch) - - # ---- Step 5: open PR -------------------------------------------- - title = f"[API Review] {package_name} {target_version} (base {base_version})" - working_selector = args.target or original_branch - working_ref = _working_reference_markdown(working_selector) - body_lines = [ - f"Automated API review PR for `{package_name}`.", - "", - f"- **Working branch:** {working_ref}", - f"- **Target:** `{args.target or 'origin/main'}` (version `{target_version}`)", - f"- **Baseline:** {'tag `' + args.base + '`' if args.base else '_empty_'} " - f"(version `{base_version}`)", - "", - "Generated by `scripts/api_md_workflow/create_api_review_pr.py`.", - ] - body = "\n".join(body_lines) - - print(f"\n=== Opening PR ===") - compare_url = ( - f"https://github.com/Azure/azure-sdk-for-python/compare/" - f"{base_branch}...{review_branch}?expand=1" - ) - pr_result = run([ - "gh", "pr", "create", - "--repo", "Azure/azure-sdk-for-python", - "--base", base_branch, - "--head", review_branch, - "--title", title, - "--body", body, - "--draft", - ], check=False, env=_env_with_real_git()) - if pr_result.returncode != 0: - print( - "\nWARNING: `gh pr create` failed. Both branches were pushed " - "successfully -- open the PR manually here:\n" - f" {compare_url}\n" - f" Title: {title}" - ) - - return 0 - - finally: - # Restore the user's original branch. - try: - git("checkout", original_branch, check=False) - finally: - shutil.rmtree(tmp_script_dir, ignore_errors=True) - - -def _find_real_git_exe() -> Optional[str]: - """Locate the real ``git.exe`` (skipping any .cmd/.bat shims on PATH). - - On Windows, some environments install a ``git.cmd`` wrapper in front of - the real ``git.exe`` (e.g. ``C:\\Windows\\System32\\git.cmd``). ``gh`` - spawns ``git`` as a child process and is sensitive to argument quoting - when the resolved binary is a ``.cmd`` shim -- subcommands like - ``git merge-base`` get mangled into ``merge``. We search PATH for an - actual ``git.exe`` so we can prefer it. - """ - if os.name != "nt": - return None - seen = set() - for entry in os.environ.get("PATH", "").split(os.pathsep): - entry = entry.strip('"') - if not entry or entry.lower() in seen: - continue - seen.add(entry.lower()) - candidate = os.path.join(entry, "git.exe") - if os.path.isfile(candidate): - return candidate - # Fallback: common install location. - fallback = r"C:\Program Files\Git\cmd\git.exe" - return fallback if os.path.isfile(fallback) else None - - -def _env_with_real_git() -> dict: - """Return a copy of os.environ with the real git.exe directory pushed to - the front of PATH (no-op on non-Windows or if no .exe is found).""" - env = os.environ.copy() - real_git = _find_real_git_exe() - if not real_git: - return env - git_dir = os.path.dirname(real_git) - current_path = env.get("PATH", "") - # Only prepend if it isn't already first. - parts = current_path.split(os.pathsep) - if not parts or parts[0].rstrip("\\").lower() != git_dir.rstrip("\\").lower(): - env["PATH"] = git_dir + os.pathsep + current_path - print(f"(prepending real git to PATH for gh: {git_dir})") - return env - - -def _find_open_pr_for_head(head_selector: str) -> Optional[dict]: - """Return best PR metadata for a head selector, or None when no PR exists. - - ``head_selector`` supports both ``branch`` and ``owner:branch``. - Preference order: - 1) Open PRs - 2) Most recently updated PR (if only closed/merged PRs exist) - """ - - def _parse_prs(output: str) -> Optional[list]: - try: - prs = json.loads(output or "[]") - except json.JSONDecodeError: - return None - if not isinstance(prs, list): - return None - return prs - - def _select_best(prs: list) -> Optional[dict]: - candidates = [ - pr - for pr in prs - if isinstance(pr, dict) - and "number" in pr - and "url" in pr - and "state" in pr - and "updatedAt" in pr - ] - if not candidates: - return None - - open_prs = [pr for pr in candidates if str(pr.get("state", "")).lower() == "open"] - pool = open_prs or candidates - # ISO-8601 timestamps sort correctly lexicographically. - pool.sort(key=lambda pr: str(pr.get("updatedAt", "")), reverse=True) - return pool[0] - - env = _env_with_real_git() - selectors = [head_selector] - if ":" in head_selector: - _, branch_only = head_selector.split(":", 1) - if branch_only and branch_only not in selectors: - selectors.append(branch_only) - - all_prs = [] - - # First attempt: native head filter for each selector form. - for selector in selectors: - direct = run( - [ - "gh", - "pr", - "list", - "--repo", - "Azure/azure-sdk-for-python", - "--head", - selector, - "--state", - "all", - "--json", - "number,url,state,updatedAt", - "--limit", - "50", - ], - check=False, - capture=True, - env=env, - ) - if direct.returncode == 0: - direct_prs = _parse_prs(direct.stdout) - if direct_prs: - all_prs.extend(direct_prs) - - # Fallback: search filter for each selector form. - for selector in selectors: - search_query = f"repo:Azure/azure-sdk-for-python head:{selector}" - search = run( - [ - "gh", - "pr", - "list", - "--repo", - "Azure/azure-sdk-for-python", - "--search", - search_query, - "--state", - "all", - "--json", - "number,url,state,updatedAt", - "--limit", - "50", - ], - check=False, - capture=True, - env=env, - ) - if search.returncode == 0: - search_prs = _parse_prs(search.stdout) - if search_prs: - all_prs.extend(search_prs) - - if not all_prs: - return None - - # De-duplicate by PR number. - deduped = {} - for pr in all_prs: - if isinstance(pr, dict) and "number" in pr: - deduped[pr["number"]] = pr - - return _select_best(list(deduped.values())) - - -def _working_reference_markdown(head_selector: str) -> str: - """Build markdown for a working head selector, preferring an open PR link.""" - pr = _find_open_pr_for_head(head_selector) - if pr: - return f"[PR #{pr['number']}]({pr['url']})" - - if ":" in head_selector: - owner, branch = head_selector.split(":", 1) - branch_url = f"https://github.com/{owner}/azure-sdk-for-python/tree/{quote(branch, safe='')}" - return f"[branch `{head_selector}`]({branch_url})" - - branch_url = f"https://github.com/Azure/azure-sdk-for-python/tree/{quote(head_selector, safe='')}" - return f"[branch `{head_selector}`]({branch_url})" - - -def _generate_with_cached_script(cached_script: str, cached_export: str, package_name: str, package_dir: str) -> bytes: - """Run the cached copy of generate_api_text.py against the currently - checked-out ref and return the bytes of the resulting API.md.""" - print(f"--- Generating API.md on {current_branch_or_sha()} ---") - env = os.environ.copy() - env["AZSDK_REPO_ROOT"] = REPO_ROOT - env["AZSDK_EXPORT_SCRIPT"] = cached_export - run([sys.executable, cached_script, package_name], env=env) - path = api_md_path(package_dir) - if not os.path.exists(path): - raise SystemExit(f"ERROR: did not produce {path}") - with open(path, "rb") as f: - return f.read() - - -if __name__ == "__main__": - sys.exit(main()) From 688e16f1c995c6a245d49dcce84912393d9f2dbe Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Tue, 2 Jun 2026 14:53:19 -0700 Subject: [PATCH 06/33] Remove update process and just have a consistency check. --- .github/workflows/commenter.yml | 36 ---- .github/workflows/consistency.yml | 169 ++++-------------- scripts/api_md_workflow/README.md | 146 ++++----------- scripts/api_md_workflow/adapter_config.js | 49 +++++ scripts/api_md_workflow/adapters/python.js | 25 ++- .../api_md_workflow.config.json | 3 + .../build_apply_result_payload.py | 52 ------ .../api_md_workflow/build_comment_payload.py | 66 ------- scripts/api_md_workflow/common.js | 79 ++++++++ scripts/api_md_workflow/common.py | 95 ---------- .../api_md_workflow/create_api_review_pr.js | 23 +-- scripts/api_md_workflow/find_affected.js | 64 +++++++ scripts/api_md_workflow/find_affected.py | 37 ---- scripts/api_md_workflow/find_mismatches.js | 52 ++++++ scripts/api_md_workflow/find_mismatches.py | 40 ----- scripts/api_md_workflow/post_comment.py | 54 ------ scripts/api_md_workflow/regenerate.js | 40 +++++ scripts/api_md_workflow/regenerate.py | 26 --- 18 files changed, 385 insertions(+), 671 deletions(-) delete mode 100644 .github/workflows/commenter.yml create mode 100644 scripts/api_md_workflow/adapter_config.js create mode 100644 scripts/api_md_workflow/api_md_workflow.config.json delete mode 100644 scripts/api_md_workflow/build_apply_result_payload.py delete mode 100644 scripts/api_md_workflow/build_comment_payload.py create mode 100644 scripts/api_md_workflow/common.js delete mode 100644 scripts/api_md_workflow/common.py create mode 100644 scripts/api_md_workflow/find_affected.js delete mode 100644 scripts/api_md_workflow/find_affected.py create mode 100644 scripts/api_md_workflow/find_mismatches.js delete mode 100644 scripts/api_md_workflow/find_mismatches.py delete mode 100644 scripts/api_md_workflow/post_comment.py create mode 100644 scripts/api_md_workflow/regenerate.js delete mode 100644 scripts/api_md_workflow/regenerate.py diff --git a/.github/workflows/commenter.yml b/.github/workflows/commenter.yml deleted file mode 100644 index 2f8402d9d27f..000000000000 --- a/.github/workflows/commenter.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: API.md Consistency Commenter - -on: - workflow_run: - workflows: ["API.md Consistency"] - types: [completed] - -permissions: - actions: read - pull-requests: write - -jobs: - commenter: - if: ${{ github.event.workflow_run.event == 'pull_request' }} - runs-on: ubuntu-latest - steps: - - name: Prepare artifact directory - run: mkdir -p "${{ runner.temp }}/api-md-comment" - - - name: Download comment artifact - uses: actions/download-artifact@v8 - with: - name: api-md-comment - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ secrets.GITHUB_TOKEN }} - path: ${{ runner.temp }}/api-md-comment - - - name: Create or update PR comment - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COMMENT_FILE: ${{ runner.temp }}/api-md-comment/comment.json - DEFAULT_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} - shell: bash - run: | - set -euo pipefail - python scripts/api_md_workflow/post_comment.py diff --git a/.github/workflows/consistency.yml b/.github/workflows/consistency.yml index 4dc2eea06d7b..c7a662860f3a 100644 --- a/.github/workflows/consistency.yml +++ b/.github/workflows/consistency.yml @@ -16,6 +16,8 @@ jobs: outputs: changed_count: ${{ steps.changed.outputs.count || '0' }} mismatch_count: ${{ steps.consistency.outputs.mismatch_count || '0' }} + missing_count: ${{ steps.consistency.outputs.missing_count || '0' }} + issue_count: ${{ steps.consistency.outputs.issue_count || '0' }} steps: - name: Checkout uses: actions/checkout@v6 @@ -27,6 +29,11 @@ jobs: with: python-version: "3.12" + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: "20" + - name: Find changed SDK packages id: changed env: @@ -36,7 +43,7 @@ jobs: shell: bash run: | set -euo pipefail - python scripts/api_md_workflow/find_affected.py + node scripts/api_md_workflow/find_affected.js - name: Generate API.md for affected packages if: ${{ steps.changed.outputs.count != '0' }} @@ -45,159 +52,55 @@ jobs: shell: bash run: | set -euo pipefail - python -m pip install --upgrade pip - python scripts/api_md_workflow/regenerate.py + node scripts/api_md_workflow/regenerate.js - name: Check API.md consistency id: consistency env: API_MD_PACKAGES_FILE: .artifacts/affected_package_dirs.txt API_MD_MISMATCHES_FILE: .artifacts/mismatched_api_files.txt + API_MD_MISSING_FILE: .artifacts/missing_api_files.txt shell: bash run: | set -euo pipefail if [ "${{ steps.changed.outputs.count }}" = "0" ]; then echo "mismatch_count=0" >> "$GITHUB_OUTPUT" + echo "missing_count=0" >> "$GITHUB_OUTPUT" + echo "issue_count=0" >> "$GITHUB_OUTPUT" exit 0 fi - python scripts/api_md_workflow/find_mismatches.py - - - name: Upload apply context artifact - if: always() - uses: actions/upload-artifact@v7 - with: - name: api-md-context - path: | - .artifacts/affected_package_dirs.txt - .artifacts/mismatched_api_files.txt - retention-days: 1 - - - name: Build PR comment payload - if: always() - shell: bash - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - REPOSITORY: ${{ github.repository }} - RUN_ID: ${{ github.run_id }} - RUN_ATTEMPT: ${{ github.run_attempt }} - MISMATCH_COUNT: ${{ steps.consistency.outputs.mismatch_count || '0' }} - CHANGED_COUNT: ${{ steps.changed.outputs.count || '0' }} - API_MD_MISMATCHES_FILE: .artifacts/mismatched_api_files.txt - API_MD_COMMENT_FILE: .artifacts/comment/comment.json - run: | - set -euo pipefail - python scripts/api_md_workflow/build_comment_payload.py - - - name: Upload comment artifact - if: always() - uses: actions/upload-artifact@v7 - with: - name: api-md-comment - path: .artifacts/comment/comment.json - retention-days: 1 + node scripts/api_md_workflow/find_mismatches.js - name: Fail when API.md is out of date - if: ${{ steps.changed.outputs.count != '0' && steps.consistency.outputs.mismatch_count != '0' }} - shell: bash - run: | - echo "Generated API.md does not match committed API.md, or API.md is missing, for one or more affected packages." - echo "Re-run this workflow to apply and commit updates to the same PR branch." - exit 1 - - apply-updates: - name: Apply API.md updates on rerun - needs: consistency - if: > - github.run_attempt > 1 && - needs.consistency.outputs.mismatch_count != '0' && - github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - name: Checkout PR head branch - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.ref }} - fetch-depth: 0 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Download apply context - uses: actions/download-artifact@v8 - with: - name: api-md-context - path: .artifacts - - - name: Regenerate API.md for affected packages - env: - API_MD_PACKAGES_FILE: .artifacts/affected_package_dirs.txt + if: ${{ steps.changed.outputs.count != '0' && steps.consistency.outputs.issue_count != '0' }} shell: bash run: | set -euo pipefail - if [ ! -s .artifacts/affected_package_dirs.txt ]; then - echo "No affected packages found; nothing to apply." - exit 0 - fi - - python -m pip install --upgrade pip - python scripts/api_md_workflow/regenerate.py + print_issues() { + local title="$1" + local issues_file="$2" - - name: Commit and push API.md updates - id: commit - shell: bash - run: | - set -euo pipefail - - if [ ! -f .artifacts/mismatched_api_files.txt ] || [ ! -s .artifacts/mismatched_api_files.txt ]; then - echo "created=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - while IFS= read -r api_file; do - if [ -f "${api_file}" ] && ! git diff --quiet -- "${api_file}"; then - git add "${api_file}" + if [ ! -s "$issues_file" ]; then + return fi - done < .artifacts/mismatched_api_files.txt - if git diff --cached --quiet; then - echo "created=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - git commit -m "Update API.md for PR #${{ github.event.pull_request.number }}" - git push origin "HEAD:${{ github.event.pull_request.head.ref }}" - - echo "created=true" >> "$GITHUB_OUTPUT" - echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - - - name: Build apply result payload - shell: bash - env: - COMMIT_CREATED: ${{ steps.commit.outputs.created || 'false' }} - COMMIT_SHA: ${{ steps.commit.outputs.sha || '' }} - PR_NUMBER: ${{ github.event.pull_request.number }} - HEAD_REF: ${{ github.event.pull_request.head.ref }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - API_MD_APPLY_RESULT_FILE: .artifacts/comment/apply-result.json - run: | - set -euo pipefail - python scripts/api_md_workflow/build_apply_result_payload.py + echo "$title" + while IFS= read -r api_file; do + [ -n "$api_file" ] || continue + package_dir="${api_file%/API.md}" + package_name="$(basename "$package_dir")" + echo "- ${package_dir}" + echo " API.md: ${api_file}" + echo " Regenerate: python scripts/generate_api_text.py ${package_name}" + done < "$issues_file" + echo + } - - name: Post apply result comment - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COMMENT_FILE: .artifacts/comment/apply-result.json - shell: bash - run: | - set -euo pipefail - python scripts/api_md_workflow/post_comment.py + echo "Generated API.md does not match committed API.md, or API.md is missing, for one or more affected packages." + echo + print_issues "Mismatched packages:" ".artifacts/mismatched_api_files.txt" + print_issues "Missing API.md packages:" ".artifacts/missing_api_files.txt" + echo "To regenerate API.md locally, run the command shown for each package from the repository root." + exit 1 diff --git a/scripts/api_md_workflow/README.md b/scripts/api_md_workflow/README.md index 946e9d6b8eb8..d750693dc86f 100644 --- a/scripts/api_md_workflow/README.md +++ b/scripts/api_md_workflow/README.md @@ -4,12 +4,9 @@ This folder contains the helper scripts used by the GitHub Actions workflows tha ## Purpose -The workflow has two goals: +The workflow validates that when a pull request changes one or more SDK packages, the committed `API.md` files are still up to date. -1. Detect when a pull request changes one or more SDK packages and the committed `API.md` files are out of date. -2. Allow the PR author or maintainer to re-run the workflow so the regenerated `API.md` files are committed back to the PR branch. - -The logic is split between GitHub workflow YAML files and these Python helper scripts. +The logic is split between GitHub workflow YAML files and helper scripts in Python and JavaScript. ## Workflow Files @@ -17,38 +14,17 @@ The logic is split between GitHub workflow YAML files and these Python helper sc This is the main workflow. -It runs on pull requests for changes under `sdk/**` and contains two jobs: - -1. `consistency` - - Detects affected package directories from the PR diff. - - Regenerates `API.md` for those packages. - - Fails if the generated files differ from the committed files. - - Fails if an affected package does not have a committed `API.md`. - - Uploads two artifacts: - - `api-md-context`: package paths and mismatched files for rerun/apply - - `api-md-comment`: JSON payload used by the commenter workflow - -2. `apply-updates` - - Only runs on reruns (`github.run_attempt > 1`). - - Downloads `api-md-context` from the earlier job. - - Regenerates `API.md` again. - - Commits and pushes the updated files back to the same PR branch. - - Posts a follow-up result comment to the PR. - -### `.github/workflows/commenter.yml` - -This is the trusted follow-up workflow. +It runs on pull requests for changes under `sdk/**`. -It runs on `workflow_run` for `API.md Consistency` and does not build or regenerate code. It only: - -1. Downloads the `api-md-comment` artifact from the completed consistency run. -2. Creates or updates a single PR comment using a marker-based upsert. - -This separate workflow keeps comment publishing isolated from the PR execution context. +- Detects affected package directories from the PR diff. +- Regenerates `API.md` for those packages. +- Fails if the generated files differ from the committed files. +- Fails if an affected package does not have a committed `API.md`. +- Prints the mismatched or missing packages and the `scripts/generate_api_text.py` command needed to regenerate each `API.md` file. ## Script Layout -### `common.py` +### `common.js` Shared helpers used by the other scripts: @@ -58,7 +34,7 @@ Shared helpers used by the other scripts: - writing GitHub Actions outputs - GitHub REST API helpers for listing/updating comments -### `find_affected.py` +### `find_affected.js` Used by the `consistency` job. @@ -69,80 +45,37 @@ Reads `API_MD_BASE_REF`, compares the PR branch to `origin/`, and writes: Also writes `count=` to `GITHUB_OUTPUT`. -### `regenerate.py` +### `regenerate.js` Reads package directories from `API_MD_PACKAGES_FILE` and runs `scripts/generate_api_text.py` for each package. -This script is used in both: - -- the initial consistency check -- the rerun apply step - -### `find_mismatches.py` - -Reads package directories from `API_MD_PACKAGES_FILE`, checks whether `/API.md` is missing, untracked, or differs from git, and writes the mismatched file list to `API_MD_MISMATCHES_FILE`. - -Also writes `mismatch_count=` to `GITHUB_OUTPUT`. - -### `build_comment_payload.py` - -Builds the JSON payload consumed by `commenter.yml`. - -Inputs come from environment variables such as: - -- `PR_NUMBER` -- `REPOSITORY` -- `RUN_ID` -- `RUN_ATTEMPT` -- `CHANGED_COUNT` -- `MISMATCH_COUNT` - -It writes the final comment JSON to `API_MD_COMMENT_FILE`. - -The payload includes: - -- a stable marker comment -- the PR number -- the rendered markdown body - -When drift is found on the initial run, the body tells the user to open the workflow run and click `Re-run all jobs`. - -### `build_apply_result_payload.py` +This script is used by the consistency check. -Builds the JSON payload for the follow-up comment after `apply-updates` runs. +### `find_mismatches.js` -It uses environment variables such as: +Reads package directories from `API_MD_PACKAGES_FILE`, checks whether `/API.md` is missing/untracked or differs from git, and writes: -- `COMMIT_CREATED` -- `PR_NUMBER` -- `HEAD_REF` -- `RUN_URL` -- `COMMIT_SHA` +- mismatched files to `API_MD_MISMATCHES_FILE` +- missing files to `API_MD_MISSING_FILE` -It writes the result payload to `API_MD_APPLY_RESULT_FILE`. +Also writes `mismatch_count=`, `missing_count=`, and `issue_count=` to `GITHUB_OUTPUT`. -### `post_comment.py` +### `create_api_review_pr.js` and adapters -Posts or updates the PR comment from a payload file. +API review PR creation now uses a shared JavaScript orchestrator with a language adapter boundary: -Inputs: +- `create_api_review_pr.js`: shared git/branch/PR orchestration logic. +- `adapters/python.js`: Python-specific package discovery, version parsing, and `API.md` generation. -- `COMMENT_FILE` -- `GITHUB_TOKEN` -- `GITHUB_REPOSITORY` -- optional `DEFAULT_PR_NUMBER` -- optional `DEFAULT_MARKER` +This split allows the core workflow to be reused across other language repos while keeping generation behavior language-specific. -Behavior: +### `api_md_workflow.config.json` -1. Reads the JSON payload. -2. Finds an existing bot comment containing the same marker. -3. Updates that comment if found, otherwise creates a new one. +Shared configuration for adapter selection across `api_md_workflow` scripts. -This is used by: +- `adapter`: default adapter name (for this repo: `python`) -- `commenter.yml` for the main consistency comment -- `consistency.yml` for the apply result comment +Both `create_api_review_pr.js` and `find_affected.js` read this file for adapter selection. ## Environment Variables Used @@ -154,30 +87,13 @@ Common variables include: - `API_MD_PACKAGES_FILE` - `API_MD_CHANGED_FILE` - `API_MD_MISMATCHES_FILE` -- `API_MD_COMMENT_FILE` -- `API_MD_APPLY_RESULT_FILE` -- `PR_NUMBER` -- `REPOSITORY` -- `RUN_ID` -- `RUN_ATTEMPT` -- `GITHUB_TOKEN` -- `GITHUB_REPOSITORY` +- `API_MD_MISSING_FILE` ## End-to-End Flow 1. A PR changes files under `sdk/**`. 2. `consistency.yml` runs. -3. `find_affected.py` determines which packages were touched. -4. `regenerate.py` rebuilds `API.md` for those packages. -5. `find_mismatches.py` records any `API.md` drift, including missing or untracked `API.md` files. -6. `build_comment_payload.py` creates a comment artifact. -7. `commenter.yml` downloads that artifact and runs `post_comment.py`. -8. The PR comment tells the user to rerun the workflow if they want the fixes applied. -9. On rerun, the `apply-updates` job in `consistency.yml` runs. -10. It regenerates the files again, commits them, and posts a result comment via `build_apply_result_payload.py` and `post_comment.py`. - -## Maintenance Notes - -- Keep comment rendering logic in the Python scripts, not inline in the YAML. -- Keep workflow YAML focused on orchestration: checkout, setup, artifacts, and calling scripts. -- If the comment format changes, update the marker handling carefully so existing PR comments continue to be updated instead of duplicated. +3. `find_affected.js` determines which packages were touched. +4. `regenerate.js` rebuilds `API.md` for those packages. +5. `find_mismatches.js` records any `API.md` drift, including missing or untracked `API.md` files. +6. If drift is found, the workflow fails and prints the affected packages plus the `scripts/generate_api_text.py` command to regenerate each `API.md` file locally. diff --git a/scripts/api_md_workflow/adapter_config.js b/scripts/api_md_workflow/adapter_config.js new file mode 100644 index 000000000000..660cc9462419 --- /dev/null +++ b/scripts/api_md_workflow/adapter_config.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); + +const DEFAULT_CONFIG = { + adapter: "python", +}; + +function loadWorkflowConfig() { + const configPath = path.join(__dirname, "api_md_workflow.config.json"); + if (!fs.existsSync(configPath)) { + return { ...DEFAULT_CONFIG }; + } + + const raw = fs.readFileSync(configPath, "utf-8"); + let parsed; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error( + `ERROR: invalid JSON in ${configPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (!parsed || typeof parsed !== "object") { + throw new Error(`ERROR: ${configPath} must contain a JSON object.`); + } + + return { + ...DEFAULT_CONFIG, + ...parsed, + }; +} + +function loadAdapter(name) { + const adapterPath = path.join(__dirname, "adapters", `${name}.js`); + if (!fs.existsSync(adapterPath)) { + throw new Error(`ERROR: adapter '${name}' not found at ${adapterPath}`); + } + + // eslint-disable-next-line global-require, import/no-dynamic-require + return require(adapterPath); +} + +module.exports = { + loadWorkflowConfig, + loadAdapter, +}; diff --git a/scripts/api_md_workflow/adapters/python.js b/scripts/api_md_workflow/adapters/python.js index 7e29d5bb7d12..32f74f922323 100644 --- a/scripts/api_md_workflow/adapters/python.js +++ b/scripts/api_md_workflow/adapters/python.js @@ -53,6 +53,15 @@ function findPackageDir(repoRoot, packageName) { return matches[0]; } +function isPackageDir(repoRoot, packageDirRelative) { + const candidate = path.join(repoRoot, packageDirRelative); + if (!fs.existsSync(candidate) || !fs.statSync(candidate).isDirectory()) { + return false; + } + + return fs.existsSync(path.join(candidate, "pyproject.toml")) || fs.existsSync(path.join(candidate, "setup.py")); +} + function* walkFiles(startDir) { const entries = fs.readdirSync(startDir, { withFileTypes: true }); for (const entry of entries) { @@ -99,9 +108,10 @@ function generateApiMdBytes({ packageDir, generateScriptPath, exportScriptPath, - pythonExecutable, + runtimeExecutable, refLabel, }) { + const executable = runtimeExecutable || process.env.PYTHON || "python"; console.log(`--- Generating API.md on ${refLabel} ---`); const env = { ...process.env, @@ -109,7 +119,7 @@ function generateApiMdBytes({ AZSDK_EXPORT_SCRIPT: exportScriptPath, }; - run(pythonExecutable, [generateScriptPath, packageName], { + run(executable, [generateScriptPath, packageName], { cwd: repoRoot, env, check: true, @@ -123,9 +133,20 @@ function generateApiMdBytes({ return fs.readFileSync(apiMdPath); } +function generateApiForPackage({ repoRoot, packageName, runtimeExecutable }) { + const executable = runtimeExecutable || process.env.PYTHON || "python"; + const generateScriptPath = path.join(repoRoot, "scripts", "generate_api_text.py"); + run(executable, [generateScriptPath, packageName], { + cwd: repoRoot, + check: true, + }); +} + module.exports = { name: "python", + isPackageDir, findPackageDir, readVersion, + generateApiForPackage, generateApiMdBytes, }; diff --git a/scripts/api_md_workflow/api_md_workflow.config.json b/scripts/api_md_workflow/api_md_workflow.config.json new file mode 100644 index 000000000000..34b0ae2b8ce7 --- /dev/null +++ b/scripts/api_md_workflow/api_md_workflow.config.json @@ -0,0 +1,3 @@ +{ + "adapter": "python" +} \ No newline at end of file diff --git a/scripts/api_md_workflow/build_apply_result_payload.py b/scripts/api_md_workflow/build_apply_result_payload.py deleted file mode 100644 index 1647aa7e4886..000000000000 --- a/scripts/api_md_workflow/build_apply_result_payload.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python - -from __future__ import annotations - -import json -import os - -from common import DEFAULT_APPLY_MARKER, env_path, require_env - - -def main() -> int: - created = require_env("COMMIT_CREATED").lower() == "true" - pr_number = int(require_env("PR_NUMBER")) - head_ref = require_env("HEAD_REF") - run_url = require_env("RUN_URL") - commit_sha = os.environ.get("COMMIT_SHA", "") - marker = os.environ.get("API_MD_APPLY_MARKER", DEFAULT_APPLY_MARKER) - out_file = env_path("API_MD_APPLY_RESULT_FILE", ".artifacts/comment/apply-result.json") - - if created: - body = "\n".join( - [ - "API.md updates have been committed to this PR branch.", - "", - f"- Branch: `{head_ref}`", - f"- Commit: `{commit_sha}`", - f"- Run: {run_url}", - ] - ) - else: - body = "\n".join( - [ - "No API.md changes were required after regeneration.", - "", - f"- Branch: `{head_ref}`", - f"- Run: {run_url}", - ] - ) - - payload = { - "marker": marker, - "pr_number": pr_number, - "body": body, - } - - out_file.parent.mkdir(parents=True, exist_ok=True) - out_file.write_text(json.dumps(payload, indent=2), encoding="utf-8") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/api_md_workflow/build_comment_payload.py b/scripts/api_md_workflow/build_comment_payload.py deleted file mode 100644 index 0f8f6962eafb..000000000000 --- a/scripts/api_md_workflow/build_comment_payload.py +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env python - -from __future__ import annotations - -import json -import os - -from common import DEFAULT_CONSISTENCY_MARKER, env_path, read_lines, require_env - - -def main() -> int: - marker = os.environ.get("API_MD_COMMENT_MARKER", DEFAULT_CONSISTENCY_MARKER) - pr_number = int(require_env("PR_NUMBER")) - repository = require_env("REPOSITORY") - run_id = require_env("RUN_ID") - run_attempt = int(require_env("RUN_ATTEMPT")) - mismatch_count = int(require_env("MISMATCH_COUNT")) - changed_count = int(require_env("CHANGED_COUNT")) - mismatches_file = env_path("API_MD_MISMATCHES_FILE", ".artifacts/mismatched_api_files.txt") - out_file = env_path("API_MD_COMMENT_FILE", ".artifacts/comment/comment.json") - - mismatches = read_lines(mismatches_file) - run_url = f"https://github.com/{repository}/actions/runs/{run_id}/attempts/{run_attempt}" - - if changed_count == 0: - body = ( - "## API.md consistency\n\n" - "No SDK package changes were detected in this PR." - ) - elif mismatch_count == 0: - body = ( - "## API.md consistency\n\n" - f"Checked {changed_count} affected package(s). All generated API.md files are up to date." - ) - elif run_attempt == 1: - lines = "\n".join(f"- `{path}`" for path in mismatches) - body = ( - "## API.md consistency\n\n" - "Generated API.md differs from files in this branch.\n\n" - "### Files that need update\n" - f"{lines}\n\n" - "### Apply updates to this same PR\n" - f"- Click [this workflow run]({run_url}).\n" - "- In the Actions UI, click `Re-run all jobs`.\n" - "- On rerun, this workflow will commit regenerated API.md files directly to this PR branch.\n\n" - "No PR number input is required." - ) - else: - body = ( - "## API.md consistency\n\n" - "API.md updates are being applied for this PR rerun." - ) - - payload = { - "marker": marker, - "pr_number": pr_number, - "body": body, - } - - out_file.parent.mkdir(parents=True, exist_ok=True) - out_file.write_text(json.dumps(payload, indent=2), encoding="utf-8") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/api_md_workflow/common.js b/scripts/api_md_workflow/common.js new file mode 100644 index 000000000000..9b60247b7e20 --- /dev/null +++ b/scripts/api_md_workflow/common.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const DEFAULT_CONSISTENCY_MARKER = ""; +const DEFAULT_APPLY_MARKER = ""; + +function run(cmd, args, options = {}) { + const result = spawnSync(cmd, args, { + check: false, + cwd: options.cwd, + env: options.env, + encoding: "utf-8", + stdio: options.capture ? "pipe" : "inherit", + }); + + if ((options.check ?? true) && result.status !== 0) { + throw new Error(`Command failed (${result.status}): ${[cmd, ...args].join(" ")}`); + } + + return result; +} + +function readLines(filePath) { + if (!fs.existsSync(filePath)) { + return []; + } + + return fs + .readFileSync(filePath, "utf-8") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => Boolean(line)); +} + +function writeLines(filePath, lines) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + if (!lines.length) { + fs.writeFileSync(filePath, "", "utf-8"); + return; + } + fs.writeFileSync(filePath, `${lines.join("\n")}\n`, "utf-8"); +} + +function appendGithubOutput(key, value) { + const outputPath = process.env.GITHUB_OUTPUT; + if (!outputPath) { + return; + } + + fs.appendFileSync(outputPath, `${key}=${value}\n`, "utf-8"); +} + +function envPath(name, fallback) { + return process.env[name] || fallback; +} + +function requireEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`Environment variable ${name} is required`); + } + return value; +} + +module.exports = { + REPO_ROOT, + DEFAULT_CONSISTENCY_MARKER, + DEFAULT_APPLY_MARKER, + run, + readLines, + writeLines, + appendGithubOutput, + envPath, + requireEnv, +}; diff --git a/scripts/api_md_workflow/common.py b/scripts/api_md_workflow/common.py deleted file mode 100644 index cbd997bbc51d..000000000000 --- a/scripts/api_md_workflow/common.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env python - -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -from __future__ import annotations - -import json -import os -import subprocess -from pathlib import Path -from typing import Any -from urllib import request - - -REPO_ROOT = Path(__file__).resolve().parents[2] -GENERATE_API_SCRIPT = REPO_ROOT / "scripts" / "generate_api_text.py" -DEFAULT_CONSISTENCY_MARKER = "" -DEFAULT_APPLY_MARKER = "" - - -def run(cmd: list[str], *, check: bool = True, capture: bool = False) -> subprocess.CompletedProcess[str]: - return subprocess.run(cmd, check=check, text=True, capture_output=capture) - - -def read_lines(path: Path) -> list[str]: - if not path.exists(): - return [] - return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] - - -def write_lines(path: Path, lines: list[str]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - if not lines: - path.write_text("", encoding="utf-8") - return - path.write_text("\n".join(lines) + "\n", encoding="utf-8") - - -def append_github_output(key: str, value: str | int) -> None: - output_path = os.environ.get("GITHUB_OUTPUT") - if not output_path: - return - with open(output_path, "a", encoding="utf-8") as handle: - handle.write(f"{key}={value}\n") - - -def env_path(name: str, default: str) -> Path: - return Path(os.environ.get(name, default)) - - -def require_env(name: str) -> str: - value = os.environ.get(name) - if not value: - raise ValueError(f"Environment variable {name} is required") - return value - - -def github_request(method: str, url: str, token: str, data: dict[str, Any] | None = None) -> Any: - payload = None - headers = { - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {token}", - "X-GitHub-Api-Version": "2022-11-28", - } - if data is not None: - payload = json.dumps(data).encode("utf-8") - headers["Content-Type"] = "application/json" - - req = request.Request(url, method=method, data=payload, headers=headers) - with request.urlopen(req) as response: - text = response.read().decode("utf-8") - if not text: - return None - return json.loads(text) - - -def list_comments(owner: str, repo: str, pr_number: int, token: str) -> list[dict[str, Any]]: - comments: list[dict[str, Any]] = [] - page = 1 - while True: - url = ( - f"https://api.github.com/repos/{owner}/{repo}/issues/{pr_number}/comments" - f"?per_page=100&page={page}" - ) - batch = github_request("GET", url, token) - if not isinstance(batch, list) or not batch: - break - comments.extend(batch) - if len(batch) < 100: - break - page += 1 - return comments diff --git a/scripts/api_md_workflow/create_api_review_pr.js b/scripts/api_md_workflow/create_api_review_pr.js index 9a4d60fdcd0f..03bf125d8a6b 100644 --- a/scripts/api_md_workflow/create_api_review_pr.js +++ b/scripts/api_md_workflow/create_api_review_pr.js @@ -4,18 +4,20 @@ const fs = require("fs"); const os = require("os"); const path = require("path"); const { spawnSync } = require("child_process"); +const { loadAdapter, loadWorkflowConfig } = require("./adapter_config"); const REPO_ROOT = path.resolve(__dirname, "..", ".."); const REMOTE = "origin"; const MAIN_REF = `${REMOTE}/main`; function parseArgs(argv) { + const config = loadWorkflowConfig(); const args = { packageName: null, base: null, target: null, - adapter: process.env.API_REVIEW_ADAPTER || "python", - pythonExecutable: process.env.PYTHON || "python", + adapter: config.adapter, + runtimeExecutable: process.env.RUNTIME_EXECUTABLE || null, }; for (let i = 0; i < argv.length; i += 1) { @@ -39,8 +41,8 @@ function parseArgs(argv) { args.target = value; } else if (key === "adapter") { args.adapter = value; - } else if (key === "python") { - args.pythonExecutable = value; + } else if (key === "python" || key === "runtime") { + args.runtimeExecutable = value; } else { throw new Error(`Unknown option: --${key}`); } @@ -328,15 +330,6 @@ function writeBytes(filePath, bytes) { fs.writeFileSync(filePath, bytes); } -function loadAdapter(name) { - const adapterPath = path.join(__dirname, "adapters", `${name}.js`); - if (!fs.existsSync(adapterPath)) { - throw new Error(`ERROR: adapter '${name}' not found at ${adapterPath}`); - } - // eslint-disable-next-line global-require, import/no-dynamic-require - return require(adapterPath); -} - function main() { const args = parseArgs(process.argv.slice(2)); const adapter = loadAdapter(args.adapter); @@ -376,7 +369,7 @@ function main() { packageDir, generateScriptPath: cachedScript, exportScriptPath: cachedExport, - pythonExecutable: args.pythonExecutable, + runtimeExecutable: args.runtimeExecutable, refLabel: currentBranchOrSha(), }); } @@ -390,7 +383,7 @@ function main() { packageDir, generateScriptPath: cachedScript, exportScriptPath: cachedExport, - pythonExecutable: args.pythonExecutable, + runtimeExecutable: args.runtimeExecutable, refLabel: currentBranchOrSha(), }); diff --git a/scripts/api_md_workflow/find_affected.js b/scripts/api_md_workflow/find_affected.js new file mode 100644 index 000000000000..54e71f5967fe --- /dev/null +++ b/scripts/api_md_workflow/find_affected.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +const { + REPO_ROOT, + appendGithubOutput, + envPath, + requireEnv, + run, + writeLines, +} = require("./common"); +const { loadAdapter, loadWorkflowConfig } = require("./adapter_config"); + +function main() { + const config = loadWorkflowConfig(); + const adapterName = config.adapter; + const adapter = loadAdapter(adapterName); + if (typeof adapter.isPackageDir !== "function") { + throw new Error(`ERROR: adapter '${adapterName}' does not implement isPackageDir(repoRoot, packageDirRelative).`); + } + + const baseRef = requireEnv("API_MD_BASE_REF"); + const packagesFile = envPath("API_MD_PACKAGES_FILE", ".artifacts/affected_package_dirs.txt"); + const changedFile = envPath("API_MD_CHANGED_FILE", ".artifacts/changed_package_dirs.txt"); + + run("git", ["fetch", "--no-tags", "--depth=1", "origin", baseRef]); + const diff = run("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`], { + capture: true, + }).stdout; + + const changedDirs = new Set(); + for (const filePath of diff.split(/\r?\n/)) { + const trimmed = filePath.trim(); + if (!trimmed) { + continue; + } + + const parts = trimmed.split("/"); + if (parts.length < 3 || parts[0] !== "sdk") { + continue; + } + + changedDirs.add(parts.slice(0, 3).join("/")); + } + + const sortedChanged = [...changedDirs].sort(); + writeLines(changedFile, sortedChanged); + + const affected = []; + for (const packageDir of sortedChanged) { + if (adapter.isPackageDir(REPO_ROOT, packageDir)) { + affected.push(packageDir); + } + } + + writeLines(packagesFile, affected); + appendGithubOutput("count", affected.length); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/scripts/api_md_workflow/find_affected.py b/scripts/api_md_workflow/find_affected.py deleted file mode 100644 index dfad9272397b..000000000000 --- a/scripts/api_md_workflow/find_affected.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python - -from __future__ import annotations - -from common import append_github_output, env_path, require_env, run, write_lines, REPO_ROOT - - -def main() -> int: - base_ref = require_env("API_MD_BASE_REF") - packages_file = env_path("API_MD_PACKAGES_FILE", ".artifacts/affected_package_dirs.txt") - changed_file = env_path("API_MD_CHANGED_FILE", ".artifacts/changed_package_dirs.txt") - - run(["git", "fetch", "--no-tags", "--depth=1", "origin", base_ref]) - diff = run(["git", "diff", "--name-only", f"origin/{base_ref}...HEAD"], capture=True).stdout - - changed_dirs: set[str] = set() - for file_path in diff.splitlines(): - parts = file_path.strip().split("/") - if len(parts) < 3 or parts[0] != "sdk": - continue - changed_dirs.add("/".join(parts[:3])) - - write_lines(changed_file, sorted(changed_dirs)) - - affected: list[str] = [] - for package_dir in sorted(changed_dirs): - pkg_path = REPO_ROOT / package_dir - if (pkg_path / "pyproject.toml").exists() or (pkg_path / "setup.py").exists(): - affected.append(package_dir) - - write_lines(packages_file, affected) - append_github_output("count", len(affected)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/api_md_workflow/find_mismatches.js b/scripts/api_md_workflow/find_mismatches.js new file mode 100644 index 000000000000..fc2ab9dfe865 --- /dev/null +++ b/scripts/api_md_workflow/find_mismatches.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node + +const fs = require("fs"); + +const { appendGithubOutput, envPath, readLines, run, writeLines } = require("./common"); + +function main() { + const packagesFile = envPath("API_MD_PACKAGES_FILE", ".artifacts/affected_package_dirs.txt"); + const mismatchesFile = envPath("API_MD_MISMATCHES_FILE", ".artifacts/mismatched_api_files.txt"); + const missingFile = envPath("API_MD_MISSING_FILE", ".artifacts/missing_api_files.txt"); + const packages = readLines(packagesFile); + + const mismatches = []; + const missing = []; + for (const pkgDir of packages) { + const apiFile = `${pkgDir}/API.md`; + + // Enforce that each affected package has a committed API.md file. + if (!fs.existsSync(apiFile) || !fs.statSync(apiFile).isFile()) { + missing.push(apiFile); + continue; + } + + const trackedResult = run("git", ["ls-files", "--error-unmatch", "--", apiFile], { + check: false, + }); + if (trackedResult.status !== 0) { + missing.push(apiFile); + continue; + } + + const diffResult = run("git", ["diff", "--quiet", "--", apiFile], { + check: false, + }); + if (diffResult.status !== 0) { + mismatches.push(apiFile); + } + } + + writeLines(mismatchesFile, mismatches); + writeLines(missingFile, missing); + appendGithubOutput("mismatch_count", mismatches.length); + appendGithubOutput("missing_count", missing.length); + appendGithubOutput("issue_count", mismatches.length + missing.length); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/scripts/api_md_workflow/find_mismatches.py b/scripts/api_md_workflow/find_mismatches.py deleted file mode 100644 index e4ad385889af..000000000000 --- a/scripts/api_md_workflow/find_mismatches.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python - -from __future__ import annotations - -from pathlib import Path - -from common import append_github_output, env_path, read_lines, run, write_lines - - -def main() -> int: - packages_file = env_path("API_MD_PACKAGES_FILE", ".artifacts/affected_package_dirs.txt") - out_file = env_path("API_MD_MISMATCHES_FILE", ".artifacts/mismatched_api_files.txt") - packages = read_lines(packages_file) - - mismatches: list[str] = [] - for pkg_dir in packages: - api_file = f"{pkg_dir}/API.md" - api_path = Path(api_file) - - # Enforce that each affected package has a committed API.md file. - if not api_path.is_file(): - mismatches.append(api_file) - continue - - tracked_result = run(["git", "ls-files", "--error-unmatch", "--", api_file], check=False) - if tracked_result.returncode != 0: - mismatches.append(api_file) - continue - - diff_result = run(["git", "diff", "--quiet", "--", api_file], check=False) - if diff_result.returncode != 0: - mismatches.append(api_file) - - write_lines(out_file, mismatches) - append_github_output("mismatch_count", len(mismatches)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/api_md_workflow/post_comment.py b/scripts/api_md_workflow/post_comment.py deleted file mode 100644 index 50d51b18bd16..000000000000 --- a/scripts/api_md_workflow/post_comment.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python - -from __future__ import annotations - -import json -import os - -from common import DEFAULT_CONSISTENCY_MARKER, env_path, github_request, list_comments - - -def main() -> int: - comment_file = env_path("COMMENT_FILE", ".artifacts/comment/comment.json") - payload = json.loads(comment_file.read_text(encoding="utf-8")) - - marker = payload.get("marker") or os.environ.get("DEFAULT_MARKER") or DEFAULT_CONSISTENCY_MARKER - default_pr = int(os.environ.get("DEFAULT_PR_NUMBER", "0") or "0") - pr_number = int(payload.get("pr_number") or default_pr) - if pr_number <= 0: - raise ValueError("No PR number available for comment publishing.") - - token = os.environ.get("GITHUB_TOKEN") - if not token: - raise ValueError("GITHUB_TOKEN is required to post comments.") - - repo_full_name = os.environ.get("GITHUB_REPOSITORY", "") - if "/" not in repo_full_name: - raise ValueError("GITHUB_REPOSITORY is missing or invalid.") - owner, repo = repo_full_name.split("/", 1) - - body = f"{marker}\n{payload.get('body') or 'API.md consistency finished.'}" - - comments = list_comments(owner, repo, pr_number, token) - existing = None - for comment in comments: - user = comment.get("user") or {} - comment_body = comment.get("body") or "" - if user.get("type") == "Bot" and marker in comment_body: - existing = comment - break - - if existing: - update_url = f"https://api.github.com/repos/{owner}/{repo}/issues/comments/{existing['id']}" - github_request("PATCH", update_url, token, {"body": body}) - print(f"Updated comment on PR #{pr_number}") - else: - create_url = f"https://api.github.com/repos/{owner}/{repo}/issues/{pr_number}/comments" - github_request("POST", create_url, token, {"body": body}) - print(f"Created comment on PR #{pr_number}") - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/api_md_workflow/regenerate.js b/scripts/api_md_workflow/regenerate.js new file mode 100644 index 000000000000..1954152f3b31 --- /dev/null +++ b/scripts/api_md_workflow/regenerate.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node + +const path = require("path"); + +const { REPO_ROOT, envPath, readLines } = require("./common"); +const { loadAdapter, loadWorkflowConfig } = require("./adapter_config"); + +function main() { + const config = loadWorkflowConfig(); + const adapter = loadAdapter(config.adapter); + if (typeof adapter.generateApiForPackage !== "function") { + throw new Error( + `ERROR: adapter '${config.adapter}' does not implement generateApiForPackage({ repoRoot, packageName, runtimeExecutable }).`, + ); + } + + const packagesFile = envPath("API_MD_PACKAGES_FILE", ".artifacts/affected_package_dirs.txt"); + const packages = readLines(packagesFile); + if (!packages.length) { + return; + } + + const runtimeExecutable = process.env.RUNTIME_EXECUTABLE || null; + for (const pkgDir of packages) { + const packageName = path.basename(pkgDir); + console.log(`Generating API.md for ${packageName}`); + adapter.generateApiForPackage({ + repoRoot: REPO_ROOT, + packageName, + runtimeExecutable, + }); + } +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/scripts/api_md_workflow/regenerate.py b/scripts/api_md_workflow/regenerate.py deleted file mode 100644 index 0b956ab376b4..000000000000 --- a/scripts/api_md_workflow/regenerate.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python - -from __future__ import annotations - -import sys -from pathlib import Path - -from common import GENERATE_API_SCRIPT, env_path, read_lines, run - - -def main() -> int: - packages_file = env_path("API_MD_PACKAGES_FILE", ".artifacts/affected_package_dirs.txt") - packages = read_lines(packages_file) - if not packages: - return 0 - - for pkg_dir in packages: - package_name = Path(pkg_dir).name - print(f"Generating API.md for {package_name}") - run([sys.executable, str(GENERATE_API_SCRIPT), package_name]) - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From 323d4f36b462b434a66a2e4b1501e7ba22c6faf4 Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Tue, 2 Jun 2026 15:01:56 -0700 Subject: [PATCH 07/33] Add minor change to azure-template to test pipeline. --- sdk/template/azure-template/README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sdk/template/azure-template/README.md b/sdk/template/azure-template/README.md index 4e7b3cc44531..c66dcd0c93c8 100644 --- a/sdk/template/azure-template/README.md +++ b/sdk/template/azure-template/README.md @@ -26,10 +26,8 @@ Running into issues? This section should contain details as to what to do there. # Next steps -More sample code should go here, along with links out to the appropriate example tests. +More sample code should go here, along with links out to the appropriate example tests. And more. # Contributing If you encounter any bugs or have suggestions, please file an issue in the [Issues]() section of the project. - - From 93184afd2630cd9d98dcab92bf969cbfa26d0195 Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Tue, 2 Jun 2026 15:11:14 -0700 Subject: [PATCH 08/33] Add API.md to test mismatch. --- sdk/template/azure-template/API.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 sdk/template/azure-template/API.md diff --git a/sdk/template/azure-template/API.md b/sdk/template/azure-template/API.md new file mode 100644 index 000000000000..086f47de5b0a --- /dev/null +++ b/sdk/template/azure-template/API.md @@ -0,0 +1,9 @@ +```py +# Package is parsed using apiview-stub-generator(version:0.3.28), Python version: 3.12.9 + + +namespace azure.template + + def azure.template.template_main() -> bool: ... + +``` \ No newline at end of file From 7680ff67ad1426f815a2fdbeccc6e8ee7cf27607 Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Tue, 2 Jun 2026 15:15:04 -0700 Subject: [PATCH 09/33] Apply actual api.md --- sdk/template/azure-template/API.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sdk/template/azure-template/API.md b/sdk/template/azure-template/API.md index 086f47de5b0a..eaac5a5b33d1 100644 --- a/sdk/template/azure-template/API.md +++ b/sdk/template/azure-template/API.md @@ -6,4 +6,10 @@ namespace azure.template def azure.template.template_main() -> bool: ... + +namespace azure.template.template_code + + def azure.template.template_code.template_main() -> bool: ... + + ``` \ No newline at end of file From 33c39cfd6265b89d41a5860268ca653086c016ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:22:58 +0000 Subject: [PATCH 10/33] Update GitHub Actions to latest major versions Co-authored-by: mikeharder <9459391+mikeharder@users.noreply.github.com> --- .github/workflows/consistency.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/consistency.yml b/.github/workflows/consistency.yml index c7a662860f3a..11cf738a769f 100644 --- a/.github/workflows/consistency.yml +++ b/.github/workflows/consistency.yml @@ -25,12 +25,12 @@ jobs: fetch-depth: 0 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" - name: Setup Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: "20" From 15534d7a0cca88f1c9dadc991223b759d73be7e8 Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Wed, 3 Jun 2026 09:15:32 -0700 Subject: [PATCH 11/33] Add shared JS scripts from rest-api-specs repo. --- .github/shared/package.json | 76 +++++++ .github/shared/src/cache.js | 61 ++++++ .github/shared/src/changed-files.js | 313 ++++++++++++++++++++++++++++ .github/shared/src/exec.js | 134 ++++++++++++ .github/shared/src/github.js | 102 +++++++++ .github/shared/src/logger.js | 64 ++++++ .github/shared/src/path.js | 104 +++++++++ .github/shared/src/simple-git.js | 13 ++ sdk/template/azure-template/API.md | 15 -- 9 files changed, 867 insertions(+), 15 deletions(-) create mode 100644 .github/shared/package.json create mode 100644 .github/shared/src/cache.js create mode 100644 .github/shared/src/changed-files.js create mode 100644 .github/shared/src/exec.js create mode 100644 .github/shared/src/github.js create mode 100644 .github/shared/src/logger.js create mode 100644 .github/shared/src/path.js create mode 100644 .github/shared/src/simple-git.js delete mode 100644 sdk/template/azure-template/API.md diff --git a/.github/shared/package.json b/.github/shared/package.json new file mode 100644 index 000000000000..df75018baf14 --- /dev/null +++ b/.github/shared/package.json @@ -0,0 +1,76 @@ +{ + "name": "@azure-tools/specs-shared", + "private": true, + "type": "module", + "exports": { + "./array": "./src/array.js", + "./breaking-change": "./src/breaking-change.js", + "./changed-files": "./src/changed-files.js", + "./console": "./src/console.js", + "./eslint-base-config": "./eslint.base.config.js", + "./exec": "./src/exec.js", + "./git": "./src/git.js", + "./github": "./src/github.js", + "./logger": "./src/logger.js", + "./math": "./src/math.js", + "./path": "./src/path.js", + "./readme": "./src/readme.js", + "./sdk-types": "./src/sdk-types.js", + "./set": "./src/set.js", + "./simple-git": "./src/simple-git.js", + "./sleep": "./src/sleep.js", + "./sort": "./src/sort.js", + "./spec-model-error": "./src/spec-model-error.js", + "./spec-model": "./src/spec-model.js", + "./swagger": "./src/swagger.js", + "./tag": "./src/tag.js", + "./time": "./src/time.js", + "./test/examples": "./test/examples.js" + }, + "bin": { + "spec-model": "./cmd/spec-model.js" + }, + "_comments": { + "dependencies": "Runtime dependencies must be kept to an absolute minimum for performance, ideally with no transitive dependencies", + "dependencies2": "All runtime and dev dependencies in this file, must be a subset of ../package.json" + }, + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^15.1.3", + "debug": "^4.4.3", + "js-yaml": "^4.1.0", + "marked": "^18.0.0", + "simple-git": "^3.36.0", + "zod": "^4.3.5" + }, + "devDependencies": { + "@eslint/js": "^10.0.0", + "@tsconfig/node20": "^20.1.4", + "@types/debug": "^4.1.12", + "@types/js-yaml": "^4.0.9", + "@types/node": "^20.0.0", + "@types/semver": "^7.7.1", + "@vitest/coverage-v8": "^4.1.0", + "cross-env": "^10.1.0", + "eslint": "^10.0.0", + "globals": "^17.0.0", + "prettier": "3.8.3", + "prettier-plugin-organize-imports": "^4.2.0", + "semver": "^7.7.1", + "tinybench": "^6.0.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.0", + "vitest": "^4.1.0" + }, + "scripts": { + "check": "npm run test:ci && npm run lint && npm run format:check", + "lint": "npm run lint:eslint && npm run lint:tsc", + "lint:eslint": "cross-env DEBUG=eslint:eslint,eslint:linter eslint", + "lint:tsc": "tsc --build --verbose", + "format": "prettier . --ignore-path ../.prettierignore --write", + "format:check": "prettier . --ignore-path ../.prettierignore --check", + "format:check:ci": "prettier . --ignore-path ../.prettierignore --check --log-level debug", + "perf": "node perf/perf.js", + "test": "vitest", + "test:ci": "vitest run --coverage --reporter=verbose" + } +} diff --git a/.github/shared/src/cache.js b/.github/shared/src/cache.js new file mode 100644 index 000000000000..ec54ede7b399 --- /dev/null +++ b/.github/shared/src/cache.js @@ -0,0 +1,61 @@ +/** + * Caches values in memory with a single key of any type. + * + * @template K, V + */ +export class KeyedCache { + /** @type {Map} */ + #map = new Map(); + + /** + * Returns cached value, initializing if necessary + * + * @param {K} key + * @param {() => V} factory + * @returns {V} cached value + * + * @example + * const result = cache.getOrCreate(42, async () => await doWork(42)); + */ + getOrCreate(key, factory) { + let value = this.#map.get(key); + + if (value === undefined) { + value = factory(); + this.#map.set(key, value); + } + + return value; + } +} + +/** + * Caches values in memory with an ordered pair of keys of any types. + * + * @template K1, K2, V + */ +export class KeyedPairCache { + // Two-layer nested cache + /** @type {KeyedCache>} */ + #cache1 = new KeyedCache(); + + /** + * Returns cached value, initializing if necessary. + * Keys are ordered, so (key1, key2) != (key2, key1). + * + * @param {K1} key1 + * @param {K2} key2 + * @param {() => V} factory + * @returns {V} cached value + * + * @example + * const result = cache.getOrCreate(42, 7 async () => await doWork(42, 7)); + */ + getOrCreate(key1, key2, factory) { + // key1 => cache for the next layer + const cache2 = this.#cache1.getOrCreate(key1, () => new KeyedCache()); + + // key2 => final value + return cache2.getOrCreate(key2, factory); + } +} diff --git a/.github/shared/src/changed-files.js b/.github/shared/src/changed-files.js new file mode 100644 index 000000000000..f167e7056680 --- /dev/null +++ b/.github/shared/src/changed-files.js @@ -0,0 +1,313 @@ +import debug from "debug"; +import { simpleGit } from "simple-git"; +import { KeyedCache } from "./cache.js"; +import { includesSegment } from "./path.js"; + +// Enable simple-git debug logging to improve console output +debug.enable("simple-git"); + +// Cache results of the `example` filter, using the un-resolved path for maximum perf +// The `example` filter is a hot path in spec-model for large specs like "network". +/** @type {KeyedCache} */ +const exampleCache = new KeyedCache(); + +/** + * Get a list of changed files in a git repository + * + * @param {Object} [options] + * @param {string} [options.baseCommitish] Default: "HEAD^". + * @param {string} [options.cwd] Current working directory. Default: process.cwd(). + * @param {string[]} [options.gitOptions] Additional git options to pass to git diff command. Example: ["--no-renames"]. Default: [] + * @param {string} [options.headCommitish] Default: "HEAD". + * @param {import('./logger.js').ILogger} [options.logger] + * @param {string[]} [options.paths] Limits the diff to the named paths. If not set, includes all paths in repo. Default: [] + * @returns {Promise} List of changed files, using posix paths, relative to repo root. Example: ["specification/foo/Microsoft.Foo/main.tsp"]. + */ +export async function getChangedFiles(options = {}) { + const { + baseCommitish = "HEAD^", + cwd, + gitOptions = [], + headCommitish = "HEAD", + logger, + paths = [], + } = options; + + if (paths.length > 0) { + // Use "--" to separate paths from revisions + paths.unshift("--"); + } + + // TODO: If we need to filter based on status, instead of passing an argument to `--diff-filter, + // consider using "--name-status" instead of "--name-only", and return an array of objects like + // { name: "/foo/baz.js", status: Status.Renamed, previousName: "/foo/bar.js"}. + // Then add filter functions to filter based on status. This is more flexible and lets consumers + // filter based on status with a single call to `git diff`. + const result = await simpleGit(cwd).diff([ + "--name-only", + ...gitOptions, + baseCommitish, + headCommitish, + ...paths, + ]); + + const files = result + .trim() + .split("\n") + // ignore empty lines (e.g. when no files are changed) + .filter((s) => s.length > 0); + logger?.info("Changed Files:"); + for (const file of files) { + logger?.info(` ${file}`); + } + logger?.info(""); + + return files; +} + +/** + * Get a list of changed files in a git repository with statuses for additions, + * modifications, deletions, and renames. Warning: rename behavior can vary + * based on the git client's configuration of diff.renames. + * + * @param {Object} [options] + * @param {string} [options.baseCommitish] Default: "HEAD^". + * @param {string} [options.cwd] Current working directory. Default: process.cwd(). + * @param {string[]} [options.gitOptions] Additional git options to pass to git diff command. Example: ["--no-renames"]. Default: [] + * @param {string} [options.headCommitish] Default: "HEAD". + * @param {import('./logger.js').ILogger} [options.logger] + * @param {string[]} [options.paths] Limits the diff to the named paths. If not set, includes all paths in repo. Default: [] + * @returns {Promise<{additions: string[], modifications: string[], deletions: string[], renames: {from: string, to: string}[], total: number}>} + */ +export async function getChangedFilesStatuses(options = {}) { + const { + baseCommitish = "HEAD^", + cwd, + gitOptions = [], + headCommitish = "HEAD", + logger, + paths = [], + } = options; + + if (paths.length > 0) { + // Use "--" to separate paths from revisions + paths.unshift("--"); + } + + const result = await simpleGit(cwd).diff([ + "--name-status", + ...gitOptions, + baseCommitish, + headCommitish, + ...paths, + ]); + + const categorizedFiles = { + additions: /** @type {string[]} */ ([]), + modifications: /** @type {string[]} */ ([]), + deletions: /** @type {string[]} */ ([]), + renames: /** @type {{from: string, to: string}[]} */ ([]), + total: 0, + }; + + if (result.trim()) { + const lines = result.trim().split("\n"); + + for (const line of lines) { + const parts = line.split("\t"); + const status = parts[0]; + + switch (status[0]) { + case "A": + categorizedFiles.additions.push(parts[1]); + break; + case "M": + categorizedFiles.modifications.push(parts[1]); + break; + case "D": + categorizedFiles.deletions.push(parts[1]); + break; + case "R": + categorizedFiles.renames.push({ + from: parts[1], + to: parts[2], + }); + break; + case "C": + categorizedFiles.additions.push(parts[2]); + break; + default: + categorizedFiles.modifications.push(parts[1]); + } + } + + categorizedFiles.total = + categorizedFiles.additions.length + + categorizedFiles.modifications.length + + categorizedFiles.deletions.length + + categorizedFiles.renames.length; + } + + // Log all changed files by categories + if (logger) { + logger.info("Categorized Changed Files:"); + + if (categorizedFiles.additions.length > 0) { + logger.info(` Additions (${categorizedFiles.additions.length}):`); + for (const file of categorizedFiles.additions) { + logger.info(` + ${file}`); + } + } + + if (categorizedFiles.modifications.length > 0) { + logger.info(` Modifications (${categorizedFiles.modifications.length}):`); + for (const file of categorizedFiles.modifications) { + logger.info(` M ${file}`); + } + } + + if (categorizedFiles.deletions.length > 0) { + logger.info(` Deletions (${categorizedFiles.deletions.length}):`); + for (const file of categorizedFiles.deletions) { + logger.info(` - ${file}`); + } + } + + if (categorizedFiles.renames.length > 0) { + logger.info(` Renames (${categorizedFiles.renames.length}):`); + for (const rename of categorizedFiles.renames) { + logger.info(` R ${rename.from} -> ${rename.to}`); + } + } + + logger.info(` Total: ${categorizedFiles.total} files`); + logger.info(""); + } + + return categorizedFiles; +} + +// Functions suitable for passing to string[].filter(), ordered roughly in order of increasing specificity +// Functions accept both relative and absolute paths, since paths are resolve()'d before searching (when needed) + +/** + * @param {string} [file] + * @returns {boolean} + */ +export function json(file) { + // Extension "json" with any case is a valid JSON file + return typeof file === "string" && file.toLowerCase().endsWith(".json"); +} + +/** + * @param {string} [file] + * @returns {boolean} + */ +export function markdown(file) { + // Extension ".md" with any case is a valid markdown file + return typeof file === "string" && file.toLowerCase().endsWith(".md"); +} + +/** + * @param {string} [file] + * @returns {boolean} + */ +export function readme(file) { + // Filename "readme.md" with any case is a valid README file + return typeof file === "string" && file.toLowerCase().endsWith("readme.md"); +} + +/** + * @param {string} [file] + * @returns {boolean} + */ +export function dataPlane(file) { + // Folder name "data-plane" should match case for consistency across specs + return typeof file === "string" && includesSegment(file, "data-plane"); +} + +/** + * @param {string} [file] + * @returns {boolean} + */ +export function resourceManager(file) { + // Folder name "resource-manager" should match case for consistency across specs + return typeof file === "string" && includesSegment(file, "resource-manager"); +} + +/** + * @param {string} [file] + * @returns {boolean} + */ +export function preview(file) { + // Folder name "preview" should match case for consistency across specs + return typeof file === "string" && includesSegment(file, "preview"); +} + +/** + * @param {string} [file] + * @returns {boolean} + */ +export function stable(file) { + // Folder name "stable" should match case for consistency across specs + return typeof file === "string" && includesSegment(file, "stable"); +} + +/** + * @param {string} [file] + * @returns {boolean} + */ +export function example(file) { + return ( + typeof file === "string" && + // Intentionally use un-resolved path as key for perf, since we are OK + // caching the same result for different representations of the same path. + exampleCache.getOrCreate( + file, + // Folder name "examples" should match case for consistency across specs + () => json(file) && includesSegment(file, "examples"), + ) + ); +} + +/** + * @param {string} file + * @returns {boolean} + */ +export function typespec(file) { + return ( + typeof file === "string" && + (file.toLowerCase().endsWith(".tsp") || file.toLowerCase().endsWith("tspconfig.yaml")) + ); +} + +/** + * @param {string} [file] + * @returns {boolean} + */ +export function quickstartTemplate(file) { + return typeof file === "string" && json(file) && file.includes("/quickstart-templates/"); +} + +/** + * @param {string} [file] + * @returns {boolean} + */ +export function swagger(file) { + return ( + typeof file === "string" && + json(file) && + (dataPlane(file) || resourceManager(file)) && + !example(file) && + !quickstartTemplate(file) && + !scenario(file) + ); +} + +/** + * @param {string} [file] + * @returns {boolean} + */ +export function scenario(file) { + return typeof file === "string" && json(file) && includesSegment(file, "scenarios"); +} diff --git a/.github/shared/src/exec.js b/.github/shared/src/exec.js new file mode 100644 index 000000000000..aba68ea0cf25 --- /dev/null +++ b/.github/shared/src/exec.js @@ -0,0 +1,134 @@ +import child_process from "child_process"; +import { dirname, join } from "path"; +import { promisify } from "util"; +const execFileImpl = promisify(child_process.execFile); + +/** + * @typedef {Object} ExecOptions + * @property {string} [cwd] Current working directory. Default: process.cwd(). + * @property {import('./logger.js').ILogger} [logger] + * @property {number} [maxBuffer] Max bytes allowed on stdout or stderr. Default: 16 * 1024 * 1024. + */ + +/** + * @typedef {Object} NpmPrefixOptions + * @property {string} [prefix] Prefix to pass to npm via "--prefix". + */ + +/** + * @typedef {ExecOptions & NpmPrefixOptions} ExecNpmOptions + */ + +/** + * @typedef {Object} ExecResult + * @property {string} stdout + * @property {string} stderr + */ + +/** + * @typedef {Error & { stdout?: string, stderr?: string, code?: number }} ExecError + */ + +/** + * Checks whether an unknown error object is an ExecError. + * @param {unknown} error + * @returns {error is ExecError} + */ +export function isExecError(error) { + if (!(error instanceof Error)) return false; + + const e = /** @type {ExecError} */ (error); + return typeof e.stdout === "string" || typeof e.stderr === "string"; +} + +/** + * Wraps `child_process.execFile()`, adding logging and a larger default maxBuffer. + * + * @param {string} file + * @param {string[]} [args] + * @param {ExecOptions} [options] + * @returns {Promise} + * @throws {ExecError} + */ +export async function execFile(file, args, options = {}) { + const { + cwd, + logger, + // Node default is 1024 * 1024, which is too small for some git commands returning many entities or large file content. + // To support "git show", should be larger than the largest swagger file in the repo (2.5 MB as of 2/28/2025). + maxBuffer = 16 * 1024 * 1024, + } = options; + + logger?.info(`execFile("${file}", ${JSON.stringify(args)})`); + + try { + // execFile(file, args) is more secure than exec(cmd), since the latter is vulnerable to shell injection + const result = await execFileImpl(file, args, { + cwd, + maxBuffer, + }); + + logger?.debug(`stdout: '${result.stdout}'`); + logger?.debug(`stderr: '${result.stderr}'`); + + return result; + } catch (error) { + /* v8 ignore next */ + logger?.debug(`error: '${JSON.stringify(error)}'`); + + throw error; + } +} + +/** + * Calls `execFile()` with appropriate arguments to run `npm` on all platforms + * + * @param {string[]} args + * @param {ExecNpmOptions} [options] + * @returns {Promise} + * @throws {ExecError} + */ +export async function execNpm(args, options = {}) { + const { prefix } = options; + + // Exclude platform-specific code from coverage + /* v8 ignore start */ + const { file, defaultArgs } = + process.platform === "win32" + ? { + // Only way I could find to run "npm" on Windows, without using the shell (e.g. "cmd /c npm ...") + // + // "node.exe", ["--", "npm-cli.js", ...args] + // + // The "--" MUST come BEFORE "npm-cli.js", to ensure args are sent to the script unchanged. + // If the "--" comes after "npm-cli.js", the args sent to the script will be ["--", ...args], + // which is NOT equivalent, and can break if args itself contains another "--". + + // example: "C:\Program Files\nodejs\node.exe" + file: process.execPath, + + // example: "C:\Program Files\nodejs\node_modules\npm\bin\npm-cli.js" + defaultArgs: [ + "--", + join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"), + ], + } + : { file: "npm", defaultArgs: [] }; + /* v8 ignore stop */ + + const prefixArgs = prefix ? ["--prefix", prefix] : []; + + return await execFile(file, [...defaultArgs, ...prefixArgs, ...args], options); +} + +/** + * Calls `execNpm()` with arguments ["exec", "--no", "--"] prepended. + * + * @param {string[]} args + * @param {ExecNpmOptions} [options] + * @returns {Promise} + * @throws {ExecError} + */ +export async function execNpmExec(args, options = {}) { + return await execNpm(["exec", "--no", "--", ...args], options); +} diff --git a/.github/shared/src/github.js b/.github/shared/src/github.js new file mode 100644 index 000000000000..501a512810da --- /dev/null +++ b/.github/shared/src/github.js @@ -0,0 +1,102 @@ +/* v8 ignore start */ + +export const PER_PAGE_MAX = 100; + +/** + * https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/about-status-checks#check-statuses-and-conclusions + * + * @readonly + * @enum {"completed" | "expected" | "failure" | "in_progress" | "pending" | "queued" | "requested" | "startup_failure" | "waiting" } + */ +export const CheckStatus = Object.freeze({ + /** + * @description The check run completed and has a conclusion. + */ + COMPLETED: "completed", + /** + * @description The check run is waiting for a status to be reported. + */ + EXPECTED: "expected", + /** + * @description The check run failed. + */ + FAILURE: "failure", + /** + * @description The check run is in progress. + */ + IN_PROGRESS: "in_progress", + /** + * @description The check run is at the front of the queue but the group-based concurrency limit has been reached. + */ + PENDING: "pending", + /** + * @description The check run has been queued. + */ + QUEUED: "queued", + /** + * @description The check run has been created but has not been queued. + */ + REQUESTED: "requested", + /** + * @description The check suite failed during startup. This status is not applicable to check runs. + */ + STARTUP_FAILURE: "startup_failure", + /** + * @description The check run is waiting for a deployment protection rule to be satisfied. + */ + WAITING: "waiting", +}); + +/** + * https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/about-status-checks#check-statuses-and-conclusions + * + * @readonly + * @enum {"action_required" | "cancelled" | "failure" | "neutral" | "skipped" | "stale" | "success" | "timed_out" } + */ +export const CheckConclusion = Object.freeze({ + /** + * @description The check run provided required actions upon its completion. For more information, see Using the REST API to interact with checks. + */ + ACTION_REQUIRED: "action_required", + /** + * @description The check run was cancelled before it completed. + */ + CANCELLED: "cancelled", + /** + * @description The check run failed. + */ + FAILURE: "failure", + /** + * @description The check run completed with a neutral result. This is treated as a success for dependent checks in GitHub Actions. + */ + NEUTRAL: "neutral", + /** + * @description The check run was skipped. This is treated as a success for dependent checks in GitHub Actions. + */ + SKIPPED: "skipped", + /** + * @description The check run was marked stale by GitHub because it took too long. + */ + STALE: "stale", + /** + * @description The check run completed successfully. + */ + SUCCESS: "success", + /** + * @description The check run timed out. + */ + TIMED_OUT: "timed_out", +}); + +/** + * https://docs.github.com/en/rest/commits/statuses?apiVersion=2022-11-28#create-a-commit-status--parameters + * + * @readonly + * @enum {"error" | "failure" | "pending" | "success"} + */ +export const CommitStatusState = Object.freeze({ + ERROR: "error", + FAILURE: "failure", + PENDING: "pending", + SUCCESS: "success", +}); diff --git a/.github/shared/src/logger.js b/.github/shared/src/logger.js new file mode 100644 index 000000000000..dc43a06d9782 --- /dev/null +++ b/.github/shared/src/logger.js @@ -0,0 +1,64 @@ +/** + * @typedef {Object} ILogger + * @property {(message:string) => void} debug + * @property {(message:string) => void} error + * @property {(message:string) => void} info + * @property {(message:string) => void} warning + * @property {() => boolean} isDebug + */ + +/** + * @implements {ILogger} + */ +export class ConsoleLogger { + /** @type {boolean} */ + #isDebug; + + /** + * @param {boolean} [isDebug] - If true, debug logs will be printed. Default: false. + */ + constructor(isDebug = false) { + this.#isDebug = isDebug; + } + + /** + * @param {string} message + */ + debug(message) { + if (this.isDebug()) { + console.debug(message); + } + } + + /** + * @param {string} message + */ + error(message) { + console.error(message); + } + + /** + * @param {string} message + */ + info(message) { + console.log(message); + } + + /** + * @returns {boolean} + */ + isDebug() { + return this.#isDebug; + } + + /** + * @param {string} message + */ + warning(message) { + console.warn(message); + } +} + +// Singleton loggers +export const defaultLogger = new ConsoleLogger(); +export const debugLogger = new ConsoleLogger(/*isDebug*/ true); diff --git a/.github/shared/src/path.js b/.github/shared/src/path.js new file mode 100644 index 000000000000..929116c931b6 --- /dev/null +++ b/.github/shared/src/path.js @@ -0,0 +1,104 @@ +import { basename, dirname, resolve } from "path"; + +import { KeyedCache, KeyedPairCache } from "./cache.js"; + +/** @type {KeyedCache} */ +const resolveCache = new KeyedCache(); + +/** @type {KeyedPairCache} */ +const resolvePairCache = new KeyedPairCache(); + +/** + * + * @param {string} path Absolute or relative path + * @param {string} segment File or folder + * @returns {boolean} True if resolved path contains segment + * + * @example + * includesSegment("stable/2025-01-01/examples/foo.json", "examples") + * // -> true + */ +export function includesSegment(path, segment) { + return untilLastSegment(path, segment) !== ""; +} + +/** + * Wraps `path.resolve(path)` with a cache to improve performance + * + * @param {string} path + * @returns {string} + */ +export function resolveCached(path) { + return resolveCache.getOrCreate(path, () => resolve(path)); +} + +/** + * Wraps `path.resolve(from, to)` with a cache to improve performance + +* @param {string} from + * @param {string} to + * @returns {string} + */ +export function resolvePairCached(from, to) { + return resolvePairCache.getOrCreate(from, to, () => resolve(from, to)); +} + +/** + * @param {string} path Absolute or relative path + * @param {string} segment File or folder + * @returns {string} Portion of resolved path up to (and including) the last occurrence of segment + * + * @example + * untilLastSegment("stable/2025-01-01/examples/foo.json", "examples") + * // -> "{cwd}/stable/2025-01-01/examples" + */ +export function untilLastSegment(path, segment) { + // Shares code with `untilLastSegmentWithParent()`, but not worth refactoring yet + + let current = resolveCached(path); + + while (true) { + const parent = dirname(current); + + if (basename(current) === segment) { + // Found the target folder. Return it. + return current; + } else if (parent === current) { + // Reached the filesystem root (folder not found). Return empty string. + return ""; + } else { + // Keep walking upward + current = parent; + } + } +} + +/** + * @param {string} path Absolute or relative path + * @param {string} segment File or folder + * @returns {string} Portion of resolved path up to (and including) the last segment with the specified parent + * + * @example + * untilLastSegmentWithParent("specification/foo/data-plane/stable/2025-01-01/foo.json", "specification") + * // -> "{cwd}/specification/foo" + */ +export function untilLastSegmentWithParent(path, segment) { + // Shares code with `untilLastSegment()`, but not worth refactoring yet + + let current = resolveCached(path); + + while (true) { + const parent = dirname(current); + + if (basename(parent) === segment) { + // Found the target parent. Return current; + return current; + } else if (parent === current) { + // Reached the filesystem root (folder not found). Return empty string. + return ""; + } else { + // Keep walking upward + current = parent; + } + } +} diff --git a/.github/shared/src/simple-git.js b/.github/shared/src/simple-git.js new file mode 100644 index 000000000000..c8241c839e35 --- /dev/null +++ b/.github/shared/src/simple-git.js @@ -0,0 +1,13 @@ +import { resolve } from "path"; +import { simpleGit } from "simple-git"; + +/** + * + * @param {string} inputPath + * @returns {Promise} + */ +export async function getRootFolder(inputPath) { + // expecting users to handle the case where inputPath is not a git repo + const gitRoot = await simpleGit(inputPath).revparse("--show-toplevel"); + return resolve(gitRoot.trim()); +} diff --git a/sdk/template/azure-template/API.md b/sdk/template/azure-template/API.md deleted file mode 100644 index eaac5a5b33d1..000000000000 --- a/sdk/template/azure-template/API.md +++ /dev/null @@ -1,15 +0,0 @@ -```py -# Package is parsed using apiview-stub-generator(version:0.3.28), Python version: 3.12.9 - - -namespace azure.template - - def azure.template.template_main() -> bool: ... - - -namespace azure.template.template_code - - def azure.template.template_code.template_main() -> bool: ... - - -``` \ No newline at end of file From 19f65aca9ba62ac3f38974208318b90f98275eee Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Wed, 3 Jun 2026 09:39:25 -0700 Subject: [PATCH 12/33] Refactor scripts to use shared code and simplify. --- scripts/api_md_workflow/adapters/python.js | 46 ++++------ scripts/api_md_workflow/common.js | 72 +++++++++++---- .../api_md_workflow/create_api_review_pr.js | 90 +++++++++++++++---- scripts/api_md_workflow/find_affected.js | 32 ++++--- scripts/api_md_workflow/find_mismatches.js | 21 +++-- scripts/api_md_workflow/regenerate.js | 17 ++-- 6 files changed, 184 insertions(+), 94 deletions(-) diff --git a/scripts/api_md_workflow/adapters/python.js b/scripts/api_md_workflow/adapters/python.js index 32f74f922323..61325f41f25f 100644 --- a/scripts/api_md_workflow/adapters/python.js +++ b/scripts/api_md_workflow/adapters/python.js @@ -5,8 +5,9 @@ const path = require("path"); const { spawnSync } = require("child_process"); function run(cmd, args, options = {}) { + const logger = options.logger || console; const printable = [cmd, ...args].join(" "); - console.log(`$ ${printable}`); + logger.info(`$ ${printable}`); const result = spawnSync(cmd, args, { cwd: options.cwd, env: options.env, @@ -102,43 +103,35 @@ function readVersion(packageDir) { throw new Error(`ERROR: could not find a version string in ${packageDir}`); } -function generateApiMdBytes({ +function generateApiForPackage({ repoRoot, packageName, - packageDir, + runtimeExecutable, + logger, generateScriptPath, exportScriptPath, - runtimeExecutable, refLabel, }) { const executable = runtimeExecutable || process.env.PYTHON || "python"; - console.log(`--- Generating API.md on ${refLabel} ---`); - const env = { - ...process.env, - AZSDK_REPO_ROOT: repoRoot, - AZSDK_EXPORT_SCRIPT: exportScriptPath, - }; - - run(executable, [generateScriptPath, packageName], { - cwd: repoRoot, - env, - check: true, - }); - - const apiMdPath = path.join(packageDir, "API.md"); - if (!fs.existsSync(apiMdPath)) { - throw new Error(`ERROR: did not produce ${apiMdPath}`); + const activeLogger = logger || console; + const scriptPath = generateScriptPath || path.join(repoRoot, "scripts", "generate_api_text.py"); + if (refLabel) { + activeLogger.info(`--- Generating API.md on ${refLabel} ---`); } - return fs.readFileSync(apiMdPath); -} + const env = exportScriptPath + ? { + ...process.env, + AZSDK_REPO_ROOT: repoRoot, + AZSDK_EXPORT_SCRIPT: exportScriptPath, + } + : undefined; -function generateApiForPackage({ repoRoot, packageName, runtimeExecutable }) { - const executable = runtimeExecutable || process.env.PYTHON || "python"; - const generateScriptPath = path.join(repoRoot, "scripts", "generate_api_text.py"); - run(executable, [generateScriptPath, packageName], { + run(executable, [scriptPath, packageName], { cwd: repoRoot, + env, check: true, + logger: activeLogger, }); } @@ -148,5 +141,4 @@ module.exports = { findPackageDir, readVersion, generateApiForPackage, - generateApiMdBytes, }; diff --git a/scripts/api_md_workflow/common.js b/scripts/api_md_workflow/common.js index 9b60247b7e20..8a97c7c50749 100644 --- a/scripts/api_md_workflow/common.js +++ b/scripts/api_md_workflow/common.js @@ -2,26 +2,60 @@ const fs = require("fs"); const path = require("path"); -const { spawnSync } = require("child_process"); +const { pathToFileURL } = require("url"); const REPO_ROOT = path.resolve(__dirname, "..", ".."); -const DEFAULT_CONSISTENCY_MARKER = ""; -const DEFAULT_APPLY_MARKER = ""; - -function run(cmd, args, options = {}) { - const result = spawnSync(cmd, args, { - check: false, - cwd: options.cwd, - env: options.env, - encoding: "utf-8", - stdio: options.capture ? "pipe" : "inherit", - }); - - if ((options.check ?? true) && result.status !== 0) { - throw new Error(`Command failed (${result.status}): ${[cmd, ...args].join(" ")}`); +const SHARED_SRC_ROOT = path.join(REPO_ROOT, ".github", "shared", "src"); +const sharedModuleCache = new Map(); + +async function loadSharedModule(fileName) { + if (sharedModuleCache.has(fileName)) { + return sharedModuleCache.get(fileName); } - return result; + const filePath = path.join(SHARED_SRC_ROOT, fileName); + const modulePromise = import(pathToFileURL(filePath).href); + sharedModuleCache.set(fileName, modulePromise); + return modulePromise; +} + +async function getDefaultLogger() { + const { defaultLogger } = await loadSharedModule("logger.js"); + return defaultLogger; +} + +async function runAsync(cmd, args, options = {}) { + const { execFile, isExecError } = await loadSharedModule("exec.js"); + const check = options.check ?? true; + const logger = options.logger ?? (await getDefaultLogger()); + + try { + const result = await execFile(cmd, args, { + cwd: options.cwd, + logger, + maxBuffer: options.maxBuffer, + }); + + return { + status: 0, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; + } catch (error) { + if (!isExecError(error)) { + throw error; + } + + const status = Number.isInteger(error.code) ? error.code : 1; + const stdout = error.stdout ?? ""; + const stderr = error.stderr ?? ""; + + if (!check) { + return { status, stdout, stderr }; + } + + throw new Error(`Command failed (${status}): ${[cmd, ...args].join(" ")}`); + } } function readLines(filePath) { @@ -68,9 +102,9 @@ function requireEnv(name) { module.exports = { REPO_ROOT, - DEFAULT_CONSISTENCY_MARKER, - DEFAULT_APPLY_MARKER, - run, + loadSharedModule, + getDefaultLogger, + runAsync, readLines, writeLines, appendGithubOutput, diff --git a/scripts/api_md_workflow/create_api_review_pr.js b/scripts/api_md_workflow/create_api_review_pr.js index 03bf125d8a6b..9d123ebd2ea6 100644 --- a/scripts/api_md_workflow/create_api_review_pr.js +++ b/scripts/api_md_workflow/create_api_review_pr.js @@ -4,11 +4,29 @@ const fs = require("fs"); const os = require("os"); const path = require("path"); const { spawnSync } = require("child_process"); +const { getDefaultLogger } = require("./common"); const { loadAdapter, loadWorkflowConfig } = require("./adapter_config"); const REPO_ROOT = path.resolve(__dirname, "..", ".."); const REMOTE = "origin"; const MAIN_REF = `${REMOTE}/main`; +let logger = console; + +function logInfo(message) { + logger.info(message); +} + +function logWarning(message) { + if (typeof logger.warning === "function") { + logger.warning(message); + return; + } + logger.warn(message); +} + +function logError(message) { + logger.error(message); +} function parseArgs(argv) { const config = loadWorkflowConfig(); @@ -57,7 +75,7 @@ function parseArgs(argv) { function run(cmd, args, options = {}) { const printable = [cmd, ...args].join(" "); - console.log(`$ ${printable}`); + logInfo(`$ ${printable}`); const result = spawnSync(cmd, args, { cwd: options.cwd ?? REPO_ROOT, env: options.env, @@ -198,7 +216,7 @@ function envWithRealGit() { const first = parts[0] || ""; if (first.replace(/\\+$/, "").toLowerCase() !== gitDir.replace(/\\+$/, "").toLowerCase()) { env.PATH = `${gitDir}${path.delimiter}${current}`; - console.log(`(prepending real git to PATH for gh: ${gitDir})`); + logInfo(`(prepending real git to PATH for gh: ${gitDir})`); } return env; @@ -330,12 +348,41 @@ function writeBytes(filePath, bytes) { fs.writeFileSync(filePath, bytes); } +function generateApiBytesForPackage({ + adapter, + repoRoot, + packageName, + packageDir, + generateScriptPath, + exportScriptPath, + runtimeExecutable, + refLabel, + logger, +}) { + adapter.generateApiForPackage({ + repoRoot, + packageName, + runtimeExecutable, + logger, + generateScriptPath, + exportScriptPath, + refLabel, + }); + + const outputPath = apiMdPath(packageDir); + if (!fs.existsSync(outputPath)) { + throw new Error(`ERROR: did not produce ${outputPath}`); + } + + return fs.readFileSync(outputPath); +} + function main() { const args = parseArgs(process.argv.slice(2)); const adapter = loadAdapter(args.adapter); const packageDir = adapter.findPackageDir(REPO_ROOT, args.packageName); - console.log(`Found package at: ${packageDir}`); + logInfo(`Found package at: ${packageDir}`); ensureCleanWorktree(); const originalBranch = currentBranch(); @@ -361,9 +408,10 @@ function main() { try { let baseApiBytes = null; if (args.base) { - console.log(`\n=== Capturing baseline API.md from tag ${args.base} ===`); + logInfo(`\n=== Capturing baseline API.md from tag ${args.base} ===`); git(["checkout", "--detach", args.base]); - baseApiBytes = adapter.generateApiMdBytes({ + baseApiBytes = generateApiBytesForPackage({ + adapter, repoRoot: REPO_ROOT, packageName: args.packageName, packageDir, @@ -371,13 +419,15 @@ function main() { exportScriptPath: cachedExport, runtimeExecutable: args.runtimeExecutable, refLabel: currentBranchOrSha(), + logger, }); } - console.log(`\n=== Capturing target API.md from ${targetRef} ===`); + logInfo(`\n=== Capturing target API.md from ${targetRef} ===`); git(["checkout", "--detach", targetRef]); const targetVersion = adapter.readVersion(packageDir); - const targetApiBytes = adapter.generateApiMdBytes({ + const targetApiBytes = generateApiBytesForPackage({ + adapter, repoRoot: REPO_ROOT, packageName: args.packageName, packageDir, @@ -385,12 +435,13 @@ function main() { exportScriptPath: cachedExport, runtimeExecutable: args.runtimeExecutable, refLabel: currentBranchOrSha(), + logger, }); const baseBranch = `base_${args.packageName}_${baseVersion}`; const reviewBranch = `review_${args.packageName}_${targetVersion}`; - console.log(`\n=== Creating base branch ${baseBranch} ===`); + logInfo(`\n=== Creating base branch ${baseBranch} ===`); git(["checkout", "-B", baseBranch, MAIN_REF]); const apiPath = apiMdPath(packageDir); @@ -419,7 +470,7 @@ function main() { git(["push", "--force-with-lease", REMOTE, baseBranch]); - console.log(`\n=== Creating review branch ${reviewBranch} ===`); + logInfo(`\n=== Creating review branch ${reviewBranch} ===`); git(["checkout", "-B", reviewBranch, baseBranch]); writeBytes(apiPath, targetApiBytes); git(["add", apiRelative]); @@ -459,7 +510,7 @@ function main() { "Generated by `scripts/api_md_workflow/create_api_review_pr.js`.", ].join("\n"); - console.log("\n=== Opening PR ==="); + logInfo("\n=== Opening PR ==="); const compareUrl = `https://github.com/Azure/azure-sdk-for-python/compare/${baseBranch}...${reviewBranch}?expand=1`; const prCreate = run( "gh", @@ -482,7 +533,7 @@ function main() { ); if (prCreate.status !== 0) { - console.log( + logWarning( "\nWARNING: `gh pr create` failed. Both branches were pushed successfully -- open the PR manually here:\n" + ` ${compareUrl}\n` + ` Title: ${title}`, @@ -499,10 +550,13 @@ function main() { } } -try { - process.exit(main()); -} catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(message); - process.exit(1); -} +(async () => { + logger = await getDefaultLogger(); + try { + process.exit(main()); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logError(message); + process.exit(1); + } +})(); diff --git a/scripts/api_md_workflow/find_affected.js b/scripts/api_md_workflow/find_affected.js index 54e71f5967fe..25ace58d9ca8 100644 --- a/scripts/api_md_workflow/find_affected.js +++ b/scripts/api_md_workflow/find_affected.js @@ -4,13 +4,17 @@ const { REPO_ROOT, appendGithubOutput, envPath, + getDefaultLogger, + loadSharedModule, requireEnv, - run, + runAsync, writeLines, } = require("./common"); const { loadAdapter, loadWorkflowConfig } = require("./adapter_config"); -function main() { +async function main() { + const { includesSegment } = await loadSharedModule("path.js"); + const config = loadWorkflowConfig(); const adapterName = config.adapter; const adapter = loadAdapter(adapterName); @@ -22,10 +26,14 @@ function main() { const packagesFile = envPath("API_MD_PACKAGES_FILE", ".artifacts/affected_package_dirs.txt"); const changedFile = envPath("API_MD_CHANGED_FILE", ".artifacts/changed_package_dirs.txt"); - run("git", ["fetch", "--no-tags", "--depth=1", "origin", baseRef]); - const diff = run("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`], { - capture: true, - }).stdout; + await runAsync("git", ["fetch", "--no-tags", "--depth=1", "origin", baseRef], { + cwd: REPO_ROOT, + }); + const diff = ( + await runAsync("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`], { + cwd: REPO_ROOT, + }) + ).stdout; const changedDirs = new Set(); for (const filePath of diff.split(/\r?\n/)) { @@ -33,6 +41,9 @@ function main() { if (!trimmed) { continue; } + if (!includesSegment(trimmed, "sdk")) { + continue; + } const parts = trimmed.split("/"); if (parts.length < 3 || parts[0] !== "sdk") { @@ -56,9 +67,8 @@ function main() { appendGithubOutput("count", affected.length); } -try { - main(); -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); +main().catch(async (error) => { + const logger = await getDefaultLogger(); + logger.error(error instanceof Error ? error.message : String(error)); process.exit(1); -} +}); diff --git a/scripts/api_md_workflow/find_mismatches.js b/scripts/api_md_workflow/find_mismatches.js index fc2ab9dfe865..ba401156a461 100644 --- a/scripts/api_md_workflow/find_mismatches.js +++ b/scripts/api_md_workflow/find_mismatches.js @@ -2,9 +2,9 @@ const fs = require("fs"); -const { appendGithubOutput, envPath, readLines, run, writeLines } = require("./common"); +const { appendGithubOutput, envPath, getDefaultLogger, readLines, runAsync, writeLines } = require("./common"); -function main() { +async function main() { const packagesFile = envPath("API_MD_PACKAGES_FILE", ".artifacts/affected_package_dirs.txt"); const mismatchesFile = envPath("API_MD_MISMATCHES_FILE", ".artifacts/mismatched_api_files.txt"); const missingFile = envPath("API_MD_MISSING_FILE", ".artifacts/missing_api_files.txt"); @@ -21,18 +21,18 @@ function main() { continue; } - const trackedResult = run("git", ["ls-files", "--error-unmatch", "--", apiFile], { + const diffResult = await runAsync("git", ["ls-files", "--error-unmatch", "--", apiFile], { check: false, }); - if (trackedResult.status !== 0) { + if (diffResult.status !== 0) { missing.push(apiFile); continue; } - const diffResult = run("git", ["diff", "--quiet", "--", apiFile], { + const quietDiffResult = await runAsync("git", ["diff", "--quiet", "--", apiFile], { check: false, }); - if (diffResult.status !== 0) { + if (quietDiffResult.status !== 0) { mismatches.push(apiFile); } } @@ -44,9 +44,8 @@ function main() { appendGithubOutput("issue_count", mismatches.length + missing.length); } -try { - main(); -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); +main().catch(async (error) => { + const logger = await getDefaultLogger(); + logger.error(error instanceof Error ? error.message : String(error)); process.exit(1); -} +}); diff --git a/scripts/api_md_workflow/regenerate.js b/scripts/api_md_workflow/regenerate.js index 1954152f3b31..64f6048c29dc 100644 --- a/scripts/api_md_workflow/regenerate.js +++ b/scripts/api_md_workflow/regenerate.js @@ -2,10 +2,11 @@ const path = require("path"); -const { REPO_ROOT, envPath, readLines } = require("./common"); +const { REPO_ROOT, envPath, getDefaultLogger, readLines } = require("./common"); const { loadAdapter, loadWorkflowConfig } = require("./adapter_config"); -function main() { +async function main() { + const logger = await getDefaultLogger(); const config = loadWorkflowConfig(); const adapter = loadAdapter(config.adapter); if (typeof adapter.generateApiForPackage !== "function") { @@ -23,18 +24,18 @@ function main() { const runtimeExecutable = process.env.RUNTIME_EXECUTABLE || null; for (const pkgDir of packages) { const packageName = path.basename(pkgDir); - console.log(`Generating API.md for ${packageName}`); + logger.info(`Generating API.md for ${packageName}`); adapter.generateApiForPackage({ repoRoot: REPO_ROOT, packageName, runtimeExecutable, + logger, }); } } -try { - main(); -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); +main().catch(async (error) => { + const logger = await getDefaultLogger(); + logger.error(error instanceof Error ? error.message : String(error)); process.exit(1); -} +}); From 9d0946e9de3f92e168956eda88eae55b65c39229 Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Wed, 3 Jun 2026 09:53:45 -0700 Subject: [PATCH 13/33] Code review feedback. --- .github/workflows/api-consistency.yml | 59 ++++++++++ .github/workflows/consistency.yml | 106 ------------------ .../api-md-consistency/api-md-consistency.js | 106 ++++++++++++++++++ .../api_md_workflow/create_api_review_pr.js | 22 ++++ 4 files changed, 187 insertions(+), 106 deletions(-) create mode 100644 .github/workflows/api-consistency.yml delete mode 100644 .github/workflows/consistency.yml create mode 100644 .github/workflows/src/api-md-consistency/api-md-consistency.js diff --git a/.github/workflows/api-consistency.yml b/.github/workflows/api-consistency.yml new file mode 100644 index 000000000000..164b486ff1b5 --- /dev/null +++ b/.github/workflows/api-consistency.yml @@ -0,0 +1,59 @@ +name: API.md Consistency + +on: + pull_request: + types: + # default + - opened + - synchronize + - reopened + # re-run if base branch is changed, since previous merge commit may generate incorrect diff + - edited + # re-run if PR changes to/from draft + - converted_to_draft + - ready_for_review + paths: + - "sdk/**" + +permissions: + contents: read + +jobs: + consistency: + if: ${{ !github.event.pull_request.draft }} + runs-on: ubuntu-latest + outputs: + changed_count: ${{ steps.consistency.outputs.changed_count || '0' }} + mismatch_count: ${{ steps.consistency.outputs.mismatch_count || '0' }} + missing_count: ${{ steps.consistency.outputs.missing_count || '0' }} + issue_count: ${{ steps.consistency.outputs.issue_count || '0' }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 2 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Run API.md consistency checks + id: consistency + uses: actions/github-script@v8 + env: + API_MD_BASE_REF: ${{ github.event.pull_request.base.ref }} + API_MD_CHANGED_FILE: .artifacts/changed_package_dirs.txt + API_MD_PACKAGES_FILE: .artifacts/affected_package_dirs.txt + API_MD_MISMATCHES_FILE: .artifacts/mismatched_api_files.txt + API_MD_MISSING_FILE: .artifacts/missing_api_files.txt + with: + script: | + const { default: apiMdConsistency } = + await import('${{ github.workspace }}/.github/workflows/src/api-md-consistency/api-md-consistency.js'); + return await apiMdConsistency({ github, context, core }); diff --git a/.github/workflows/consistency.yml b/.github/workflows/consistency.yml deleted file mode 100644 index 11cf738a769f..000000000000 --- a/.github/workflows/consistency.yml +++ /dev/null @@ -1,106 +0,0 @@ -name: API.md Consistency - -on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - paths: - - "sdk/**" - -permissions: - contents: read - -jobs: - consistency: - if: ${{ !github.event.pull_request.draft }} - runs-on: ubuntu-latest - outputs: - changed_count: ${{ steps.changed.outputs.count || '0' }} - mismatch_count: ${{ steps.consistency.outputs.mismatch_count || '0' }} - missing_count: ${{ steps.consistency.outputs.missing_count || '0' }} - issue_count: ${{ steps.consistency.outputs.issue_count || '0' }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: "20" - - - name: Find changed SDK packages - id: changed - env: - API_MD_BASE_REF: ${{ github.event.pull_request.base.ref }} - API_MD_PACKAGES_FILE: .artifacts/affected_package_dirs.txt - API_MD_CHANGED_FILE: .artifacts/changed_package_dirs.txt - shell: bash - run: | - set -euo pipefail - node scripts/api_md_workflow/find_affected.js - - - name: Generate API.md for affected packages - if: ${{ steps.changed.outputs.count != '0' }} - env: - API_MD_PACKAGES_FILE: .artifacts/affected_package_dirs.txt - shell: bash - run: | - set -euo pipefail - node scripts/api_md_workflow/regenerate.js - - - name: Check API.md consistency - id: consistency - env: - API_MD_PACKAGES_FILE: .artifacts/affected_package_dirs.txt - API_MD_MISMATCHES_FILE: .artifacts/mismatched_api_files.txt - API_MD_MISSING_FILE: .artifacts/missing_api_files.txt - shell: bash - run: | - set -euo pipefail - if [ "${{ steps.changed.outputs.count }}" = "0" ]; then - echo "mismatch_count=0" >> "$GITHUB_OUTPUT" - echo "missing_count=0" >> "$GITHUB_OUTPUT" - echo "issue_count=0" >> "$GITHUB_OUTPUT" - exit 0 - fi - - node scripts/api_md_workflow/find_mismatches.js - - - name: Fail when API.md is out of date - if: ${{ steps.changed.outputs.count != '0' && steps.consistency.outputs.issue_count != '0' }} - shell: bash - run: | - set -euo pipefail - - print_issues() { - local title="$1" - local issues_file="$2" - - if [ ! -s "$issues_file" ]; then - return - fi - - echo "$title" - while IFS= read -r api_file; do - [ -n "$api_file" ] || continue - package_dir="${api_file%/API.md}" - package_name="$(basename "$package_dir")" - echo "- ${package_dir}" - echo " API.md: ${api_file}" - echo " Regenerate: python scripts/generate_api_text.py ${package_name}" - done < "$issues_file" - echo - } - - echo "Generated API.md does not match committed API.md, or API.md is missing, for one or more affected packages." - echo - print_issues "Mismatched packages:" ".artifacts/mismatched_api_files.txt" - print_issues "Missing API.md packages:" ".artifacts/missing_api_files.txt" - echo "To regenerate API.md locally, run the command shown for each package from the repository root." - exit 1 diff --git a/.github/workflows/src/api-md-consistency/api-md-consistency.js b/.github/workflows/src/api-md-consistency/api-md-consistency.js new file mode 100644 index 000000000000..5b2d479e0e68 --- /dev/null +++ b/.github/workflows/src/api-md-consistency/api-md-consistency.js @@ -0,0 +1,106 @@ +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +function runNode(scriptRelativePath, workspace, core) { + const result = spawnSync("node", [scriptRelativePath], { + cwd: workspace, + env: process.env, + encoding: "utf-8", + }); + + if (result.stdout) { + core.info(result.stdout.trimEnd()); + } + if (result.stderr) { + core.info(result.stderr.trimEnd()); + } + if (result.status !== 0) { + throw new Error(`Command failed (${result.status}): node ${scriptRelativePath}`); + } +} + +function readLines(fileRelativePath, workspace) { + const fullPath = path.join(workspace, fileRelativePath); + if (!fs.existsSync(fullPath)) { + return []; + } + + return fs + .readFileSync(fullPath, "utf-8") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => Boolean(line)); +} + +function formatIssueSection(title, apiFiles) { + if (!apiFiles.length) { + return ""; + } + + const lines = [title]; + for (const apiFile of apiFiles) { + const packageDir = apiFile.replace(/\/API\.md$/, ""); + const packageName = path.basename(packageDir); + lines.push(`- ${packageDir}`); + lines.push(` API.md: ${apiFile}`); + lines.push(` Regenerate: python scripts/generate_api_text.py ${packageName}`); + } + lines.push(""); + return lines.join("\n"); +} + +module.exports = async function apiMdConsistency({ core }) { + const workspace = process.env.GITHUB_WORKSPACE || process.cwd(); + + runNode("scripts/api_md_workflow/find_affected.js", workspace, core); + + const affected = readLines(process.env.API_MD_PACKAGES_FILE, workspace); + const changedCount = affected.length; + core.setOutput("changed_count", String(changedCount)); + + if (changedCount === 0) { + core.setOutput("mismatch_count", "0"); + core.setOutput("missing_count", "0"); + core.setOutput("issue_count", "0"); + return { + changedCount, + mismatchCount: 0, + missingCount: 0, + issueCount: 0, + }; + } + + runNode("scripts/api_md_workflow/regenerate.js", workspace, core); + runNode("scripts/api_md_workflow/find_mismatches.js", workspace, core); + + const mismatches = readLines(process.env.API_MD_MISMATCHES_FILE, workspace); + const missing = readLines(process.env.API_MD_MISSING_FILE, workspace); + + const mismatchCount = mismatches.length; + const missingCount = missing.length; + const issueCount = mismatchCount + missingCount; + + core.setOutput("mismatch_count", String(mismatchCount)); + core.setOutput("missing_count", String(missingCount)); + core.setOutput("issue_count", String(issueCount)); + + if (issueCount > 0) { + const messageParts = [ + "Generated API.md does not match committed API.md, or API.md is missing, for one or more affected packages.", + "", + formatIssueSection("Mismatched packages:", mismatches), + formatIssueSection("Missing API.md packages:", missing), + "To regenerate API.md locally, run the command shown for each package from the repository root.", + ].filter((part) => part !== ""); + + core.setFailed(messageParts.join("\n")); + } + + return { + changedCount, + mismatchCount, + missingCount, + issueCount, + }; +}; diff --git a/scripts/api_md_workflow/create_api_review_pr.js b/scripts/api_md_workflow/create_api_review_pr.js index 9d123ebd2ea6..99ef4c31b539 100644 --- a/scripts/api_md_workflow/create_api_review_pr.js +++ b/scripts/api_md_workflow/create_api_review_pr.js @@ -348,6 +348,26 @@ function writeBytes(filePath, bytes) { fs.writeFileSync(filePath, bytes); } +function discardTemporaryWorktreeChanges() { + const status = git(["status", "--porcelain"], { capture: true }).stdout.trim(); + if (!status) { + return; + } + + const marker = `api-md-workflow-temp-${Date.now()}`; + git(["stash", "push", "--include-untracked", "-m", marker]); + + const topEntry = git(["stash", "list", "-n", "1", "--format=%gd %s"], { + capture: true, + }).stdout.trim(); + + if (!topEntry.includes(marker)) { + throw new Error("ERROR: failed to identify temporary stash entry while cleaning generated files."); + } + + git(["stash", "drop", "stash@{0}"]); +} + function generateApiBytesForPackage({ adapter, repoRoot, @@ -421,6 +441,7 @@ function main() { refLabel: currentBranchOrSha(), logger, }); + discardTemporaryWorktreeChanges(); } logInfo(`\n=== Capturing target API.md from ${targetRef} ===`); @@ -437,6 +458,7 @@ function main() { refLabel: currentBranchOrSha(), logger, }); + discardTemporaryWorktreeChanges(); const baseBranch = `base_${args.packageName}_${baseVersion}`; const reviewBranch = `review_${args.packageName}_${targetVersion}`; From ec760b0ade28105689abd267d24558285e7df9f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:05:38 +0000 Subject: [PATCH 14/33] Add generated API.md for azure-template Co-authored-by: tjprescott <5723682+tjprescott@users.noreply.github.com> --- sdk/template/azure-template/API.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 sdk/template/azure-template/API.md diff --git a/sdk/template/azure-template/API.md b/sdk/template/azure-template/API.md new file mode 100644 index 000000000000..809d8a57143d --- /dev/null +++ b/sdk/template/azure-template/API.md @@ -0,0 +1,15 @@ +```py +# Package is parsed using apiview-stub-generator(version:0.3.28), Python version: 3.12.13 + + +namespace azure.template + + def azure.template.template_main() -> bool: ... + + +namespace azure.template.template_code + + def azure.template.template_code.template_main() -> bool: ... + + +``` \ No newline at end of file From d04b5db8281d56e21ae352184ff79eabcf35b2b8 Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Wed, 3 Jun 2026 10:54:48 -0700 Subject: [PATCH 15/33] Refactor to use `azpysdk apistub --md` command. --- .github/shared/src/changed-files.js | 2 + .github/workflows/api-consistency.yml | 8 +- .../api-md-consistency/api-md-consistency.js | 2 +- scripts/api_md_workflow/README.md | 6 +- scripts/api_md_workflow/adapters/python.js | 16 +- .../api_md_workflow/create_api_review_pr.js | 21 +- scripts/generate_api_text.py | 229 ------------------ 7 files changed, 15 insertions(+), 269 deletions(-) delete mode 100644 scripts/generate_api_text.py diff --git a/.github/shared/src/changed-files.js b/.github/shared/src/changed-files.js index f167e7056680..43b26ddbd877 100644 --- a/.github/shared/src/changed-files.js +++ b/.github/shared/src/changed-files.js @@ -3,6 +3,8 @@ import { simpleGit } from "simple-git"; import { KeyedCache } from "./cache.js"; import { includesSegment } from "./path.js"; +// cSpell:ignore unshift + // Enable simple-git debug logging to improve console output debug.enable("simple-git"); diff --git a/.github/workflows/api-consistency.yml b/.github/workflows/api-consistency.yml index 164b486ff1b5..743bda16b36f 100644 --- a/.github/workflows/api-consistency.yml +++ b/.github/workflows/api-consistency.yml @@ -36,7 +36,13 @@ jobs: - name: Setup Python uses: actions/setup-python@v6 with: - python-version: "3.12" + python-version: "3.10" + + - name: Install azpysdk + shell: bash + run: | + python -m pip install --upgrade pip + python -m pip install ./eng/tools/azure-sdk-tools - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/src/api-md-consistency/api-md-consistency.js b/.github/workflows/src/api-md-consistency/api-md-consistency.js index 5b2d479e0e68..050cef7c7ced 100644 --- a/.github/workflows/src/api-md-consistency/api-md-consistency.js +++ b/.github/workflows/src/api-md-consistency/api-md-consistency.js @@ -44,7 +44,7 @@ function formatIssueSection(title, apiFiles) { const packageName = path.basename(packageDir); lines.push(`- ${packageDir}`); lines.push(` API.md: ${apiFile}`); - lines.push(` Regenerate: python scripts/generate_api_text.py ${packageName}`); + lines.push(` Regenerate: azpysdk apistub --md ${packageName}`); } lines.push(""); return lines.join("\n"); diff --git a/scripts/api_md_workflow/README.md b/scripts/api_md_workflow/README.md index d750693dc86f..9e6a97f7bbee 100644 --- a/scripts/api_md_workflow/README.md +++ b/scripts/api_md_workflow/README.md @@ -20,7 +20,7 @@ It runs on pull requests for changes under `sdk/**`. - Regenerates `API.md` for those packages. - Fails if the generated files differ from the committed files. - Fails if an affected package does not have a committed `API.md`. -- Prints the mismatched or missing packages and the `scripts/generate_api_text.py` command needed to regenerate each `API.md` file. +- Prints the mismatched or missing packages and the `azpysdk apistub --md` command needed to regenerate each `API.md` file. ## Script Layout @@ -47,7 +47,7 @@ Also writes `count=` to `GITHUB_OUTPUT`. ### `regenerate.js` -Reads package directories from `API_MD_PACKAGES_FILE` and runs `scripts/generate_api_text.py` for each package. +Reads package directories from `API_MD_PACKAGES_FILE` and runs `azpysdk apistub --md ` for each package. This script is used by the consistency check. @@ -96,4 +96,4 @@ Common variables include: 3. `find_affected.js` determines which packages were touched. 4. `regenerate.js` rebuilds `API.md` for those packages. 5. `find_mismatches.js` records any `API.md` drift, including missing or untracked `API.md` files. -6. If drift is found, the workflow fails and prints the affected packages plus the `scripts/generate_api_text.py` command to regenerate each `API.md` file locally. +6. If drift is found, the workflow fails and prints the affected packages plus the `azpysdk apistub --md ` command to regenerate each `API.md` file locally. diff --git a/scripts/api_md_workflow/adapters/python.js b/scripts/api_md_workflow/adapters/python.js index 61325f41f25f..6ca550d4ec65 100644 --- a/scripts/api_md_workflow/adapters/python.js +++ b/scripts/api_md_workflow/adapters/python.js @@ -106,30 +106,16 @@ function readVersion(packageDir) { function generateApiForPackage({ repoRoot, packageName, - runtimeExecutable, logger, - generateScriptPath, - exportScriptPath, refLabel, }) { - const executable = runtimeExecutable || process.env.PYTHON || "python"; const activeLogger = logger || console; - const scriptPath = generateScriptPath || path.join(repoRoot, "scripts", "generate_api_text.py"); if (refLabel) { activeLogger.info(`--- Generating API.md on ${refLabel} ---`); } - const env = exportScriptPath - ? { - ...process.env, - AZSDK_REPO_ROOT: repoRoot, - AZSDK_EXPORT_SCRIPT: exportScriptPath, - } - : undefined; - - run(executable, [scriptPath, packageName], { + run("azpysdk", ["apistub", "--md", packageName], { cwd: repoRoot, - env, check: true, logger: activeLogger, }); diff --git a/scripts/api_md_workflow/create_api_review_pr.js b/scripts/api_md_workflow/create_api_review_pr.js index 99ef4c31b539..09ea4e6f6081 100644 --- a/scripts/api_md_workflow/create_api_review_pr.js +++ b/scripts/api_md_workflow/create_api_review_pr.js @@ -1,7 +1,6 @@ #!/usr/bin/env node const fs = require("fs"); -const os = require("os"); const path = require("path"); const { spawnSync } = require("child_process"); const { getDefaultLogger } = require("./common"); @@ -373,8 +372,6 @@ function generateApiBytesForPackage({ repoRoot, packageName, packageDir, - generateScriptPath, - exportScriptPath, runtimeExecutable, refLabel, logger, @@ -384,8 +381,6 @@ function generateApiBytesForPackage({ packageName, runtimeExecutable, logger, - generateScriptPath, - exportScriptPath, refLabel, }); @@ -419,12 +414,6 @@ function main() { const targetRef = args.target ? resolveTargetRef(args.target) : MAIN_REF; - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "apirev_script_")); - const cachedScript = path.join(tempDir, "generate_api_text.py"); - const cachedExport = path.join(tempDir, "Export-APIViewMarkdown.ps1"); - fs.copyFileSync(path.join(REPO_ROOT, "scripts", "generate_api_text.py"), cachedScript); - fs.copyFileSync(path.join(REPO_ROOT, "eng", "common", "scripts", "Export-APIViewMarkdown.ps1"), cachedExport); - try { let baseApiBytes = null; if (args.base) { @@ -435,8 +424,6 @@ function main() { repoRoot: REPO_ROOT, packageName: args.packageName, packageDir, - generateScriptPath: cachedScript, - exportScriptPath: cachedExport, runtimeExecutable: args.runtimeExecutable, refLabel: currentBranchOrSha(), logger, @@ -452,8 +439,6 @@ function main() { repoRoot: REPO_ROOT, packageName: args.packageName, packageDir, - generateScriptPath: cachedScript, - exportScriptPath: cachedExport, runtimeExecutable: args.runtimeExecutable, refLabel: currentBranchOrSha(), logger, @@ -564,11 +549,7 @@ function main() { return 0; } finally { - try { - git(["checkout", originalBranch], { check: false }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } + git(["checkout", originalBranch], { check: false }); } } diff --git a/scripts/generate_api_text.py b/scripts/generate_api_text.py deleted file mode 100644 index d11e2e2bff08..000000000000 --- a/scripts/generate_api_text.py +++ /dev/null @@ -1,229 +0,0 @@ -#!/usr/bin/env python - -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -"""Generate API.md for an Azure SDK package. - -Usage: - python scripts/generate_api_text.py azure-ai-projects -""" - -import argparse -import glob -import os -import shutil -import subprocess -import sys -import tempfile - - -REPO_ROOT = os.environ.get("AZSDK_REPO_ROOT") or os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -APIVIEW_REQS = os.path.join(REPO_ROOT, "eng", "apiview_reqs.txt") -AZURE_SDK_INDEX = "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-python/pypi/simple/" -EXPORT_SCRIPT = os.environ.get("AZSDK_EXPORT_SCRIPT") or os.path.join( - REPO_ROOT, "eng", "common", "scripts", "Export-APIViewMarkdown.ps1" -) - - -def find_package_dir(package_name: str) -> str: - """Find the package directory under sdk/*/{package_name}/.""" - pattern = os.path.join(REPO_ROOT, "sdk", "*", package_name) - matches = glob.glob(pattern) - # Filter to directories that contain a pyproject.toml or setup.py - valid = [ - m - for m in matches - if os.path.isdir(m) - and (os.path.exists(os.path.join(m, "pyproject.toml")) or os.path.exists(os.path.join(m, "setup.py"))) - ] - if not valid: - raise FileNotFoundError(f"Package '{package_name}' not found under sdk/*/") - if len(valid) > 1: - raise ValueError(f"Multiple matches for '{package_name}': {valid}") - return valid[0] - - -def get_installed_version(package: str) -> str | None: - """Get the currently installed version of a package, or None.""" - try: - result = subprocess.run( - [sys.executable, "-m", "pip", "show", package], - capture_output=True, - text=True, - check=True, - ) - for line in result.stdout.splitlines(): - if line.startswith("Version:"): - return line.split(":", 1)[1].strip() - except subprocess.CalledProcessError: - pass - return None - - -def get_latest_version(package: str) -> str | None: - """Query the Azure SDK feed for the latest version of a package.""" - try: - result = subprocess.run( - [sys.executable, "-m", "pip", "index", "versions", package, "--index-url", AZURE_SDK_INDEX], - capture_output=True, - text=True, - check=True, - ) - # Output format: "apiview-stub-generator (0.3.28)" - for line in result.stdout.splitlines(): - if package in line and "(" in line: - version = line.split("(")[1].split(")")[0].strip() - return version - except subprocess.CalledProcessError: - pass - return None - - -def ensure_latest_apiview_stub_generator(): - """Ensure the latest apiview-stub-generator is installed from the Azure SDK feed. - - If we cannot determine the latest version (e.g. the feed query fails), - fail fast rather than proceeding with an unknown potentially stale version. - """ - installed = get_installed_version("apiview-stub-generator") - latest = get_latest_version("apiview-stub-generator") - - print(f"apiview-stub-generator: installed={installed}, latest={latest}") - - if installed and latest and installed == latest: - print("Already at latest version.") - return - - if not latest: - raise RuntimeError( - "Could not determine the latest apiview-stub-generator from the " - "Azure SDK feed. Failing to avoid using an unknown local version." - ) - - # Install from apiview_reqs.txt first (gets dependencies right) - print("Installing apiview_reqs.txt...") - subprocess.run( - [sys.executable, "-m", "pip", "install", "-r", APIVIEW_REQS, f"--index-url={AZURE_SDK_INDEX}"], - check=True, - ) - - # Override with latest version (not the pinned one) - print("Upgrading apiview-stub-generator to latest...") - subprocess.run( - [ - sys.executable, - "-m", - "pip", - "install", - "--upgrade", - "apiview-stub-generator", - f"--index-url={AZURE_SDK_INDEX}", - ], - check=True, - ) - - new_version = get_installed_version("apiview-stub-generator") - print(f"apiview-stub-generator now at version {new_version}") - - -def build_wheel(package_dir: str, output_dir: str) -> str: - """Build a wheel for the package and return the path to the .whl file.""" - subprocess.run( - [sys.executable, "-m", "pip", "wheel", package_dir, "--no-deps", "-w", output_dir], - check=True, - ) - whls = glob.glob(os.path.join(output_dir, "*.whl")) - if not whls: - raise FileNotFoundError(f"No .whl file found in {output_dir}") - return whls[0] - - -def run_apistub(whl_path: str, out_path: str): - """Run apiview-stub-generator on the wheel.""" - subprocess.run( - [ - sys.executable, - "-m", - "apistub", - "--pkg-path", - whl_path, - "--out-path", - out_path, - "--skip-pylint", - ], - check=True, - ) - - -def export_api_markdown(token_json_path: str, output_path: str): - """Run the Export-APIViewMarkdown.ps1 script to convert token JSON to API.md.""" - try: - subprocess.run( - ["pwsh", EXPORT_SCRIPT, "-TokenJsonPath", token_json_path, "-OutputPath", output_path], - check=True, - ) - except FileNotFoundError as exc: - raise RuntimeError( - "PowerShell 7 (pwsh) is required to export API markdown but was not found on PATH. " - "Install PowerShell from " - "https://learn.microsoft.com/powershell/scripting/install/installing-powershell " - "and restart your terminal/IDE." - ) from exc - - -def main(): - parser = argparse.ArgumentParser(description="Generate API.md for an Azure SDK package.") - parser.add_argument("package", help="Package name (e.g. azure-ai-projects)") - args = parser.parse_args() - - package_name = args.package - print(f"Generating API.md for {package_name}...") - - # Find the package - package_dir = find_package_dir(package_name) - print(f"Found package at: {package_dir}") - - # Ensure latest apiview-stub-generator - ensure_latest_apiview_stub_generator() - - # Build wheel in a temp directory - tmp_dir = tempfile.mkdtemp(prefix="apistub_") - try: - print("Building wheel...") - whl_path = build_wheel(package_dir, tmp_dir) - print(f"Built: {os.path.basename(whl_path)}") - - # Run apiview-stub-generator - print("Running apiview-stub-generator...") - run_apistub(whl_path, tmp_dir) - - # Find the generated token JSON - token_json = os.path.join(tmp_dir, f"{package_name}_python.json") - if not os.path.exists(token_json): - # Try with underscores (package name normalization) - normalized = package_name.replace("-", "_") - token_json = os.path.join(tmp_dir, f"{normalized}_python.json") - if not os.path.exists(token_json): - # Find any json file - jsons = glob.glob(os.path.join(tmp_dir, "*_python.json")) - if jsons: - token_json = jsons[0] - else: - raise FileNotFoundError(f"No token JSON found in {tmp_dir}") - - # Export to API.md - api_md_path = os.path.join(package_dir, "API.md") - print("Exporting API.md...") - export_api_markdown(token_json, api_md_path) - print(f"Generated: {api_md_path}") - - finally: - # Clean up temp directory (wheel + token json) - shutil.rmtree(tmp_dir, ignore_errors=True) - - -if __name__ == "__main__": - main() From 7f8eeb56a9b07d8976b55436fce393338c47dd61 Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Wed, 3 Jun 2026 12:22:14 -0700 Subject: [PATCH 16/33] Remove Python 3.10 limit on apistub.py. --- .github/workflows/api-consistency.yml | 2 +- doc/eng_sys_checks.md | 2 -- eng/tools/azure-sdk-tools/azpysdk/apistub.py | 7 ------- eng/tools/azure-sdk-tools/tests/test_apistub.py | 4 ---- 4 files changed, 1 insertion(+), 14 deletions(-) diff --git a/.github/workflows/api-consistency.yml b/.github/workflows/api-consistency.yml index 743bda16b36f..006c032d23dc 100644 --- a/.github/workflows/api-consistency.yml +++ b/.github/workflows/api-consistency.yml @@ -36,7 +36,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v6 with: - python-version: "3.10" + python-version: "3.12" - name: Install azpysdk shell: bash diff --git a/doc/eng_sys_checks.md b/doc/eng_sys_checks.md index 3515f24c3f8c..28839bf6d6af 100644 --- a/doc/eng_sys_checks.md +++ b/doc/eng_sys_checks.md @@ -177,8 +177,6 @@ analyze_python_version = "3.11" This setting is read by `eng/scripts/dispatch_checks.py` and is passed to `azpysdk` via the `--python` flag (which requires `--isolate` and `uv`). This is useful for packages that use newer syntax or type features that require a more recent Python interpreter. > **Note:** This setting only affects the Python interpreter version used for the analyze venv; it does not change the minimum supported Python version declared in `setup.py`/`pyproject.toml`. -> -> **Warning:** This override applies to _all_ analyze checks dispatched by `dispatch_checks.py`, including `apistub`. The `apistub` tool currently requires Python < 3.11 (`PYTHON_VERSION_LIMIT = (3, 11)` in `azpysdk/apistub.py`). Do not set `analyze_python_version` to `3.11` or higher for packages that still run `apistub` through the standard dispatched analyze flow. ## Environment variables important to CI diff --git a/eng/tools/azure-sdk-tools/azpysdk/apistub.py b/eng/tools/azure-sdk-tools/azpysdk/apistub.py index 4465562950c3..aa2e1e6dee5a 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/apistub.py +++ b/eng/tools/azure-sdk-tools/azpysdk/apistub.py @@ -13,7 +13,6 @@ from ci_tools.parsing import ParsedSetup REPO_ROOT = discover_repo_root() -PYTHON_VERSION_LIMIT = (3, 11) # apistub doesn't support Python 3.11+ def get_package_wheel_path(pkg_root: str) -> str: @@ -76,12 +75,6 @@ def run(self, args: argparse.Namespace) -> int: """Run the apistub check command.""" logger.info("Running apistub check...") - if sys.version_info >= PYTHON_VERSION_LIMIT: - logger.error( - f"Python version {sys.version_info.major}.{sys.version_info.minor} is not supported. Version must be less than {PYTHON_VERSION_LIMIT[0]}.{PYTHON_VERSION_LIMIT[1]}." - ) - return 1 - set_envvar_defaults() targeted = self.get_targeted_directories(args) diff --git a/eng/tools/azure-sdk-tools/tests/test_apistub.py b/eng/tools/azure-sdk-tools/tests/test_apistub.py index 85a60a407794..bcd14e066a36 100644 --- a/eng/tools/azure-sdk-tools/tests/test_apistub.py +++ b/eng/tools/azure-sdk-tools/tests/test_apistub.py @@ -86,7 +86,6 @@ def _make_args(self, dest_dir=None, generate_md=False): @patch( "azpysdk.apistub.REPO_ROOT", os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) ) - @patch("azpysdk.apistub.PYTHON_VERSION_LIMIT", (99, 99)) @patch("azpysdk.apistub.get_cross_language_mapping_path", return_value=None) @patch("azpysdk.apistub.get_package_wheel_path", return_value="/fake/pkg.whl") @patch("azpysdk.apistub.create_package_and_install") @@ -139,7 +138,6 @@ def fake_pwsh(cmd, **kwargs): @patch( "azpysdk.apistub.REPO_ROOT", os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) ) - @patch("azpysdk.apistub.PYTHON_VERSION_LIMIT", (99, 99)) @patch("azpysdk.apistub.get_cross_language_mapping_path", return_value=None) @patch("azpysdk.apistub.get_package_wheel_path", return_value="/fake/pkg.whl") @patch("azpysdk.apistub.create_package_and_install") @@ -191,7 +189,6 @@ def fake_pwsh(cmd, **kwargs): @patch( "azpysdk.apistub.REPO_ROOT", os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) ) - @patch("azpysdk.apistub.PYTHON_VERSION_LIMIT", (99, 99)) @patch("azpysdk.apistub.get_cross_language_mapping_path", return_value=None) @patch("azpysdk.apistub.get_package_wheel_path", return_value="/fake/pkg.whl") @patch("azpysdk.apistub.create_package_and_install") @@ -235,7 +232,6 @@ def fake_pwsh(cmd, **kwargs): @patch( "azpysdk.apistub.REPO_ROOT", os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) ) - @patch("azpysdk.apistub.PYTHON_VERSION_LIMIT", (99, 99)) @patch("azpysdk.apistub.get_cross_language_mapping_path", return_value=None) @patch("azpysdk.apistub.get_package_wheel_path", return_value="/fake/pkg.whl") @patch("azpysdk.apistub.create_package_and_install") From c75faacb50139ccca2e86385c1141ac7fe21878c Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Wed, 3 Jun 2026 13:55:29 -0700 Subject: [PATCH 17/33] Refactor `azpysdk apistub` command. Extract metadata from API.md. --- .github/skills/generate-api-markdown/SKILL.md | 2 +- .../api-md-consistency/api-md-consistency.js | 3 +- .../Extract-APIViewMetadata-Python.ps1 | 159 ++++++++++++++++++ eng/tools/azure-sdk-tools/azpysdk/apistub.py | 72 +++++--- .../azure-sdk-tools/tests/test_apistub.py | 4 +- scripts/api_md_workflow/README.md | 10 +- scripts/api_md_workflow/adapters/python.js | 2 +- scripts/api_md_workflow/find_mismatches.js | 2 + 8 files changed, 227 insertions(+), 27 deletions(-) create mode 100644 eng/scripts/Extract-APIViewMetadata-Python.ps1 diff --git a/.github/skills/generate-api-markdown/SKILL.md b/.github/skills/generate-api-markdown/SKILL.md index f3f96e32c839..4eebc5d43c38 100644 --- a/.github/skills/generate-api-markdown/SKILL.md +++ b/.github/skills/generate-api-markdown/SKILL.md @@ -19,5 +19,5 @@ description: Generate an API markdown file and token file using ApiView. Use thi 1. Navigate to the desired package directory 2. Run the command: ```bash - azpysdk apistub --md . + azpysdk apistub --md --extract-metadata . 3. The command outputs the location of the generated markdown file. Provide this file to the user for review. \ No newline at end of file diff --git a/.github/workflows/src/api-md-consistency/api-md-consistency.js b/.github/workflows/src/api-md-consistency/api-md-consistency.js index 050cef7c7ced..f375edb9c0f8 100644 --- a/.github/workflows/src/api-md-consistency/api-md-consistency.js +++ b/.github/workflows/src/api-md-consistency/api-md-consistency.js @@ -44,7 +44,7 @@ function formatIssueSection(title, apiFiles) { const packageName = path.basename(packageDir); lines.push(`- ${packageDir}`); lines.push(` API.md: ${apiFile}`); - lines.push(` Regenerate: azpysdk apistub --md ${packageName}`); + lines.push(` Regenerate: azpysdk apistub --md --extract-metadata ${packageName}`); } lines.push(""); return lines.join("\n"); @@ -88,6 +88,7 @@ module.exports = async function apiMdConsistency({ core }) { if (issueCount > 0) { const messageParts = [ "Generated API.md does not match committed API.md, or API.md is missing, for one or more affected packages.", + "API.metadata.yml is informational only (for troubleshooting API drift, e.g., parser/runtime differences) and is not part of pass/fail gating.", "", formatIssueSection("Mismatched packages:", mismatches), formatIssueSection("Missing API.md packages:", missing), diff --git a/eng/scripts/Extract-APIViewMetadata-Python.ps1 b/eng/scripts/Extract-APIViewMetadata-Python.ps1 new file mode 100644 index 000000000000..0cd62ec99e24 --- /dev/null +++ b/eng/scripts/Extract-APIViewMetadata-Python.ps1 @@ -0,0 +1,159 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. + +<# +.SYNOPSIS +Extracts Python APIView metadata from API markdown and writes API.metadata.yml. + +.DESCRIPTION +Reads an API markdown file, extracts parser and Python runtime versions from the +Python APIView metadata header, removes that header from the markdown, trims leading +blank lines from the markdown body, and writes API.metadata.yml beside the markdown file. + +.PARAMETER ApiMarkdownPath +Optional. Path to API markdown file. If omitted, a markdown file will be resolved +from OutputPath (prefers API.md, then api.md). + +.PARAMETER OutputPath +Optional. Directory containing API markdown output. Defaults to current directory. + +.EXAMPLE +./Extract-APIViewMetadata-Python.ps1 -OutputPath ./sdk/template/azure-template + +.EXAMPLE +./Extract-APIViewMetadata-Python.ps1 -ApiMarkdownPath ./sdk/template/azure-template/API.md +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string]$ApiMarkdownPath, + + [Parameter(Mandatory = $false)] + [string]$OutputPath = "." +) + +Set-StrictMode -Version 3 +$ErrorActionPreference = 'Stop' + +function Resolve-ApiMarkdownPath { + param( + [string]$ProvidedPath, + [string]$OutputDirectory + ) + + if ($ProvidedPath) { + return $ProvidedPath + } + + $resolvedOutput = Resolve-Path -LiteralPath $OutputDirectory -ErrorAction Stop + $apiUpper = Join-Path $resolvedOutput.Path "API.md" + if (Test-Path -LiteralPath $apiUpper -PathType Leaf) { + return $apiUpper + } + + $apiLower = Join-Path $resolvedOutput.Path "api.md" + if (Test-Path -LiteralPath $apiLower -PathType Leaf) { + return $apiLower + } + + throw "Could not find API markdown file in '$OutputDirectory'. Expected API.md or api.md." +} + +function Trim-LeadingBlankLines { + param([string[]]$Lines) + + $start = 0 + while ($start -lt $Lines.Count -and [string]::IsNullOrWhiteSpace($Lines[$start])) { + $start++ + } + + if ($start -eq 0) { + return $Lines + } + + if ($start -ge $Lines.Count) { + return @() + } + + return $Lines[$start..($Lines.Count - 1)] +} + +function Get-Sha256Hex { + param([string]$Text) + + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Text) + $hashBytes = $sha256.ComputeHash($bytes) + return ([System.BitConverter]::ToString($hashBytes)).Replace("-", "").ToLowerInvariant() + } + finally { + $sha256.Dispose() + } +} + +$resolvedApiPath = Resolve-ApiMarkdownPath -ProvidedPath $ApiMarkdownPath -OutputDirectory $OutputPath +if (-not (Test-Path -LiteralPath $resolvedApiPath -PathType Leaf)) { + throw "API markdown file not found: $resolvedApiPath" +} + +$metadataPattern = '^# Package is parsed using apiview-stub-generator\(version:([^\)]+)\), Python version:\s*([^\s]+)\s*$' + +$fileText = Get-Content -LiteralPath $resolvedApiPath -Raw +$lineEnding = if ($fileText -match "`r`n") { "`r`n" } else { "`n" } +$lines = $fileText -split '\r?\n' + +$metadata = [ordered]@{} +$filtered = [System.Collections.Generic.List[string]]::new() + +foreach ($line in $lines) { + $match = [regex]::Match($line, $metadataPattern) + if ($match.Success) { + # Alphabetical keys in output YAML. + $metadata['parserVersion'] = $match.Groups[1].Value + $metadata['pythonVersion'] = $match.Groups[2].Value + continue + } + + $filtered.Add($line) +} + +# Remove blank lines after opening fence so markdown body starts at namespace. +if ($filtered.Count -gt 0 -and $filtered[0].StartsWith('```')) { + $fence = $filtered[0] + $body = Trim-LeadingBlankLines -Lines @($filtered | Select-Object -Skip 1) + $rewritten = [System.Collections.Generic.List[string]]::new() + $rewritten.Add($fence) + foreach ($line in $body) { + $rewritten.Add($line) + } + $filtered = $rewritten +} +else { + $trimmed = Trim-LeadingBlankLines -Lines @($filtered) + $filtered = [System.Collections.Generic.List[string]]::new($trimmed) +} + +$normalizedLinesForHash = @($filtered | ForEach-Object { $_.TrimEnd() }) +$newlineForHash = [string][char]10 +$normalizedTextForHash = $normalizedLinesForHash -join $newlineForHash +$metadata['apiMdSha256'] = Get-Sha256Hex -Text $normalizedTextForHash + +Set-Content -LiteralPath $resolvedApiPath -Value ($filtered -join $lineEnding) -NoNewline -Encoding utf8 +Write-Host "Updated markdown: $resolvedApiPath" + +$metadataPath = Join-Path (Split-Path -Parent $resolvedApiPath) "API.metadata.yml" +if ($metadata.Count -gt 0) { + $yamlLines = [System.Collections.Generic.List[string]]::new() + foreach ($key in ($metadata.Keys | Sort-Object)) { + $yamlLines.Add(("{0}: {1}" -f $key, $metadata[$key])) + } + + Set-Content -LiteralPath $metadataPath -Value ($yamlLines -join $lineEnding) -Encoding utf8 + Write-Host "Generated metadata: $metadataPath" +} +elseif (Test-Path -LiteralPath $metadataPath) { + Remove-Item -LiteralPath $metadataPath -Force + Write-Host "Removed stale metadata: $metadataPath" +} diff --git a/eng/tools/azure-sdk-tools/azpysdk/apistub.py b/eng/tools/azure-sdk-tools/azpysdk/apistub.py index aa2e1e6dee5a..2c9d2ac1360a 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/apistub.py +++ b/eng/tools/azure-sdk-tools/azpysdk/apistub.py @@ -69,12 +69,30 @@ def register( action="store_true", help="Generate api.md from the JSON token file using Export-APIViewMarkdown.ps1. Output directory for api.md is the same as the generated token file.", ) + p.add_argument( + "--extract-metadata", + dest="extract_metadata", + default=False, + action="store_true", + help="Extract language-specific metadata from generated api.md into API.metadata.yml and remove metadata header from api.md.", + ) + p.add_argument( + "--install-deps", + dest="install_deps", + default=False, + action="store_true", + help="Install dev requirements and apiview dependencies before running. Skipped by default for faster local iteration.", + ) p.set_defaults(func=self.run) def run(self, args: argparse.Namespace) -> int: """Run the apistub check command.""" logger.info("Running apistub check...") + if getattr(args, "extract_metadata", False) and not getattr(args, "generate_md", False): + logger.error("--extract-metadata requires --md.") + return 1 + set_envvar_defaults() targeted = self.get_targeted_directories(args) @@ -94,22 +112,23 @@ def run(self, args: argparse.Namespace) -> int: ) logger.info(f"Processing {package_name} for apistub check") - # install dependencies - self.install_dev_reqs(executable, args, package_dir) - - try: - install_into_venv( - executable, - [ - "-r", - os.path.join(REPO_ROOT, "eng", "apiview_reqs.txt"), - "--index-url=https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-python/pypi/simple/", - ], - package_dir, - ) - except CalledProcessError as e: - logger.error(f"Failed to install dependencies: {e}") - return e.returncode + if getattr(args, "install_deps", False): + # install dependencies + self.install_dev_reqs(executable, args, package_dir) + + try: + install_into_venv( + executable, + [ + "-r", + os.path.join(REPO_ROOT, "eng", "apiview_reqs.txt"), + "--index-url=https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-python/pypi/simple/", + ], + package_dir, + ) + except CalledProcessError as e: + logger.error(f"Failed to install dependencies: {e}") + return e.returncode if not os.getenv("PREBUILT_WHEEL_DIR"): create_package_and_install( @@ -124,14 +143,15 @@ def run(self, args: argparse.Namespace) -> int: python_executable=executable, ) - self.pip_freeze(executable) + if getattr(args, "install_deps", False): + self.pip_freeze(executable) pkg_path = get_package_wheel_path(package_dir) pkg_path = os.path.abspath(pkg_path) dest_dir = getattr(args, "dest_dir", None) if dest_dir: - out_token_path = os.path.join(os.path.abspath(dest_dir), package_name) + out_token_path = os.path.abspath(dest_dir) os.makedirs(out_token_path, exist_ok=True) else: out_token_path = os.path.abspath(staging_directory) @@ -157,6 +177,9 @@ def run(self, args: argparse.Namespace) -> int: if getattr(args, "generate_md", False): token_json_path = os.path.join(out_token_path, f"{package_name}_python.json") md_script = os.path.join(REPO_ROOT, "eng", "common", "scripts", "Export-APIViewMarkdown.ps1") + metadata_script = os.path.join( + REPO_ROOT, "eng", "scripts", "Extract-APIViewMetadata-Python.ps1" + ) logger.info(f"Generating api.md for {package_name}") try: result = run( @@ -168,11 +191,22 @@ def run(self, args: argparse.Namespace) -> int: # pwsh script logs the api.md location if result.stdout: logger.info(result.stdout) + + if getattr(args, "extract_metadata", False): + logger.info(f"Extracting API metadata for {package_name}") + metadata_result = run( + ["pwsh", metadata_script, "-OutputPath", out_token_path], + check=True, + capture_output=True, + text=True, + ) + if metadata_result.stdout: + logger.info(metadata_result.stdout) except FileNotFoundError: logger.error("Failed to generate api.md: pwsh (PowerShell) is not installed or not on PATH.") results.append(1) except CalledProcessError as e: - logger.error(f"Failed to generate api.md (exit code {e.returncode}):") + logger.error(f"Failed to generate api.md or extract metadata (exit code {e.returncode}):") if e.stderr: logger.error(e.stderr) if e.stdout: diff --git a/eng/tools/azure-sdk-tools/tests/test_apistub.py b/eng/tools/azure-sdk-tools/tests/test_apistub.py index bcd14e066a36..b396629caeb8 100644 --- a/eng/tools/azure-sdk-tools/tests/test_apistub.py +++ b/eng/tools/azure-sdk-tools/tests/test_apistub.py @@ -94,7 +94,7 @@ def _make_args(self, dest_dir=None, generate_md=False): def test_dest_dir_creates_package_subfolder( self, _env, _install, _create, _get_whl, _get_mapping, tmp_path, monkeypatch ): - """When --dest-dir is given, output should go to //.""" + """When --dest-dir is given, output should go directly to /.""" monkeypatch.chdir(os.getcwd()) dest = tmp_path / "output" dest.mkdir() @@ -130,7 +130,7 @@ def fake_pwsh(cmd, **kwargs): stub.run(self._make_args(dest_dir=str(dest), generate_md=True)) - expected_out = os.path.join(str(dest), "azure-core") + expected_out = str(dest) assert os.path.isdir(expected_out) assert os.path.exists(os.path.join(expected_out, "api.md")) assert os.path.exists(os.path.join(expected_out, "azure-core_python.json")) diff --git a/scripts/api_md_workflow/README.md b/scripts/api_md_workflow/README.md index 9e6a97f7bbee..22d086c65833 100644 --- a/scripts/api_md_workflow/README.md +++ b/scripts/api_md_workflow/README.md @@ -5,6 +5,8 @@ This folder contains the helper scripts used by the GitHub Actions workflows tha ## Purpose The workflow validates that when a pull request changes one or more SDK packages, the committed `API.md` files are still up to date. +Only `API.md` is diff-gated by this workflow; `API.metadata.yml` is intentionally excluded from mismatch checks. +Use `API.metadata.yml` as diagnostic context when `API.md` drifts (for example, parser/runtime version differences), but it does not affect pass/fail. The logic is split between GitHub workflow YAML files and helper scripts in Python and JavaScript. @@ -20,7 +22,7 @@ It runs on pull requests for changes under `sdk/**`. - Regenerates `API.md` for those packages. - Fails if the generated files differ from the committed files. - Fails if an affected package does not have a committed `API.md`. -- Prints the mismatched or missing packages and the `azpysdk apistub --md` command needed to regenerate each `API.md` file. +- Prints the mismatched or missing packages and the `azpysdk apistub --md --extract-metadata` command needed to regenerate each `API.md` file. ## Script Layout @@ -47,7 +49,7 @@ Also writes `count=` to `GITHUB_OUTPUT`. ### `regenerate.js` -Reads package directories from `API_MD_PACKAGES_FILE` and runs `azpysdk apistub --md ` for each package. +Reads package directories from `API_MD_PACKAGES_FILE` and runs `azpysdk apistub --md --extract-metadata ` for each package. This script is used by the consistency check. @@ -60,6 +62,8 @@ Reads package directories from `API_MD_PACKAGES_FILE`, checks whether ` Also writes `mismatch_count=`, `missing_count=`, and `issue_count=` to `GITHUB_OUTPUT`. +`API.metadata.yml` is not part of this diff check. + ### `create_api_review_pr.js` and adapters API review PR creation now uses a shared JavaScript orchestrator with a language adapter boundary: @@ -96,4 +100,4 @@ Common variables include: 3. `find_affected.js` determines which packages were touched. 4. `regenerate.js` rebuilds `API.md` for those packages. 5. `find_mismatches.js` records any `API.md` drift, including missing or untracked `API.md` files. -6. If drift is found, the workflow fails and prints the affected packages plus the `azpysdk apistub --md ` command to regenerate each `API.md` file locally. +6. If drift is found, the workflow fails and prints the affected packages plus the `azpysdk apistub --md --extract-metadata ` command to regenerate each `API.md` file locally. diff --git a/scripts/api_md_workflow/adapters/python.js b/scripts/api_md_workflow/adapters/python.js index 6ca550d4ec65..529809275427 100644 --- a/scripts/api_md_workflow/adapters/python.js +++ b/scripts/api_md_workflow/adapters/python.js @@ -114,7 +114,7 @@ function generateApiForPackage({ activeLogger.info(`--- Generating API.md on ${refLabel} ---`); } - run("azpysdk", ["apistub", "--md", packageName], { + run("azpysdk", ["apistub", "--md", "--extract-metadata", packageName], { cwd: repoRoot, check: true, logger: activeLogger, diff --git a/scripts/api_md_workflow/find_mismatches.js b/scripts/api_md_workflow/find_mismatches.js index ba401156a461..4ce097a039b6 100644 --- a/scripts/api_md_workflow/find_mismatches.js +++ b/scripts/api_md_workflow/find_mismatches.js @@ -13,6 +13,8 @@ async function main() { const mismatches = []; const missing = []; for (const pkgDir of packages) { + // Deliberately scope consistency gating to API.md only. + // API.metadata.yml is generated sidecar metadata and is not diff-gated here. const apiFile = `${pkgDir}/API.md`; // Enforce that each affected package has a committed API.md file. From dfe985c46ac9958d937115e7dff7e03bcc52ac37 Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Wed, 3 Jun 2026 14:04:02 -0700 Subject: [PATCH 18/33] Add validation for select metadata fields. --- scripts/api_md_workflow/adapters/python.js | 5 ++ .../api_md_workflow/create_api_review_pr.js | 49 +++++++++++++++--- scripts/api_md_workflow/find_mismatches.js | 50 ++++++++++++++++++- 3 files changed, 95 insertions(+), 9 deletions(-) diff --git a/scripts/api_md_workflow/adapters/python.js b/scripts/api_md_workflow/adapters/python.js index 529809275427..860356c51874 100644 --- a/scripts/api_md_workflow/adapters/python.js +++ b/scripts/api_md_workflow/adapters/python.js @@ -121,10 +121,15 @@ function generateApiForPackage({ }); } +// Fields in API.metadata.yml that must match between working tree and committed version. +// pythonVersion is excluded because it varies across CI environments. +const metadataFieldsToValidate = ["apiMdSha256", "parserVersion"]; + module.exports = { name: "python", isPackageDir, findPackageDir, readVersion, generateApiForPackage, + metadataFieldsToValidate, }; diff --git a/scripts/api_md_workflow/create_api_review_pr.js b/scripts/api_md_workflow/create_api_review_pr.js index 09ea4e6f6081..c62904eba87f 100644 --- a/scripts/api_md_workflow/create_api_review_pr.js +++ b/scripts/api_md_workflow/create_api_review_pr.js @@ -173,6 +173,14 @@ function apiMdRel(packageDir) { return `${packageRelDir(packageDir)}/API.md`; } +function metadataPath(packageDir) { + return path.join(packageDir, "API.metadata.yml"); +} + +function metadataRel(packageDir) { + return `${packageRelDir(packageDir)}/API.metadata.yml`; +} + function findRealGitExe() { if (process.platform !== "win32") { return null; @@ -389,7 +397,14 @@ function generateApiBytesForPackage({ throw new Error(`ERROR: did not produce ${outputPath}`); } - return fs.readFileSync(outputPath); + const result = { apiMd: fs.readFileSync(outputPath), metadata: null }; + + const metaPath = metadataPath(packageDir); + if (fs.existsSync(metaPath)) { + result.metadata = fs.readFileSync(metaPath); + } + + return result; } function main() { @@ -415,11 +430,11 @@ function main() { const targetRef = args.target ? resolveTargetRef(args.target) : MAIN_REF; try { - let baseApiBytes = null; + let baseResult = null; if (args.base) { logInfo(`\n=== Capturing baseline API.md from tag ${args.base} ===`); git(["checkout", "--detach", args.base]); - baseApiBytes = generateApiBytesForPackage({ + baseResult = generateApiBytesForPackage({ adapter, repoRoot: REPO_ROOT, packageName: args.packageName, @@ -434,7 +449,7 @@ function main() { logInfo(`\n=== Capturing target API.md from ${targetRef} ===`); git(["checkout", "--detach", targetRef]); const targetVersion = adapter.readVersion(packageDir); - const targetApiBytes = generateApiBytesForPackage({ + const targetResult = generateApiBytesForPackage({ adapter, repoRoot: REPO_ROOT, packageName: args.packageName, @@ -453,10 +468,16 @@ function main() { const apiPath = apiMdPath(packageDir); const apiRelative = apiMdRel(packageDir); + const metaFilePath = metadataPath(packageDir); + const metaRelative = metadataRel(packageDir); - if (baseApiBytes !== null) { - writeBytes(apiPath, baseApiBytes); + if (baseResult !== null) { + writeBytes(apiPath, baseResult.apiMd); git(["add", apiRelative]); + if (baseResult.metadata) { + writeBytes(metaFilePath, baseResult.metadata); + git(["add", metaRelative]); + } git(["commit", "-m", `[API Review] Baseline API.md for ${args.packageName} ${baseVersion}`]); } else { const tracked = git(["ls-files", "--error-unmatch", apiRelative], { @@ -466,11 +487,21 @@ function main() { if (tracked.status === 0) { git(["rm", apiRelative]); + const metaTracked = git(["ls-files", "--error-unmatch", metaRelative], { + capture: true, + check: false, + }); + if (metaTracked.status === 0) { + git(["rm", metaRelative]); + } git(["commit", "-m", `[API Review] Remove API.md for ${args.packageName} (empty baseline)`]); } else { if (fs.existsSync(apiPath)) { fs.unlinkSync(apiPath); } + if (fs.existsSync(metaFilePath)) { + fs.unlinkSync(metaFilePath); + } git(["commit", "--allow-empty", "-m", `[API Review] Empty baseline for ${args.packageName}`]); } } @@ -479,8 +510,12 @@ function main() { logInfo(`\n=== Creating review branch ${reviewBranch} ===`); git(["checkout", "-B", reviewBranch, baseBranch]); - writeBytes(apiPath, targetApiBytes); + writeBytes(apiPath, targetResult.apiMd); git(["add", apiRelative]); + if (targetResult.metadata) { + writeBytes(metaFilePath, targetResult.metadata); + git(["add", metaRelative]); + } const diff = git(["diff", "--cached", "--quiet"], { capture: true, diff --git a/scripts/api_md_workflow/find_mismatches.js b/scripts/api_md_workflow/find_mismatches.js index 4ce097a039b6..da9cbf6376d5 100644 --- a/scripts/api_md_workflow/find_mismatches.js +++ b/scripts/api_md_workflow/find_mismatches.js @@ -3,8 +3,31 @@ const fs = require("fs"); const { appendGithubOutput, envPath, getDefaultLogger, readLines, runAsync, writeLines } = require("./common"); +const { loadAdapter, loadWorkflowConfig } = require("./adapter_config"); + +/** + * Parse a simple key: value YAML file into an object. + * Only handles flat scalar mappings (no nesting, no multi-line values). + */ +function parseSimpleYaml(text) { + const result = {}; + for (const line of text.split(/\r?\n/)) { + const match = line.match(/^(\w+)\s*:\s*(.*)$/); + if (match) { + result[match[1]] = match[2].trim(); + } + } + return result; +} async function main() { + const config = loadWorkflowConfig(); + const adapter = loadAdapter(config.adapter); + + // Fields to compare in API.metadata.yml. If the adapter doesn't specify, + // compare all fields (strict default for languages that don't opt out). + const fieldsToValidate = adapter.metadataFieldsToValidate || null; + const packagesFile = envPath("API_MD_PACKAGES_FILE", ".artifacts/affected_package_dirs.txt"); const mismatchesFile = envPath("API_MD_MISMATCHES_FILE", ".artifacts/mismatched_api_files.txt"); const missingFile = envPath("API_MD_MISSING_FILE", ".artifacts/missing_api_files.txt"); @@ -13,9 +36,8 @@ async function main() { const mismatches = []; const missing = []; for (const pkgDir of packages) { - // Deliberately scope consistency gating to API.md only. - // API.metadata.yml is generated sidecar metadata and is not diff-gated here. const apiFile = `${pkgDir}/API.md`; + const metadataFile = `${pkgDir}/API.metadata.yml`; // Enforce that each affected package has a committed API.md file. if (!fs.existsSync(apiFile) || !fs.statSync(apiFile).isFile()) { @@ -31,6 +53,30 @@ async function main() { continue; } + // API.metadata.yml must be present alongside API.md. + if (!fs.existsSync(metadataFile) || !fs.statSync(metadataFile).isFile()) { + missing.push(metadataFile); + } else { + const committedMeta = await runAsync("git", ["show", `HEAD:${metadataFile}`], { + check: false, + }); + if (committedMeta.status !== 0) { + // Not yet committed — treat as missing + missing.push(metadataFile); + } else { + const current = parseSimpleYaml(fs.readFileSync(metadataFile, "utf-8")); + const committed = parseSimpleYaml(committedMeta.stdout); + + // Compare only adapter-specified fields, or all fields if not specified. + const keys = fieldsToValidate || Object.keys({ ...committed, ...current }); + const mismatch = keys.some((key) => current[key] !== committed[key]); + if (mismatch) { + mismatches.push(metadataFile); + } + } + } + + // Diff-gate only API.md; metadata content differences are acceptable. const quietDiffResult = await runAsync("git", ["diff", "--quiet", "--", apiFile], { check: false, }); From 9d8137c90813be1643ea7e092cb83867c77fc772 Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Wed, 3 Jun 2026 14:22:16 -0700 Subject: [PATCH 19/33] CI fixes. --- eng/tools/azure-sdk-tools/azpysdk/apistub.py | 4 +--- scripts/api_md_workflow/adapters/python.js | 2 +- scripts/api_md_workflow/find_affected.js | 2 +- sdk/template/azure-template/API.md | 3 --- sdk/template/azure-template/API.metadata.yml | 3 +++ 5 files changed, 6 insertions(+), 8 deletions(-) create mode 100644 sdk/template/azure-template/API.metadata.yml diff --git a/eng/tools/azure-sdk-tools/azpysdk/apistub.py b/eng/tools/azure-sdk-tools/azpysdk/apistub.py index 2c9d2ac1360a..f61371477fc0 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/apistub.py +++ b/eng/tools/azure-sdk-tools/azpysdk/apistub.py @@ -177,9 +177,7 @@ def run(self, args: argparse.Namespace) -> int: if getattr(args, "generate_md", False): token_json_path = os.path.join(out_token_path, f"{package_name}_python.json") md_script = os.path.join(REPO_ROOT, "eng", "common", "scripts", "Export-APIViewMarkdown.ps1") - metadata_script = os.path.join( - REPO_ROOT, "eng", "scripts", "Extract-APIViewMetadata-Python.ps1" - ) + metadata_script = os.path.join(REPO_ROOT, "eng", "scripts", "Extract-APIViewMetadata-Python.ps1") logger.info(f"Generating api.md for {package_name}") try: result = run( diff --git a/scripts/api_md_workflow/adapters/python.js b/scripts/api_md_workflow/adapters/python.js index 860356c51874..8e5b19149e60 100644 --- a/scripts/api_md_workflow/adapters/python.js +++ b/scripts/api_md_workflow/adapters/python.js @@ -114,7 +114,7 @@ function generateApiForPackage({ activeLogger.info(`--- Generating API.md on ${refLabel} ---`); } - run("azpysdk", ["apistub", "--md", "--extract-metadata", packageName], { + run("azpysdk", ["apistub", "--md", "--extract-metadata", "--install-deps", packageName], { cwd: repoRoot, check: true, logger: activeLogger, diff --git a/scripts/api_md_workflow/find_affected.js b/scripts/api_md_workflow/find_affected.js index 25ace58d9ca8..51047e732bd5 100644 --- a/scripts/api_md_workflow/find_affected.js +++ b/scripts/api_md_workflow/find_affected.js @@ -30,7 +30,7 @@ async function main() { cwd: REPO_ROOT, }); const diff = ( - await runAsync("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`], { + await runAsync("git", ["diff", "--name-only", `origin/${baseRef}..HEAD`], { cwd: REPO_ROOT, }) ).stdout; diff --git a/sdk/template/azure-template/API.md b/sdk/template/azure-template/API.md index 809d8a57143d..638c13dae01a 100644 --- a/sdk/template/azure-template/API.md +++ b/sdk/template/azure-template/API.md @@ -1,7 +1,4 @@ ```py -# Package is parsed using apiview-stub-generator(version:0.3.28), Python version: 3.12.13 - - namespace azure.template def azure.template.template_main() -> bool: ... diff --git a/sdk/template/azure-template/API.metadata.yml b/sdk/template/azure-template/API.metadata.yml new file mode 100644 index 000000000000..f29742a2b179 --- /dev/null +++ b/sdk/template/azure-template/API.metadata.yml @@ -0,0 +1,3 @@ +apiMdSha256: 9b0fa6154e3a859680da1a07f5106508983884de567522c5166fc57bacb9cb00 +parserVersion: 0.3.28 +pythonVersion: 3.12.9 From 760fc636c2a0d02c69cd597f938349435a7d9aaf Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Wed, 3 Jun 2026 14:55:43 -0700 Subject: [PATCH 20/33] Add skill for generating review PR. --- .github/skills/create-api-review-pr/SKILL.md | 76 ++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .github/skills/create-api-review-pr/SKILL.md diff --git a/.github/skills/create-api-review-pr/SKILL.md b/.github/skills/create-api-review-pr/SKILL.md new file mode 100644 index 000000000000..5a399f925fbd --- /dev/null +++ b/.github/skills/create-api-review-pr/SKILL.md @@ -0,0 +1,76 @@ +--- +name: create-api-review-pr +description: Create a GitHub PR for API review by comparing a baseline API surface against a target branch. Use this when the user wants to create an API review PR, compare API changes between versions, or review API surface differences for a package. +--- + +# Create API Review PR + +Creates a dedicated API review PR that shows the diff between a baseline release and a target branch's API surface using `scripts/api_md_workflow/create_api_review_pr.js`. + +## Prerequisites + +1. The user must have `gh` CLI authenticated (`gh auth login`). +2. The working tree must be clean (no uncommitted changes). +3. Node.js must be installed. +4. `azpysdk` must be installed (`pip install -e ./eng/tools/azure-sdk-tools`). + +## Information to Gather + +Ask the user for the following using `vscode_askQuestions`: + +### 1. Package Name (required) +The Azure SDK package name (e.g. `azure-storage-blob`, `azure-ai-projects`). + +### 2. Baseline (optional) +The release tag to use as the baseline for comparison. Tags follow the format `_` (e.g. `azure-storage-blob_12.29.0`). + +- If the user provides a package name and version separately, construct the tag as `_`. +- If this is a **new package** with no prior release, the baseline should be omitted (the script handles this as an empty baseline). + +### 3. Target (optional) +The branch or PR to generate the "current" API surface from. Can be: +- A branch name (e.g. `main`, `feature-branch`) — fetched from `origin` +- An `owner:branch` reference (e.g. `someone:their-branch`) — fetched from the fork +- If omitted, defaults to `origin/main` + +## Validation Steps + +Before running the script: + +1. **Validate the package exists**: Confirm a directory matching `sdk/*/` exists with a `pyproject.toml` or `setup.py`. +2. **Validate the baseline tag** (if provided): Run `git tag -l ""` to confirm the tag exists. If the user provided a version like `12.29.0`, construct the full tag as `_` and validate that. +3. **Confirm the working tree is clean**: Run `git status --porcelain` and warn if there are uncommitted changes. + +## Execution + +Run the following command from the repository root: + +```bash +node scripts/api_md_workflow/create_api_review_pr.js --package-name [--base ] [--target ] +``` + +### Examples + +**Standard review (comparing a release tag to a PR branch):** +```bash +node scripts/api_md_workflow/create_api_review_pr.js --package-name azure-storage-blob --base azure-storage-blob_12.29.0 --target someone:feature-branch +``` + +**Review against main (no target specified):** +```bash +node scripts/api_md_workflow/create_api_review_pr.js --package-name azure-cosmos --base azure-cosmos_4.14.0 +``` + +**New package (no baseline):** +```bash +node scripts/api_md_workflow/create_api_review_pr.js --package-name azure-keyvault-secrets --target main +``` + +## Post-Execution + +The script will: +1. Generate `API.md` for both baseline and target +2. Push `base__` and `review__` branches +3. Open a draft PR (or print a compare URL if `gh pr create` fails) + +Report the PR URL to the user when complete. From 1517d5825c94efe7caa461a1a6fa74ded6820a0b Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Wed, 3 Jun 2026 15:04:20 -0700 Subject: [PATCH 21/33] Update review generation logic. --- .../api_md_workflow/create_api_review_pr.js | 88 +++++++++---------- 1 file changed, 41 insertions(+), 47 deletions(-) diff --git a/scripts/api_md_workflow/create_api_review_pr.js b/scripts/api_md_workflow/create_api_review_pr.js index c62904eba87f..8d70101b66c4 100644 --- a/scripts/api_md_workflow/create_api_review_pr.js +++ b/scripts/api_md_workflow/create_api_review_pr.js @@ -355,56 +355,52 @@ function writeBytes(filePath, bytes) { fs.writeFileSync(filePath, bytes); } -function discardTemporaryWorktreeChanges() { - const status = git(["status", "--porcelain"], { capture: true }).stdout.trim(); - if (!status) { - return; - } - - const marker = `api-md-workflow-temp-${Date.now()}`; - git(["stash", "push", "--include-untracked", "-m", marker]); - - const topEntry = git(["stash", "list", "-n", "1", "--format=%gd %s"], { - capture: true, - }).stdout.trim(); - - if (!topEntry.includes(marker)) { - throw new Error("ERROR: failed to identify temporary stash entry while cleaning generated files."); - } - - git(["stash", "drop", "stash@{0}"]); -} - -function generateApiBytesForPackage({ +function generateApiBytesForRef({ adapter, repoRoot, packageName, packageDir, runtimeExecutable, + ref, refLabel, logger, }) { - adapter.generateApiForPackage({ - repoRoot, - packageName, - runtimeExecutable, - logger, - refLabel, - }); + const packageRelative = packageRelDir(packageDir); + logInfo(`Overlaying package source from ${refLabel} (${ref})`); - const outputPath = apiMdPath(packageDir); - if (!fs.existsSync(outputPath)) { - throw new Error(`ERROR: did not produce ${outputPath}`); - } + // Overlay just the package directory from the target ref onto the working tree + git(["checkout", ref, "--", packageRelative]); - const result = { apiMd: fs.readFileSync(outputPath), metadata: null }; + try { + const version = adapter.readVersion(packageDir); - const metaPath = metadataPath(packageDir); - if (fs.existsSync(metaPath)) { - result.metadata = fs.readFileSync(metaPath); - } + adapter.generateApiForPackage({ + repoRoot, + packageName, + runtimeExecutable, + logger, + refLabel, + }); - return result; + const outputPath = apiMdPath(packageDir); + if (!fs.existsSync(outputPath)) { + throw new Error(`ERROR: did not produce ${outputPath}`); + } + + const result = { apiMd: fs.readFileSync(outputPath), metadata: null, version }; + + const metaPath = metadataPath(packageDir); + if (fs.existsSync(metaPath)) { + result.metadata = fs.readFileSync(metaPath); + } + + return result; + } finally { + // Restore the package directory to the current branch state + git(["checkout", "HEAD", "--", packageRelative]); + // Clean any untracked files that the generation may have left behind + run("git", ["clean", "-fd", "--", packageRelative], { check: false }); + } } function main() { @@ -433,32 +429,30 @@ function main() { let baseResult = null; if (args.base) { logInfo(`\n=== Capturing baseline API.md from tag ${args.base} ===`); - git(["checkout", "--detach", args.base]); - baseResult = generateApiBytesForPackage({ + baseResult = generateApiBytesForRef({ adapter, repoRoot: REPO_ROOT, packageName: args.packageName, packageDir, runtimeExecutable: args.runtimeExecutable, - refLabel: currentBranchOrSha(), + ref: args.base, + refLabel: args.base, logger, }); - discardTemporaryWorktreeChanges(); } logInfo(`\n=== Capturing target API.md from ${targetRef} ===`); - git(["checkout", "--detach", targetRef]); - const targetVersion = adapter.readVersion(packageDir); - const targetResult = generateApiBytesForPackage({ + const targetResult = generateApiBytesForRef({ adapter, repoRoot: REPO_ROOT, packageName: args.packageName, packageDir, runtimeExecutable: args.runtimeExecutable, - refLabel: currentBranchOrSha(), + ref: targetRef, + refLabel: targetRef, logger, }); - discardTemporaryWorktreeChanges(); + const targetVersion = targetResult.version; const baseBranch = `base_${args.packageName}_${baseVersion}`; const reviewBranch = `review_${args.packageName}_${targetVersion}`; From 258a36a9c74d5064724e3bbda878fa0777660dd8 Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Wed, 3 Jun 2026 15:14:20 -0700 Subject: [PATCH 22/33] fix(python adapter): add shell:true on Windows for spawnSync --- scripts/api_md_workflow/adapters/python.js | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/api_md_workflow/adapters/python.js b/scripts/api_md_workflow/adapters/python.js index 8e5b19149e60..6c3a9f063386 100644 --- a/scripts/api_md_workflow/adapters/python.js +++ b/scripts/api_md_workflow/adapters/python.js @@ -13,6 +13,7 @@ function run(cmd, args, options = {}) { env: options.env, encoding: "utf-8", stdio: options.capture ? "pipe" : "inherit", + shell: process.platform === "win32", }); if ((options.check ?? true) && result.status !== 0) { From 89e6c983c9c3dd722be67d06d323bbcdce8e7b09 Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Wed, 3 Jun 2026 15:16:31 -0700 Subject: [PATCH 23/33] fix(python adapter): pass --dest-dir so API.md lands in package dir --- scripts/api_md_workflow/adapters/python.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/api_md_workflow/adapters/python.js b/scripts/api_md_workflow/adapters/python.js index 6c3a9f063386..979ef81003a2 100644 --- a/scripts/api_md_workflow/adapters/python.js +++ b/scripts/api_md_workflow/adapters/python.js @@ -115,7 +115,8 @@ function generateApiForPackage({ activeLogger.info(`--- Generating API.md on ${refLabel} ---`); } - run("azpysdk", ["apistub", "--md", "--extract-metadata", "--install-deps", packageName], { + const packageDir = findPackageDir(repoRoot, packageName); + run("azpysdk", ["apistub", "--md", "--extract-metadata", "--install-deps", "--dest-dir", packageDir, packageName], { cwd: repoRoot, check: true, logger: activeLogger, From 62f81e30fe2a6f06af0f041267d5bae36f5a5090 Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Wed, 3 Jun 2026 15:21:10 -0700 Subject: [PATCH 24/33] fix(python adapter): skip _generated dirs in readVersion to avoid stale versions --- scripts/api_md_workflow/adapters/python.js | 5 + .../api_md_workflow/create_api_review_pr.js | 178 ++++++++++++++---- 2 files changed, 142 insertions(+), 41 deletions(-) diff --git a/scripts/api_md_workflow/adapters/python.js b/scripts/api_md_workflow/adapters/python.js index 979ef81003a2..7f368f3527fc 100644 --- a/scripts/api_md_workflow/adapters/python.js +++ b/scripts/api_md_workflow/adapters/python.js @@ -83,6 +83,11 @@ function readVersion(packageDir) { for (const file of walkFiles(packageDir)) { const name = path.basename(file); if (name === "_version.py" || name === "version.py") { + // Skip generated code directories — they often contain stale versions + const relative = path.relative(packageDir, file); + if (relative.includes("_generated") || relative.includes("generated_")) { + continue; + } candidates.push(file); } } diff --git a/scripts/api_md_workflow/create_api_review_pr.js b/scripts/api_md_workflow/create_api_review_pr.js index 8d70101b66c4..a6bb497ccda4 100644 --- a/scripts/api_md_workflow/create_api_review_pr.js +++ b/scripts/api_md_workflow/create_api_review_pr.js @@ -89,8 +89,40 @@ function run(cmd, args, options = {}) { return result; } +let cachedGitExecutable = undefined; +let cachedGitAwareEnv = undefined; + +function resolveGitExecutable() { + if (cachedGitExecutable !== undefined) { + return cachedGitExecutable; + } + + if (process.platform !== "win32") { + const resolved = spawnSync("git", ["--exec-path"], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: process.env, + }); + cachedGitExecutable = resolved.status === 0 ? "git" : "git"; + return cachedGitExecutable; + } + + cachedGitExecutable = findPreferredGitExecutable() || "git"; + return cachedGitExecutable; +} + function git(args, options = {}) { - return run("git", args, options); + return run(resolveGitExecutable(), args, { + ...options, + env: buildGitAwareEnv(options.env), + }); +} + +function gh(args, options = {}) { + return run("gh", args, { + ...options, + env: buildGitAwareEnv(options.env), + }); } function gitOut(args) { @@ -181,49 +213,117 @@ function metadataRel(packageDir) { return `${packageRelDir(packageDir)}/API.metadata.yml`; } -function findRealGitExe() { +function apiReviewBranchName(kind, packageName, version) { + return `apireview/${kind}_${packageName}_${version}`; +} + +function scoreGitCandidate(candidate) { + const normalized = candidate.replace(/\//g, "\\").toLowerCase(); + if (normalized.includes("\\program files\\git\\cmd\\git.exe")) { + return 0; + } + + if (normalized.includes("\\program files\\git\\bin\\git.exe")) { + return 1; + } + + if (normalized.includes("\\git\\cmd\\git.exe")) { + return 2; + } + + if (normalized.includes("\\git\\bin\\git.exe")) { + return 3; + } + + if (normalized.includes("\\windows\\")) { + return 100; + } + + return 10; +} + +function findPreferredGitExecutable() { if (process.platform !== "win32") { return null; } - const seen = new Set(); - const entries = (process.env.PATH || "").split(path.delimiter); - for (const rawEntry of entries) { - const entry = rawEntry.replace(/^"|"$/g, ""); - if (!entry) { + const candidates = new Set(); + const roots = [process.env.ProgramW6432, process.env.ProgramFiles, process.env["ProgramFiles(x86)"], process.env.LocalAppData]; + for (const root of roots) { + if (!root) { continue; } - const key = entry.toLowerCase(); - if (seen.has(key)) { + candidates.add(path.join(root, "Git", "cmd", "git.exe")); + candidates.add(path.join(root, "Git", "bin", "git.exe")); + candidates.add(path.join(root, "Programs", "Git", "cmd", "git.exe")); + candidates.add(path.join(root, "Programs", "Git", "bin", "git.exe")); + } + + for (const rawEntry of (process.env.PATH || "").split(path.delimiter)) { + const entry = rawEntry.replace(/^"|"$/g, ""); + if (!entry) { continue; } - seen.add(key); - const candidate = path.join(entry, "git.exe"); - if (fs.existsSync(candidate)) { - return candidate; - } + candidates.add(path.join(entry, "git.exe")); } - const fallback = "C:\\Program Files\\Git\\cmd\\git.exe"; - return fs.existsSync(fallback) ? fallback : null; + const existing = [...candidates].filter((candidate) => fs.existsSync(candidate)); + existing.sort((left, right) => scoreGitCandidate(left) - scoreGitCandidate(right) || left.localeCompare(right)); + return existing[0] || null; } -function envWithRealGit() { - const env = { ...process.env }; - const realGit = findRealGitExe(); - if (!realGit) { - return env; +function getGitExecPath(gitExecutable) { + if (process.platform === "win32" && !path.isAbsolute(gitExecutable)) { + return null; + } + + const result = spawnSync(gitExecutable, ["--exec-path"], { + cwd: REPO_ROOT, + encoding: "utf-8", + }); + + if (result.status !== 0) { + return null; + } + + return result.stdout.trim() || null; +} + +function samePathEntry(left, right) { + if (process.platform === "win32") { + return left.replace(/\\+$/, "").toLowerCase() === right.replace(/\\+$/, "").toLowerCase(); + } + + return left === right; +} + +function buildGitAwareEnv(baseEnv = process.env) { + if (baseEnv === process.env && cachedGitAwareEnv) { + return cachedGitAwareEnv; + } + + const env = { ...baseEnv }; + const gitExecutable = resolveGitExecutable(); + const gitExecPath = getGitExecPath(gitExecutable); + + if (path.isAbsolute(gitExecutable)) { + const gitDir = path.dirname(gitExecutable); + const currentEntries = (env.PATH || "").split(path.delimiter).filter(Boolean); + const first = currentEntries[0] || ""; + if (!first || !samePathEntry(first, gitDir)) { + env.PATH = [gitDir, ...currentEntries].join(path.delimiter); + logInfo(`(using resolved git executable: ${gitExecutable})`); + } + } + + if (gitExecPath) { + env.GIT_EXEC_PATH = gitExecPath; } - const gitDir = path.dirname(realGit); - const current = env.PATH || ""; - const parts = current.split(path.delimiter); - const first = parts[0] || ""; - if (first.replace(/\\+$/, "").toLowerCase() !== gitDir.replace(/\\+$/, "").toLowerCase()) { - env.PATH = `${gitDir}${path.delimiter}${current}`; - logInfo(`(prepending real git to PATH for gh: ${gitDir})`); + if (baseEnv === process.env) { + cachedGitAwareEnv = env; } return env; @@ -253,7 +353,6 @@ function selectBestPr(prs) { } function findOpenPrForHead(headSelector) { - const env = envWithRealGit(); const selectors = [headSelector]; if (headSelector.includes(":")) { const branchOnly = headSelector.split(":", 2)[1]; @@ -264,8 +363,7 @@ function findOpenPrForHead(headSelector) { const allPrs = []; for (const selector of selectors) { - const direct = run( - "gh", + const direct = gh( [ "pr", "list", @@ -280,7 +378,7 @@ function findOpenPrForHead(headSelector) { "--limit", "50", ], - { check: false, capture: true, env }, + { check: false, capture: true }, ); if (direct.status === 0) { @@ -293,8 +391,7 @@ function findOpenPrForHead(headSelector) { for (const selector of selectors) { const searchQuery = `repo:Azure/azure-sdk-for-python head:${selector}`; - const search = run( - "gh", + const search = gh( [ "pr", "list", @@ -309,7 +406,7 @@ function findOpenPrForHead(headSelector) { "--limit", "50", ], - { check: false, capture: true, env }, + { check: false, capture: true }, ); if (search.status === 0) { @@ -399,7 +496,7 @@ function generateApiBytesForRef({ // Restore the package directory to the current branch state git(["checkout", "HEAD", "--", packageRelative]); // Clean any untracked files that the generation may have left behind - run("git", ["clean", "-fd", "--", packageRelative], { check: false }); + git(["clean", "-fd", "--", packageRelative], { check: false }); } } @@ -454,8 +551,8 @@ function main() { }); const targetVersion = targetResult.version; - const baseBranch = `base_${args.packageName}_${baseVersion}`; - const reviewBranch = `review_${args.packageName}_${targetVersion}`; + const baseBranch = apiReviewBranchName("base", args.packageName, baseVersion); + const reviewBranch = apiReviewBranchName("review", args.packageName, targetVersion); logInfo(`\n=== Creating base branch ${baseBranch} ===`); git(["checkout", "-B", baseBranch, MAIN_REF]); @@ -548,8 +645,7 @@ function main() { logInfo("\n=== Opening PR ==="); const compareUrl = `https://github.com/Azure/azure-sdk-for-python/compare/${baseBranch}...${reviewBranch}?expand=1`; - const prCreate = run( - "gh", + const prCreate = gh( [ "pr", "create", @@ -565,7 +661,7 @@ function main() { body, "--draft", ], - { check: false, env: envWithRealGit() }, + { check: false }, ); if (prCreate.status !== 0) { From 92e0c9232c760a0ef25bcf50f24d29a7a7cd6409 Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Thu, 4 Jun 2026 09:37:34 -0700 Subject: [PATCH 25/33] Make script idempotent and reuse branches with the same API hash. --- scripts/api_md_workflow/adapters/python.js | 23 +- .../api_md_workflow/create_api_review_pr.js | 656 ++++++++++++++---- 2 files changed, 528 insertions(+), 151 deletions(-) diff --git a/scripts/api_md_workflow/adapters/python.js b/scripts/api_md_workflow/adapters/python.js index 7f368f3527fc..267d9ea9a4fd 100644 --- a/scripts/api_md_workflow/adapters/python.js +++ b/scripts/api_md_workflow/adapters/python.js @@ -13,9 +13,14 @@ function run(cmd, args, options = {}) { env: options.env, encoding: "utf-8", stdio: options.capture ? "pipe" : "inherit", - shell: process.platform === "win32", + shell: options.shell ?? false, }); + if (result.error) { + const errorMessage = result.error instanceof Error ? result.error.message : String(result.error); + throw new Error(`Command failed to start: ${printable}\n${errorMessage}`); + } + if ((options.check ?? true) && result.status !== 0) { throw new Error(`Command failed (${result.status}): ${printable}`); } @@ -112,6 +117,7 @@ function readVersion(packageDir) { function generateApiForPackage({ repoRoot, packageName, + runtimeExecutable, logger, refLabel, }) { @@ -121,10 +127,25 @@ function generateApiForPackage({ } const packageDir = findPackageDir(repoRoot, packageName); + if (runtimeExecutable || process.env.RUNTIME_EXECUTABLE) { + const pythonExecutable = runtimeExecutable || process.env.RUNTIME_EXECUTABLE; + run( + pythonExecutable, + ["-m", "azpysdk.main", "apistub", "--md", "--extract-metadata", "--install-deps", "--dest-dir", packageDir, packageName], + { + cwd: repoRoot, + check: true, + logger: activeLogger, + }, + ); + return; + } + run("azpysdk", ["apistub", "--md", "--extract-metadata", "--install-deps", "--dest-dir", packageDir, packageName], { cwd: repoRoot, check: true, logger: activeLogger, + shell: process.platform === "win32", }); } diff --git a/scripts/api_md_workflow/create_api_review_pr.js b/scripts/api_md_workflow/create_api_review_pr.js index a6bb497ccda4..1a0e338b02a8 100644 --- a/scripts/api_md_workflow/create_api_review_pr.js +++ b/scripts/api_md_workflow/create_api_review_pr.js @@ -80,8 +80,14 @@ function run(cmd, args, options = {}) { env: options.env, encoding: "utf-8", stdio: options.capture ? "pipe" : "inherit", + shell: false, }); + if (result.error) { + const errorMessage = result.error instanceof Error ? result.error.message : String(result.error); + throw new Error(`Command failed to start: ${printable}\n${errorMessage}`); + } + if ((options.check ?? true) && result.status !== 0) { throw new Error(`Command failed (${result.status}): ${printable}`); } @@ -260,7 +266,8 @@ function findPreferredGitExecutable() { candidates.add(path.join(root, "Programs", "Git", "bin", "git.exe")); } - for (const rawEntry of (process.env.PATH || "").split(path.delimiter)) { + const pathKey = getPathEnvKey(process.env); + for (const rawEntry of (process.env[pathKey] || "").split(path.delimiter)) { const entry = rawEntry.replace(/^"|"$/g, ""); if (!entry) { continue; @@ -299,21 +306,26 @@ function samePathEntry(left, right) { return left === right; } +function getPathEnvKey(env) { + return Object.keys(env).find((key) => key.toLowerCase() === "path") || "PATH"; +} + function buildGitAwareEnv(baseEnv = process.env) { if (baseEnv === process.env && cachedGitAwareEnv) { return cachedGitAwareEnv; } const env = { ...baseEnv }; + const pathKey = getPathEnvKey(env); const gitExecutable = resolveGitExecutable(); const gitExecPath = getGitExecPath(gitExecutable); if (path.isAbsolute(gitExecutable)) { const gitDir = path.dirname(gitExecutable); - const currentEntries = (env.PATH || "").split(path.delimiter).filter(Boolean); + const currentEntries = (env[pathKey] || "").split(path.delimiter).filter(Boolean); const first = currentEntries[0] || ""; if (!first || !samePathEntry(first, gitDir)) { - env.PATH = [gitDir, ...currentEntries].join(path.delimiter); + env[pathKey] = [gitDir, ...currentEntries].join(path.delimiter); logInfo(`(using resolved git executable: ${gitExecutable})`); } } @@ -338,6 +350,192 @@ function parseJsonOrNull(text) { } } +function parseJsonObjectOrNull(text) { + try { + const value = JSON.parse(text || "null"); + return value && typeof value === "object" && !Array.isArray(value) ? value : null; + } catch { + return null; + } +} + +function parseSimpleYaml(text) { + const result = {}; + for (const line of text.split(/\r?\n/)) { + const match = line.match(/^(\w+)\s*:\s*(.*)$/); + if (match) { + result[match[1]] = match[2].trim(); + } + } + return result; +} + +function metadataShaOrNull(metadataBytes) { + if (!metadataBytes) { + return null; + } + + const metadata = parseSimpleYaml(metadataBytes.toString("utf-8")); + return metadata.apiMdSha256 || null; +} + +function branchRemoteRef(branch) { + return `${REMOTE}/${branch}`; +} + +function listRemoteBranchesWithPrefix(prefix) { + const result = git(["ls-remote", "--heads", REMOTE, `refs/heads/${prefix}*`], { + capture: true, + check: false, + }); + + if (result.status !== 0 || !result.stdout.trim()) { + return []; + } + + return result.stdout + .split(/\r?\n/) + .map((line) => line.trim().split(/\s+/, 2)[1] || "") + .filter((ref) => ref.startsWith("refs/heads/")) + .map((ref) => ref.slice("refs/heads/".length)) + .filter((branch) => branch === prefix || branch.startsWith(`${prefix}_`)); +} + +function fetchRemoteBranch(branch) { + git(["fetch", REMOTE, branch]); + return branchRemoteRef(branch); +} + +function readRefFileBytes(ref, relativePath) { + const result = git(["show", `${ref}:${relativePath}`], { + capture: true, + check: false, + }); + + if (result.status !== 0) { + return null; + } + + return Buffer.from(result.stdout, "utf-8"); +} + +function desiredBranchState(result) { + if (result === null) { + return { + hasApiMd: false, + hasMetadata: false, + apiMdSha256: null, + }; + } + + return { + hasApiMd: true, + hasMetadata: Boolean(result.metadata), + apiMdSha256: metadataShaOrNull(result.metadata), + }; +} + +function branchStateMatchesDesired(actual, desired) { + return ( + actual.hasApiMd === desired.hasApiMd && + actual.hasMetadata === desired.hasMetadata && + actual.apiMdSha256 === desired.apiMdSha256 + ); +} + +function readBranchState(ref, apiRelative, metaRelative) { + const metadataBytes = readRefFileBytes(ref, metaRelative); + const apiMdBytes = readRefFileBytes(ref, apiRelative); + + return { + hasApiMd: Boolean(apiMdBytes), + hasMetadata: Boolean(metadataBytes), + apiMdSha256: metadataShaOrNull(metadataBytes), + }; +} + +function branchSuffixFromIndex(index) { + let value = index; + let suffix = ""; + + do { + suffix = String.fromCharCode(97 + (value % 26)) + suffix; + value = Math.floor(value / 26) - 1; + } while (value >= 0); + + return suffix; +} + +function compareBranchCandidates(left, right, preferredBranch) { + if (left === preferredBranch && right !== preferredBranch) { + return -1; + } + + if (right === preferredBranch && left !== preferredBranch) { + return 1; + } + + return left.localeCompare(right); +} + +function nextAvailableBranchName(preferredBranch, existingBranches) { + if (!existingBranches.has(preferredBranch)) { + return preferredBranch; + } + + let index = 0; + while (existingBranches.has(`${preferredBranch}_${branchSuffixFromIndex(index)}`)) { + index += 1; + } + + return `${preferredBranch}_${branchSuffixFromIndex(index)}`; +} + +function isAncestorRef(ancestorRef, branchRef) { + const result = git(["merge-base", "--is-ancestor", ancestorRef, branchRef], { + capture: true, + check: false, + }); + return result.status === 0; +} + +function resolveBranchSelection({ preferredBranch, desiredState, apiRelative, metaRelative, requiredAncestorRef = null }) { + const existingBranches = new Set(listRemoteBranchesWithPrefix(preferredBranch)); + const orderedCandidates = [...existingBranches].sort((left, right) => + compareBranchCandidates(left, right, preferredBranch), + ); + + for (const candidateBranch of orderedCandidates) { + const remoteRef = fetchRemoteBranch(candidateBranch); + const actualState = readBranchState(remoteRef, apiRelative, metaRelative); + if (!branchStateMatchesDesired(actualState, desiredState)) { + continue; + } + + if (requiredAncestorRef && !isAncestorRef(requiredAncestorRef, remoteRef)) { + continue; + } + + return { + branchName: candidateBranch, + reused: true, + remoteRef, + }; + } + + return { + branchName: nextAvailableBranchName(preferredBranch, existingBranches), + reused: false, + remoteRef: null, + }; +} + +function ensureBranchStateHasMetadataSha(branchLabel, state) { + if (state.hasApiMd && !state.apiMdSha256) { + throw new Error(`ERROR: ${branchLabel} is missing apiMdSha256 in API.metadata.yml.`); + } +} + function selectBestPr(prs) { const candidates = prs.filter((pr) => pr && typeof pr === "object" && "number" in pr && "url" in pr && "state" in pr && "updatedAt" in pr, @@ -352,68 +550,88 @@ function selectBestPr(prs) { return pool[0]; } -function findOpenPrForHead(headSelector) { - const selectors = [headSelector]; +function branchReferenceParts(headSelector) { + if (headSelector === MAIN_REF) { + return { + owner: "Azure", + branch: "main", + display: headSelector, + }; + } + if (headSelector.includes(":")) { - const branchOnly = headSelector.split(":", 2)[1]; - if (branchOnly && !selectors.includes(branchOnly)) { - selectors.push(branchOnly); - } + const [owner, branch] = headSelector.split(":", 2); + return { + owner, + branch, + display: headSelector, + }; } + return { + owner: "Azure", + branch: headSelector, + display: headSelector, + }; +} + +function exactHeadSelector(headSelector) { + const { owner, branch } = branchReferenceParts(headSelector); + return `${owner}:${branch}`; +} + +function findOpenPrForHead(headSelector) { + const selector = exactHeadSelector(headSelector); const allPrs = []; - for (const selector of selectors) { - const direct = gh( - [ - "pr", - "list", - "--repo", - "Azure/azure-sdk-for-python", - "--head", - selector, - "--state", - "all", - "--json", - "number,url,state,updatedAt", - "--limit", - "50", - ], - { check: false, capture: true }, - ); - - if (direct.status === 0) { - const prs = parseJsonOrNull(direct.stdout); - if (prs) { - allPrs.push(...prs); - } + + const direct = gh( + [ + "pr", + "list", + "--repo", + "Azure/azure-sdk-for-python", + "--head", + selector, + "--state", + "open", + "--json", + "number,url,state,updatedAt", + "--limit", + "50", + ], + { check: false, capture: true }, + ); + + if (direct.status === 0) { + const prs = parseJsonOrNull(direct.stdout); + if (prs) { + allPrs.push(...prs); } } - for (const selector of selectors) { - const searchQuery = `repo:Azure/azure-sdk-for-python head:${selector}`; - const search = gh( - [ - "pr", - "list", - "--repo", - "Azure/azure-sdk-for-python", - "--search", - searchQuery, - "--state", - "all", - "--json", - "number,url,state,updatedAt", - "--limit", - "50", - ], - { check: false, capture: true }, - ); - - if (search.status === 0) { - const prs = parseJsonOrNull(search.stdout); - if (prs) { - allPrs.push(...prs); - } + const searchQuery = `repo:Azure/azure-sdk-for-python is:pr is:open head:${selector}`; + const search = gh( + [ + "pr", + "list", + "--repo", + "Azure/azure-sdk-for-python", + "--search", + searchQuery, + "--state", + "open", + "--json", + "number,url,state,updatedAt", + "--limit", + "50", + ], + { check: false, capture: true }, + ); + + if (search.status === 0) { + const prs = parseJsonOrNull(search.stdout); + if (prs) { + allPrs.push(...prs); } } @@ -431,20 +649,120 @@ function findOpenPrForHead(headSelector) { return selectBestPr([...deduped.values()]); } +function findOpenPrForBranches(baseBranch, headBranch) { + const direct = gh( + [ + "pr", + "list", + "--repo", + "Azure/azure-sdk-for-python", + "--base", + baseBranch, + "--head", + headBranch, + "--state", + "open", + "--json", + "number,url,state,updatedAt", + "--limit", + "20", + ], + { check: false, capture: true }, + ); + + if (direct.status === 0) { + const prs = parseJsonOrNull(direct.stdout); + if (prs && prs.length > 0) { + return selectBestPr(prs); + } + } + + const search = gh( + [ + "pr", + "list", + "--repo", + "Azure/azure-sdk-for-python", + "--search", + `repo:Azure/azure-sdk-for-python is:pr is:open head:${headBranch} base:${baseBranch}`, + "--json", + "number,url,state,updatedAt", + "--limit", + "20", + ], + { check: false, capture: true }, + ); + + if (search.status !== 0) { + return null; + } + + const prs = parseJsonOrNull(search.stdout); + return prs ? selectBestPr(prs) : null; +} + +function createDraftPr(baseBranch, headBranch, title, body) { + const result = gh( + [ + "api", + "repos/Azure/azure-sdk-for-python/pulls", + "--method", + "POST", + "--field", + `base=${baseBranch}`, + "--field", + `head=${headBranch}`, + "--field", + `title=${title}`, + "--field", + `body=${body}`, + "--field", + "draft=true", + ], + { check: false, capture: true }, + ); + + if (result.status === 0) { + const createdPr = parseJsonObjectOrNull(result.stdout); + return { + ok: true, + url: createdPr && typeof createdPr.html_url === "string" ? createdPr.html_url : "", + stderr: result.stderr || "", + stdout: result.stdout || "", + }; + } + + return { + ok: false, + status: result.status, + stdout: result.stdout || "", + stderr: result.stderr || "", + }; +} + +function branchReferenceMarkdown(headSelector) { + const { owner, branch, display } = branchReferenceParts(headSelector); + const branchUrl = `https://github.com/${owner}/azure-sdk-for-python/tree/${encodeURIComponent(branch)}`; + return `[branch \`${display}\`](${branchUrl})`; +} + function workingReferenceMarkdown(headSelector) { const pr = findOpenPrForHead(headSelector); if (pr) { return `[PR #${pr.number}](${pr.url})`; } - if (headSelector.includes(":")) { - const [owner, branch] = headSelector.split(":", 2); - const branchUrl = `https://github.com/${owner}/azure-sdk-for-python/tree/${encodeURIComponent(branch)}`; - return `[branch \`${headSelector}\`](${branchUrl})`; + return branchReferenceMarkdown(headSelector); +} + +function baselineReferenceMarkdown(baseTag) { + if (!baseTag) { + return "empty"; } - const branchUrl = `https://github.com/Azure/azure-sdk-for-python/tree/${encodeURIComponent(headSelector)}`; - return `[branch \`${headSelector}\`](${branchUrl})`; + const commitSha = gitOut(["rev-list", "-n", "1", baseTag]); + const commitUrl = `https://github.com/Azure/azure-sdk-for-python/commit/${commitSha}`; + return `[tag \`${baseTag}\`](${commitUrl})`; } function writeBytes(filePath, bytes) { @@ -551,124 +869,162 @@ function main() { }); const targetVersion = targetResult.version; - const baseBranch = apiReviewBranchName("base", args.packageName, baseVersion); - const reviewBranch = apiReviewBranchName("review", args.packageName, targetVersion); - - logInfo(`\n=== Creating base branch ${baseBranch} ===`); - git(["checkout", "-B", baseBranch, MAIN_REF]); - const apiPath = apiMdPath(packageDir); const apiRelative = apiMdRel(packageDir); const metaFilePath = metadataPath(packageDir); const metaRelative = metadataRel(packageDir); + const desiredBaseState = desiredBranchState(baseResult); + const desiredReviewState = desiredBranchState(targetResult); - if (baseResult !== null) { - writeBytes(apiPath, baseResult.apiMd); - git(["add", apiRelative]); - if (baseResult.metadata) { - writeBytes(metaFilePath, baseResult.metadata); - git(["add", metaRelative]); - } - git(["commit", "-m", `[API Review] Baseline API.md for ${args.packageName} ${baseVersion}`]); - } else { - const tracked = git(["ls-files", "--error-unmatch", apiRelative], { - capture: true, - check: false, - }); + ensureBranchStateHasMetadataSha("baseline API result", desiredBaseState); + ensureBranchStateHasMetadataSha("target API result", desiredReviewState); + + const baseSelection = resolveBranchSelection({ + preferredBranch: apiReviewBranchName("base", args.packageName, baseVersion), + desiredState: desiredBaseState, + apiRelative, + metaRelative, + }); + const baseBranch = baseSelection.branchName; - if (tracked.status === 0) { - git(["rm", apiRelative]); - const metaTracked = git(["ls-files", "--error-unmatch", metaRelative], { + if (baseSelection.reused) { + logInfo(`\n=== Reusing base branch ${baseBranch} ===`); + git(["checkout", "-B", baseBranch, baseSelection.remoteRef]); + } else { + logInfo(`\n=== Creating base branch ${baseBranch} ===`); + git(["checkout", "-B", baseBranch, MAIN_REF]); + + if (baseResult !== null) { + writeBytes(apiPath, baseResult.apiMd); + git(["add", apiRelative]); + if (baseResult.metadata) { + writeBytes(metaFilePath, baseResult.metadata); + git(["add", metaRelative]); + } + git(["commit", "-m", `[API Review] Baseline API.md for ${args.packageName} ${baseVersion}`]); + } else { + const tracked = git(["ls-files", "--error-unmatch", apiRelative], { capture: true, check: false, }); - if (metaTracked.status === 0) { - git(["rm", metaRelative]); - } - git(["commit", "-m", `[API Review] Remove API.md for ${args.packageName} (empty baseline)`]); - } else { - if (fs.existsSync(apiPath)) { - fs.unlinkSync(apiPath); - } - if (fs.existsSync(metaFilePath)) { - fs.unlinkSync(metaFilePath); + + if (tracked.status === 0) { + git(["rm", apiRelative]); + const metaTracked = git(["ls-files", "--error-unmatch", metaRelative], { + capture: true, + check: false, + }); + if (metaTracked.status === 0) { + git(["rm", metaRelative]); + } + git(["commit", "-m", `[API Review] Remove API.md for ${args.packageName} (empty baseline)`]); + } else { + if (fs.existsSync(apiPath)) { + fs.unlinkSync(apiPath); + } + if (fs.existsSync(metaFilePath)) { + fs.unlinkSync(metaFilePath); + } + git(["commit", "--allow-empty", "-m", `[API Review] Empty baseline for ${args.packageName}`]); } - git(["commit", "--allow-empty", "-m", `[API Review] Empty baseline for ${args.packageName}`]); } - } - - git(["push", "--force-with-lease", REMOTE, baseBranch]); - logInfo(`\n=== Creating review branch ${reviewBranch} ===`); - git(["checkout", "-B", reviewBranch, baseBranch]); - writeBytes(apiPath, targetResult.apiMd); - git(["add", apiRelative]); - if (targetResult.metadata) { - writeBytes(metaFilePath, targetResult.metadata); - git(["add", metaRelative]); + git(["push", "--force-with-lease", REMOTE, baseBranch]); } - const diff = git(["diff", "--cached", "--quiet"], { - capture: true, - check: false, + const reviewSelection = resolveBranchSelection({ + preferredBranch: apiReviewBranchName("review", args.packageName, targetVersion), + desiredState: desiredReviewState, + apiRelative, + metaRelative, + requiredAncestorRef: baseBranch, }); + const reviewBranch = reviewSelection.branchName; - if (diff.status === 0) { - git([ - "commit", - "--allow-empty", - "-m", - `[API Review] API.md for ${args.packageName} ${targetVersion} (no diff vs baseline)`, - ]); + if (reviewSelection.reused) { + logInfo(`\n=== Reusing review branch ${reviewBranch} ===`); + git(["checkout", "-B", reviewBranch, reviewSelection.remoteRef]); } else { - git(["commit", "-m", `[API Review] API.md for ${args.packageName} ${targetVersion}`]); - } + logInfo(`\n=== Creating review branch ${reviewBranch} ===`); + git(["checkout", "-B", reviewBranch, baseBranch]); + writeBytes(apiPath, targetResult.apiMd); + git(["add", apiRelative]); + if (targetResult.metadata) { + writeBytes(metaFilePath, targetResult.metadata); + git(["add", metaRelative]); + } + + const diff = git(["diff", "--cached", "--quiet"], { + capture: true, + check: false, + }); + + if (diff.status === 0) { + git([ + "commit", + "--allow-empty", + "-m", + `[API Review] API.md for ${args.packageName} ${targetVersion} (no diff vs baseline)`, + ]); + } else { + git(["commit", "-m", `[API Review] API.md for ${args.packageName} ${targetVersion}`]); + } - git(["push", "--force-with-lease", REMOTE, reviewBranch]); + git(["push", "--force-with-lease", REMOTE, reviewBranch]); + } const title = `[API Review] ${args.packageName} ${targetVersion} (base ${baseVersion})`; const workingSelector = args.target || originalBranch; const workingRef = workingReferenceMarkdown(workingSelector); - const baselineDescription = args.base - ? `tag \`${args.base}\`` - : "_empty_"; + const baselineRef = baselineReferenceMarkdown(args.base); const body = [ - `Automated API review PR for \`${args.packageName}\`.`, + `Automated API review PR for ${args.packageName}.`, "", - `- **Working branch:** ${workingRef}`, - `- **Target:** \`${args.target || "origin/main"}\` (version \`${targetVersion}\`)`, - `- **Baseline:** ${baselineDescription} (version \`${baseVersion}\`)`, + `- **Working branch:** ${workingRef} (version ${targetVersion})`, + `- **Baseline:** ${baselineRef} (version ${baseVersion})`, "", - "Generated by `scripts/api_md_workflow/create_api_review_pr.js`.", + "Generated by scripts/api_md_workflow/create_api_review_pr.js.", ].join("\n"); + if (baseSelection.reused && reviewSelection.reused) { + const existingPr = findOpenPrForBranches(baseBranch, reviewBranch); + if (existingPr) { + logInfo(`\n=== Reusing existing PR #${existingPr.number} ===`); + logInfo(existingPr.url); + return 0; + } + } + logInfo("\n=== Opening PR ==="); const compareUrl = `https://github.com/Azure/azure-sdk-for-python/compare/${baseBranch}...${reviewBranch}?expand=1`; - const prCreate = gh( - [ - "pr", - "create", - "--repo", - "Azure/azure-sdk-for-python", - "--base", - baseBranch, - "--head", - reviewBranch, - "--title", - title, - "--body", - body, - "--draft", - ], - { check: false }, - ); - - if (prCreate.status !== 0) { + const prCreate = createDraftPr(baseBranch, reviewBranch, title, body); + + if (prCreate.ok) { + if (prCreate.url) { + logInfo(prCreate.url); + } + } else { + const existingPr = findOpenPrForBranches(baseBranch, reviewBranch); + if (existingPr) { + logInfo(`\n=== Reusing existing PR #${existingPr.number} ===`); + logInfo(existingPr.url); + return 0; + } + + const errorDetails = [ + `Exit code: ${prCreate.status}`, + prCreate.stderr ? `stderr: ${prCreate.stderr.replace(/\r?\n/g, " ").trim()}` : "", + prCreate.stdout ? `stdout: ${prCreate.stdout.replace(/\r?\n/g, " ").trim()}` : "", + "Debug repro: GH_DEBUG=1 gh api repos/Azure/azure-sdk-for-python/pulls --method POST --field base= --field head= --field title= --field body=<body> --field draft=true", + ] + .filter(Boolean) + .join("\n "); logWarning( - "\nWARNING: `gh pr create` failed. Both branches were pushed successfully -- open the PR manually here:\n" + + "\nWARNING: `gh api` PR creation failed. Both branches were pushed successfully -- open the PR manually here:\n" + ` ${compareUrl}\n` + - ` Title: ${title}`, + ` Title: ${title}` + + (errorDetails ? `\n ${errorDetails}` : ""), ); } From bb253fa478d8fa04e6ad34a34c960f35bb9263f7 Mon Sep 17 00:00:00 2001 From: Travis Prescott <trpresco@microsoft.com> Date: Fri, 5 Jun 2026 10:10:51 -0700 Subject: [PATCH 26/33] Code review feedback. --- .github/shared/src/cache.js | 2 +- .../api-md-consistency/api-md-consistency.js | 12 +++---- .../Extract-APIViewMetadata-Python.ps1 | 18 ++++------ eng/tools/azure-sdk-tools/azpysdk/apistub.py | 2 +- scripts/api_md_workflow/README.md | 30 ++++++++-------- scripts/api_md_workflow/adapters/python.js | 4 +-- .../api_md_workflow/create_api_review_pr.js | 34 +++++++++---------- scripts/api_md_workflow/find_mismatches.js | 12 +++---- scripts/api_md_workflow/regenerate.js | 4 +-- sdk/template/azure-template/README.md | 2 +- .../azure-template/{API.md => api.md} | 2 +- .../{API.metadata.yml => api.metadata.yml} | 0 12 files changed, 58 insertions(+), 64 deletions(-) rename sdk/template/azure-template/{API.md => api.md} (97%) rename sdk/template/azure-template/{API.metadata.yml => api.metadata.yml} (100%) diff --git a/.github/shared/src/cache.js b/.github/shared/src/cache.js index ec54ede7b399..444a7ce2f932 100644 --- a/.github/shared/src/cache.js +++ b/.github/shared/src/cache.js @@ -49,7 +49,7 @@ export class KeyedPairCache { * @returns {V} cached value * * @example - * const result = cache.getOrCreate(42, 7 async () => await doWork(42, 7)); + * const result = cache.getOrCreate(42, 7, async () => await doWork(42, 7)); */ getOrCreate(key1, key2, factory) { // key1 => cache for the next layer diff --git a/.github/workflows/src/api-md-consistency/api-md-consistency.js b/.github/workflows/src/api-md-consistency/api-md-consistency.js index f375edb9c0f8..a6d8ffb868fe 100644 --- a/.github/workflows/src/api-md-consistency/api-md-consistency.js +++ b/.github/workflows/src/api-md-consistency/api-md-consistency.js @@ -40,10 +40,10 @@ function formatIssueSection(title, apiFiles) { const lines = [title]; for (const apiFile of apiFiles) { - const packageDir = apiFile.replace(/\/API\.md$/, ""); + const packageDir = apiFile.replace(/\/(api\.md|api\.metadata\.yml)$/, ""); const packageName = path.basename(packageDir); lines.push(`- ${packageDir}`); - lines.push(` API.md: ${apiFile}`); + lines.push(` API file: ${apiFile}`); lines.push(` Regenerate: azpysdk apistub --md --extract-metadata ${packageName}`); } lines.push(""); @@ -87,12 +87,12 @@ module.exports = async function apiMdConsistency({ core }) { if (issueCount > 0) { const messageParts = [ - "Generated API.md does not match committed API.md, or API.md is missing, for one or more affected packages.", - "API.metadata.yml is informational only (for troubleshooting API drift, e.g., parser/runtime differences) and is not part of pass/fail gating.", + "Generated api.md or api.metadata.yml does not match the committed files, or required API files are missing, for one or more affected packages.", + "api.metadata.yml must be committed alongside api.md, and selected metadata fields are part of pass/fail gating.", "", formatIssueSection("Mismatched packages:", mismatches), - formatIssueSection("Missing API.md packages:", missing), - "To regenerate API.md locally, run the command shown for each package from the repository root.", + formatIssueSection("Missing required API files:", missing), + "To regenerate api.md locally, run the command shown for each package from the repository root.", ].filter((part) => part !== ""); core.setFailed(messageParts.join("\n")); diff --git a/eng/scripts/Extract-APIViewMetadata-Python.ps1 b/eng/scripts/Extract-APIViewMetadata-Python.ps1 index 0cd62ec99e24..abf162e71f20 100644 --- a/eng/scripts/Extract-APIViewMetadata-Python.ps1 +++ b/eng/scripts/Extract-APIViewMetadata-Python.ps1 @@ -3,16 +3,15 @@ <# .SYNOPSIS -Extracts Python APIView metadata from API markdown and writes API.metadata.yml. +Extracts Python APIView metadata from API markdown and writes api.metadata.yml. .DESCRIPTION Reads an API markdown file, extracts parser and Python runtime versions from the Python APIView metadata header, removes that header from the markdown, trims leading -blank lines from the markdown body, and writes API.metadata.yml beside the markdown file. +blank lines from the markdown body, and writes api.metadata.yml beside the markdown file. .PARAMETER ApiMarkdownPath -Optional. Path to API markdown file. If omitted, a markdown file will be resolved -from OutputPath (prefers API.md, then api.md). +Optional. Path to API markdown file. If omitted, api.md will be resolved from OutputPath. .PARAMETER OutputPath Optional. Directory containing API markdown output. Defaults to current directory. @@ -21,7 +20,7 @@ Optional. Directory containing API markdown output. Defaults to current director ./Extract-APIViewMetadata-Python.ps1 -OutputPath ./sdk/template/azure-template .EXAMPLE -./Extract-APIViewMetadata-Python.ps1 -ApiMarkdownPath ./sdk/template/azure-template/API.md +./Extract-APIViewMetadata-Python.ps1 -ApiMarkdownPath ./sdk/template/azure-template/api.md #> [CmdletBinding()] @@ -47,17 +46,12 @@ function Resolve-ApiMarkdownPath { } $resolvedOutput = Resolve-Path -LiteralPath $OutputDirectory -ErrorAction Stop - $apiUpper = Join-Path $resolvedOutput.Path "API.md" - if (Test-Path -LiteralPath $apiUpper -PathType Leaf) { - return $apiUpper - } - $apiLower = Join-Path $resolvedOutput.Path "api.md" if (Test-Path -LiteralPath $apiLower -PathType Leaf) { return $apiLower } - throw "Could not find API markdown file in '$OutputDirectory'. Expected API.md or api.md." + throw "Could not find API markdown file in '$OutputDirectory'. Expected api.md." } function Trim-LeadingBlankLines { @@ -143,7 +137,7 @@ $metadata['apiMdSha256'] = Get-Sha256Hex -Text $normalizedTextForHash Set-Content -LiteralPath $resolvedApiPath -Value ($filtered -join $lineEnding) -NoNewline -Encoding utf8 Write-Host "Updated markdown: $resolvedApiPath" -$metadataPath = Join-Path (Split-Path -Parent $resolvedApiPath) "API.metadata.yml" +$metadataPath = Join-Path (Split-Path -Parent $resolvedApiPath) "api.metadata.yml" if ($metadata.Count -gt 0) { $yamlLines = [System.Collections.Generic.List[string]]::new() foreach ($key in ($metadata.Keys | Sort-Object)) { diff --git a/eng/tools/azure-sdk-tools/azpysdk/apistub.py b/eng/tools/azure-sdk-tools/azpysdk/apistub.py index f61371477fc0..e734eba00d27 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/apistub.py +++ b/eng/tools/azure-sdk-tools/azpysdk/apistub.py @@ -74,7 +74,7 @@ def register( dest="extract_metadata", default=False, action="store_true", - help="Extract language-specific metadata from generated api.md into API.metadata.yml and remove metadata header from api.md.", + help="Extract language-specific metadata from generated api.md into api.metadata.yml and remove metadata header from api.md.", ) p.add_argument( "--install-deps", diff --git a/scripts/api_md_workflow/README.md b/scripts/api_md_workflow/README.md index 22d086c65833..c782a88a9d9e 100644 --- a/scripts/api_md_workflow/README.md +++ b/scripts/api_md_workflow/README.md @@ -1,28 +1,28 @@ -# API.md Workflow Helpers +# api.md Workflow Helpers -This folder contains the helper scripts used by the GitHub Actions workflows that validate and update `API.md` files for changed SDK packages. +This folder contains the helper scripts used by the GitHub Actions workflows that validate and update `api.md` files for changed SDK packages. ## Purpose -The workflow validates that when a pull request changes one or more SDK packages, the committed `API.md` files are still up to date. -Only `API.md` is diff-gated by this workflow; `API.metadata.yml` is intentionally excluded from mismatch checks. -Use `API.metadata.yml` as diagnostic context when `API.md` drifts (for example, parser/runtime version differences), but it does not affect pass/fail. +The workflow validates that when a pull request changes one or more SDK packages, the committed `api.md` files are still up to date. +`api.md` content is diff-gated by this workflow, and `api.metadata.yml` must be committed alongside it. +The workflow also validates adapter-selected metadata fields, such as `apiMdSha256` and `parserVersion`, so metadata differences can affect pass/fail. The logic is split between GitHub workflow YAML files and helper scripts in Python and JavaScript. ## Workflow Files -### `.github/workflows/consistency.yml` +### `.github/workflows/api-consistency.yml` This is the main workflow. It runs on pull requests for changes under `sdk/**`. - Detects affected package directories from the PR diff. -- Regenerates `API.md` for those packages. +- Regenerates `api.md` for those packages. - Fails if the generated files differ from the committed files. -- Fails if an affected package does not have a committed `API.md`. -- Prints the mismatched or missing packages and the `azpysdk apistub --md --extract-metadata` command needed to regenerate each `API.md` file. +- Fails if an affected package does not have a committed `api.md`. +- Prints the mismatched or missing packages and the `azpysdk apistub --md --extract-metadata` command needed to regenerate each `api.md` file. ## Script Layout @@ -55,21 +55,21 @@ This script is used by the consistency check. ### `find_mismatches.js` -Reads package directories from `API_MD_PACKAGES_FILE`, checks whether `<package>/API.md` is missing/untracked or differs from git, and writes: +Reads package directories from `API_MD_PACKAGES_FILE`, checks whether `<package>/api.md` is missing/untracked or differs from git, and writes: - mismatched files to `API_MD_MISMATCHES_FILE` - missing files to `API_MD_MISSING_FILE` Also writes `mismatch_count=<n>`, `missing_count=<n>`, and `issue_count=<n>` to `GITHUB_OUTPUT`. -`API.metadata.yml` is not part of this diff check. +`api.metadata.yml` is also required and selected metadata fields are mismatch-checked according to the active adapter. ### `create_api_review_pr.js` and adapters API review PR creation now uses a shared JavaScript orchestrator with a language adapter boundary: - `create_api_review_pr.js`: shared git/branch/PR orchestration logic. -- `adapters/python.js`: Python-specific package discovery, version parsing, and `API.md` generation. +- `adapters/python.js`: Python-specific package discovery, version parsing, and `api.md` generation. This split allows the core workflow to be reused across other language repos while keeping generation behavior language-specific. @@ -98,6 +98,6 @@ Common variables include: 1. A PR changes files under `sdk/**`. 2. `consistency.yml` runs. 3. `find_affected.js` determines which packages were touched. -4. `regenerate.js` rebuilds `API.md` for those packages. -5. `find_mismatches.js` records any `API.md` drift, including missing or untracked `API.md` files. -6. If drift is found, the workflow fails and prints the affected packages plus the `azpysdk apistub --md --extract-metadata <package-name>` command to regenerate each `API.md` file locally. +4. `regenerate.js` rebuilds `api.md` for those packages. +5. `find_mismatches.js` records any `api.md` drift, including missing or untracked `api.md` files. +6. If drift is found, the workflow fails and prints the affected packages plus the `azpysdk apistub --md --extract-metadata <package-name>` command to regenerate each `api.md` file locally. diff --git a/scripts/api_md_workflow/adapters/python.js b/scripts/api_md_workflow/adapters/python.js index 267d9ea9a4fd..f6e246b4f95a 100644 --- a/scripts/api_md_workflow/adapters/python.js +++ b/scripts/api_md_workflow/adapters/python.js @@ -123,7 +123,7 @@ function generateApiForPackage({ }) { const activeLogger = logger || console; if (refLabel) { - activeLogger.info(`--- Generating API.md on ${refLabel} ---`); + activeLogger.info(`--- Generating api.md on ${refLabel} ---`); } const packageDir = findPackageDir(repoRoot, packageName); @@ -149,7 +149,7 @@ function generateApiForPackage({ }); } -// Fields in API.metadata.yml that must match between working tree and committed version. +// Fields in api.metadata.yml that must match between working tree and committed version. // pythonVersion is excluded because it varies across CI environments. const metadataFieldsToValidate = ["apiMdSha256", "parserVersion"]; diff --git a/scripts/api_md_workflow/create_api_review_pr.js b/scripts/api_md_workflow/create_api_review_pr.js index 1a0e338b02a8..d339306e83c7 100644 --- a/scripts/api_md_workflow/create_api_review_pr.js +++ b/scripts/api_md_workflow/create_api_review_pr.js @@ -204,19 +204,19 @@ function packageRelDir(packageDir) { } function apiMdPath(packageDir) { - return path.join(packageDir, "API.md"); + return path.join(packageDir, "api.md"); } function apiMdRel(packageDir) { - return `${packageRelDir(packageDir)}/API.md`; + return `${packageRelDir(packageDir)}/api.md`; } function metadataPath(packageDir) { - return path.join(packageDir, "API.metadata.yml"); + return path.join(packageDir, "api.metadata.yml"); } function metadataRel(packageDir) { - return `${packageRelDir(packageDir)}/API.metadata.yml`; + return `${packageRelDir(packageDir)}/api.metadata.yml`; } function apiReviewBranchName(kind, packageName, version) { @@ -532,7 +532,7 @@ function resolveBranchSelection({ preferredBranch, desiredState, apiRelative, me function ensureBranchStateHasMetadataSha(branchLabel, state) { if (state.hasApiMd && !state.apiMdSha256) { - throw new Error(`ERROR: ${branchLabel} is missing apiMdSha256 in API.metadata.yml.`); + throw new Error(`ERROR: ${branchLabel} is missing apiMdSha256 in api.metadata.yml.`); } } @@ -770,7 +770,7 @@ function writeBytes(filePath, bytes) { fs.writeFileSync(filePath, bytes); } -function generateApiBytesForRef({ +async function generateApiBytesForRef({ adapter, repoRoot, packageName, @@ -789,7 +789,7 @@ function generateApiBytesForRef({ try { const version = adapter.readVersion(packageDir); - adapter.generateApiForPackage({ + await adapter.generateApiForPackage({ repoRoot, packageName, runtimeExecutable, @@ -818,7 +818,7 @@ function generateApiBytesForRef({ } } -function main() { +async function main() { const args = parseArgs(process.argv.slice(2)); const adapter = loadAdapter(args.adapter); @@ -843,8 +843,8 @@ function main() { try { let baseResult = null; if (args.base) { - logInfo(`\n=== Capturing baseline API.md from tag ${args.base} ===`); - baseResult = generateApiBytesForRef({ + logInfo(`\n=== Capturing baseline api.md from tag ${args.base} ===`); + baseResult = await generateApiBytesForRef({ adapter, repoRoot: REPO_ROOT, packageName: args.packageName, @@ -856,8 +856,8 @@ function main() { }); } - logInfo(`\n=== Capturing target API.md from ${targetRef} ===`); - const targetResult = generateApiBytesForRef({ + logInfo(`\n=== Capturing target api.md from ${targetRef} ===`); + const targetResult = await generateApiBytesForRef({ adapter, repoRoot: REPO_ROOT, packageName: args.packageName, @@ -901,7 +901,7 @@ function main() { writeBytes(metaFilePath, baseResult.metadata); git(["add", metaRelative]); } - git(["commit", "-m", `[API Review] Baseline API.md for ${args.packageName} ${baseVersion}`]); + git(["commit", "-m", `[API Review] Baseline api.md for ${args.packageName} ${baseVersion}`]); } else { const tracked = git(["ls-files", "--error-unmatch", apiRelative], { capture: true, @@ -917,7 +917,7 @@ function main() { if (metaTracked.status === 0) { git(["rm", metaRelative]); } - git(["commit", "-m", `[API Review] Remove API.md for ${args.packageName} (empty baseline)`]); + git(["commit", "-m", `[API Review] Remove api.md for ${args.packageName} (empty baseline)`]); } else { if (fs.existsSync(apiPath)) { fs.unlinkSync(apiPath); @@ -964,10 +964,10 @@ function main() { "commit", "--allow-empty", "-m", - `[API Review] API.md for ${args.packageName} ${targetVersion} (no diff vs baseline)`, + `[API Review] api.md for ${args.packageName} ${targetVersion} (no diff vs baseline)`, ]); } else { - git(["commit", "-m", `[API Review] API.md for ${args.packageName} ${targetVersion}`]); + git(["commit", "-m", `[API Review] api.md for ${args.packageName} ${targetVersion}`]); } git(["push", "--force-with-lease", REMOTE, reviewBranch]); @@ -1037,7 +1037,7 @@ function main() { (async () => { logger = await getDefaultLogger(); try { - process.exit(main()); + process.exit(await main()); } catch (error) { const message = error instanceof Error ? error.message : String(error); logError(message); diff --git a/scripts/api_md_workflow/find_mismatches.js b/scripts/api_md_workflow/find_mismatches.js index da9cbf6376d5..d2651c2ccc8f 100644 --- a/scripts/api_md_workflow/find_mismatches.js +++ b/scripts/api_md_workflow/find_mismatches.js @@ -24,7 +24,7 @@ async function main() { const config = loadWorkflowConfig(); const adapter = loadAdapter(config.adapter); - // Fields to compare in API.metadata.yml. If the adapter doesn't specify, + // Fields to compare in api.metadata.yml. If the adapter doesn't specify, // compare all fields (strict default for languages that don't opt out). const fieldsToValidate = adapter.metadataFieldsToValidate || null; @@ -36,10 +36,10 @@ async function main() { const mismatches = []; const missing = []; for (const pkgDir of packages) { - const apiFile = `${pkgDir}/API.md`; - const metadataFile = `${pkgDir}/API.metadata.yml`; + const apiFile = `${pkgDir}/api.md`; + const metadataFile = `${pkgDir}/api.metadata.yml`; - // Enforce that each affected package has a committed API.md file. + // Enforce that each affected package has a committed api.md file. if (!fs.existsSync(apiFile) || !fs.statSync(apiFile).isFile()) { missing.push(apiFile); continue; @@ -53,7 +53,7 @@ async function main() { continue; } - // API.metadata.yml must be present alongside API.md. + // api.metadata.yml must be present alongside api.md. if (!fs.existsSync(metadataFile) || !fs.statSync(metadataFile).isFile()) { missing.push(metadataFile); } else { @@ -76,7 +76,7 @@ async function main() { } } - // Diff-gate only API.md; metadata content differences are acceptable. + // Diff-gate the full api.md content; metadata is field-gated above. const quietDiffResult = await runAsync("git", ["diff", "--quiet", "--", apiFile], { check: false, }); diff --git a/scripts/api_md_workflow/regenerate.js b/scripts/api_md_workflow/regenerate.js index 64f6048c29dc..555a6ead48fd 100644 --- a/scripts/api_md_workflow/regenerate.js +++ b/scripts/api_md_workflow/regenerate.js @@ -24,8 +24,8 @@ async function main() { const runtimeExecutable = process.env.RUNTIME_EXECUTABLE || null; for (const pkgDir of packages) { const packageName = path.basename(pkgDir); - logger.info(`Generating API.md for ${packageName}`); - adapter.generateApiForPackage({ + logger.info(`Generating api.md for ${packageName}`); + await adapter.generateApiForPackage({ repoRoot: REPO_ROOT, packageName, runtimeExecutable, diff --git a/sdk/template/azure-template/README.md b/sdk/template/azure-template/README.md index c66dcd0c93c8..0425130a69c8 100644 --- a/sdk/template/azure-template/README.md +++ b/sdk/template/azure-template/README.md @@ -26,7 +26,7 @@ Running into issues? This section should contain details as to what to do there. # Next steps -More sample code should go here, along with links out to the appropriate example tests. And more. +More sample code should go here, along with links out to the appropriate example tests. # Contributing diff --git a/sdk/template/azure-template/API.md b/sdk/template/azure-template/api.md similarity index 97% rename from sdk/template/azure-template/API.md rename to sdk/template/azure-template/api.md index 638c13dae01a..85eb954408ab 100644 --- a/sdk/template/azure-template/API.md +++ b/sdk/template/azure-template/api.md @@ -9,4 +9,4 @@ namespace azure.template.template_code def azure.template.template_code.template_main() -> bool: ... -``` \ No newline at end of file +``` diff --git a/sdk/template/azure-template/API.metadata.yml b/sdk/template/azure-template/api.metadata.yml similarity index 100% rename from sdk/template/azure-template/API.metadata.yml rename to sdk/template/azure-template/api.metadata.yml From 317bd4cd9496845e1b35e7cb0c439758e4c4efb0 Mon Sep 17 00:00:00 2001 From: Travis Prescott <trpresco@microsoft.com> Date: Fri, 5 Jun 2026 11:58:34 -0700 Subject: [PATCH 27/33] Updates. --- .../src/api-md-consistency/api-md-consistency.js | 2 +- scripts/api_md_workflow/README.md | 6 +++--- .../sdk/template/azure-template/api.md | 12 ++++++++++++ .../sdk/template/azure-template/api.metadata.yml | 3 +++ 4 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 sdk/template/azure-template/sdk/template/azure-template/api.md create mode 100644 sdk/template/azure-template/sdk/template/azure-template/api.metadata.yml diff --git a/.github/workflows/src/api-md-consistency/api-md-consistency.js b/.github/workflows/src/api-md-consistency/api-md-consistency.js index a6d8ffb868fe..74419b9bf60c 100644 --- a/.github/workflows/src/api-md-consistency/api-md-consistency.js +++ b/.github/workflows/src/api-md-consistency/api-md-consistency.js @@ -44,7 +44,7 @@ function formatIssueSection(title, apiFiles) { const packageName = path.basename(packageDir); lines.push(`- ${packageDir}`); lines.push(` API file: ${apiFile}`); - lines.push(` Regenerate: azpysdk apistub --md --extract-metadata ${packageName}`); + lines.push(` Regenerate: azpysdk apistub --md --extract-metadata ${packageName} --dest-dir ${packageDir}`); } lines.push(""); return lines.join("\n"); diff --git a/scripts/api_md_workflow/README.md b/scripts/api_md_workflow/README.md index c782a88a9d9e..0e7c355e590c 100644 --- a/scripts/api_md_workflow/README.md +++ b/scripts/api_md_workflow/README.md @@ -22,7 +22,7 @@ It runs on pull requests for changes under `sdk/**`. - Regenerates `api.md` for those packages. - Fails if the generated files differ from the committed files. - Fails if an affected package does not have a committed `api.md`. -- Prints the mismatched or missing packages and the `azpysdk apistub --md --extract-metadata` command needed to regenerate each `api.md` file. +- Prints the mismatched or missing packages and the `azpysdk apistub --md --extract-metadata <package-name> --dest-dir <package-dir>` command needed to regenerate each `api.md` file. ## Script Layout @@ -49,7 +49,7 @@ Also writes `count=<n>` to `GITHUB_OUTPUT`. ### `regenerate.js` -Reads package directories from `API_MD_PACKAGES_FILE` and runs `azpysdk apistub --md --extract-metadata <package-name>` for each package. +Reads package directories from `API_MD_PACKAGES_FILE` and runs `azpysdk apistub --md --extract-metadata <package-name> --dest-dir <package-dir>` for each package. This script is used by the consistency check. @@ -100,4 +100,4 @@ Common variables include: 3. `find_affected.js` determines which packages were touched. 4. `regenerate.js` rebuilds `api.md` for those packages. 5. `find_mismatches.js` records any `api.md` drift, including missing or untracked `api.md` files. -6. If drift is found, the workflow fails and prints the affected packages plus the `azpysdk apistub --md --extract-metadata <package-name>` command to regenerate each `api.md` file locally. +6. If drift is found, the workflow fails and prints the affected packages plus the `azpysdk apistub --md --extract-metadata <package-name> --dest-dir <package-dir>` command to regenerate each `api.md` file locally. diff --git a/sdk/template/azure-template/sdk/template/azure-template/api.md b/sdk/template/azure-template/sdk/template/azure-template/api.md new file mode 100644 index 000000000000..638c13dae01a --- /dev/null +++ b/sdk/template/azure-template/sdk/template/azure-template/api.md @@ -0,0 +1,12 @@ +```py +namespace azure.template + + def azure.template.template_main() -> bool: ... + + +namespace azure.template.template_code + + def azure.template.template_code.template_main() -> bool: ... + + +``` \ No newline at end of file diff --git a/sdk/template/azure-template/sdk/template/azure-template/api.metadata.yml b/sdk/template/azure-template/sdk/template/azure-template/api.metadata.yml new file mode 100644 index 000000000000..f29742a2b179 --- /dev/null +++ b/sdk/template/azure-template/sdk/template/azure-template/api.metadata.yml @@ -0,0 +1,3 @@ +apiMdSha256: 9b0fa6154e3a859680da1a07f5106508983884de567522c5166fc57bacb9cb00 +parserVersion: 0.3.28 +pythonVersion: 3.12.9 From a82a7ee38cbba9c5e478b0ac754da806d3f6f404 Mon Sep 17 00:00:00 2001 From: Travis Prescott <trpresco@microsoft.com> Date: Fri, 5 Jun 2026 12:07:18 -0700 Subject: [PATCH 28/33] Make review PR creation only work for updates, not new packages. --- .github/skills/create-api-review-pr/SKILL.md | 37 ++-- .github/workflows/api-md-workflow-tests.yml | 26 +++ .../api-md-consistency/api-md-consistency.js | 2 +- scripts/api_md_workflow/README.md | 14 +- scripts/api_md_workflow/adapters/python.js | 4 +- .../api_md_workflow/create_api_review_pr.js | 194 ++++++++++-------- .../create_api_review_pr.test.js | 146 +++++++++++++ sdk/template/azure-template/api.md | 2 +- .../sdk/template/azure-template/api.md | 12 -- .../template/azure-template/api.metadata.yml | 3 - 10 files changed, 317 insertions(+), 123 deletions(-) create mode 100644 .github/workflows/api-md-workflow-tests.yml create mode 100644 scripts/api_md_workflow/create_api_review_pr.test.js delete mode 100644 sdk/template/azure-template/sdk/template/azure-template/api.md delete mode 100644 sdk/template/azure-template/sdk/template/azure-template/api.metadata.yml diff --git a/.github/skills/create-api-review-pr/SKILL.md b/.github/skills/create-api-review-pr/SKILL.md index 5a399f925fbd..135dfce1dc4d 100644 --- a/.github/skills/create-api-review-pr/SKILL.md +++ b/.github/skills/create-api-review-pr/SKILL.md @@ -1,11 +1,15 @@ --- name: create-api-review-pr -description: Create a GitHub PR for API review by comparing a baseline API surface against a target branch. Use this when the user wants to create an API review PR, compare API changes between versions, or review API surface differences for a package. +description: Create a GitHub PR for API review by comparing a baseline API surface against a target tag or branch. Use this when the user wants to create an API review PR, compare API changes between versions, or review API surface differences for a package. --- # Create API Review PR -Creates a dedicated API review PR that shows the diff between a baseline release and a target branch's API surface using `scripts/api_md_workflow/create_api_review_pr.js`. +Creates a dedicated API review PR that shows the diff between a baseline release and a target tag or branch's API surface using `scripts/api_md_workflow/create_api_review_pr.js`. + +## Unsupported Requests + +If the user asks to create an API review PR for a new package, explain that new packages do not use API review PRs and stop. Do not gather script inputs or run `create_api_review_pr.js` for new packages. ## Prerequisites @@ -21,14 +25,14 @@ Ask the user for the following using `vscode_askQuestions`: ### 1. Package Name (required) The Azure SDK package name (e.g. `azure-storage-blob`, `azure-ai-projects`). -### 2. Baseline (optional) +### 2. Baseline (required) The release tag to use as the baseline for comparison. Tags follow the format `<package-name>_<version>` (e.g. `azure-storage-blob_12.29.0`). - If the user provides a package name and version separately, construct the tag as `<package-name>_<version>`. -- If this is a **new package** with no prior release, the baseline should be omitted (the script handles this as an empty baseline). ### 3. Target (optional) The branch or PR to generate the "current" API surface from. Can be: +- A package release tag (e.g. `azure-storage-blob_12.30.0`) — used directly as a tag ref - A branch name (e.g. `main`, `feature-branch`) — fetched from `origin` - An `owner:branch` reference (e.g. `someone:their-branch`) — fetched from the fork - If omitted, defaults to `origin/main` @@ -38,15 +42,20 @@ The branch or PR to generate the "current" API surface from. Can be: Before running the script: 1. **Validate the package exists**: Confirm a directory matching `sdk/*/<package-name>` exists with a `pyproject.toml` or `setup.py`. -2. **Validate the baseline tag** (if provided): Run `git tag -l "<tag>"` to confirm the tag exists. If the user provided a version like `12.29.0`, construct the full tag as `<package-name>_<version>` and validate that. -3. **Confirm the working tree is clean**: Run `git status --porcelain` and warn if there are uncommitted changes. +2. **Validate the baseline tag**: Run `git tag -l "<tag>"` to confirm the tag exists. If the user provided a version like `12.29.0`, construct the full tag as `<package-name>_<version>` and validate that. +3. **Validate the target tag when applicable**: If the user provided a target version or tag, construct or validate the full tag as `<package-name>_<version>` and run `git tag -l "<tag>"`. +4. **Confirm the working tree is clean**: Run `git status --porcelain` and warn if there are uncommitted changes. ## Execution +This is a long-running operation. The script may take several minutes because it generates API surfaces for both the baseline and target, creates or reuses review branches, pushes branches, and then opens the draft PR. Do not treat quiet terminal periods during `apistub` generation as failure unless the command exits, prints an error, or waits for input. + +If `create_api_review_pr.js` fails while running this skill, do not patch the script, modify package files, retry with workaround edits, or try to manually complete branch/PR creation. Stop the workflow, report the failure clearly, include the relevant error details, and suggest practical next steps. + Run the following command from the repository root: ```bash -node scripts/api_md_workflow/create_api_review_pr.js --package-name <package-name> [--base <tag>] [--target <target>] +node scripts/api_md_workflow/create_api_review_pr.js --package-name <package-name> --base <tag> [--target <target>] ``` ### Examples @@ -56,21 +65,25 @@ node scripts/api_md_workflow/create_api_review_pr.js --package-name <package-nam node scripts/api_md_workflow/create_api_review_pr.js --package-name azure-storage-blob --base azure-storage-blob_12.29.0 --target someone:feature-branch ``` -**Review against main (no target specified):** +**Release-to-release review (comparing two package tags):** ```bash -node scripts/api_md_workflow/create_api_review_pr.js --package-name azure-cosmos --base azure-cosmos_4.14.0 +node scripts/api_md_workflow/create_api_review_pr.js --package-name azure-ai-projects --base azure-ai-projects_2.1.0 --target azure-ai-projects_2.2.0 ``` -**New package (no baseline):** +**Review against main (no target specified):** ```bash -node scripts/api_md_workflow/create_api_review_pr.js --package-name azure-keyvault-secrets --target main +node scripts/api_md_workflow/create_api_review_pr.js --package-name azure-cosmos --base azure-cosmos_4.14.0 ``` ## Post-Execution The script will: 1. Generate `API.md` for both baseline and target -2. Push `base_<package>_<version>` and `review_<package>_<version>` branches +2. Push `apireview/base_<package>_<version>` and `apireview/review_<package>_<version>` branches 3. Open a draft PR (or print a compare URL if `gh pr create` fails) +During execution, report progress at major phases: baseline generation, target generation, branch creation or reuse, branch push, and PR creation. If the terminal is quiet, check whether the process is still running before assuming it is hung. + +When the target is a tag, the PR body labels it as `Target tag`. Branch and fork targets are labeled as `Working branch`. + Report the PR URL to the user when complete. diff --git a/.github/workflows/api-md-workflow-tests.yml b/.github/workflows/api-md-workflow-tests.yml new file mode 100644 index 000000000000..8a10b49e7df0 --- /dev/null +++ b/.github/workflows/api-md-workflow-tests.yml @@ -0,0 +1,26 @@ +name: API.md Workflow Unit Tests + +on: + workflow_dispatch: + pull_request: + branches: [ main ] + paths: + - "scripts/api_md_workflow/**" + - ".github/workflows/api-md-workflow-tests.yml" + +permissions: + contents: read + +jobs: + unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Run API.md workflow unit tests + run: node --test scripts/api_md_workflow/*.test.js \ No newline at end of file diff --git a/.github/workflows/src/api-md-consistency/api-md-consistency.js b/.github/workflows/src/api-md-consistency/api-md-consistency.js index 74419b9bf60c..bff427a272d9 100644 --- a/.github/workflows/src/api-md-consistency/api-md-consistency.js +++ b/.github/workflows/src/api-md-consistency/api-md-consistency.js @@ -44,7 +44,7 @@ function formatIssueSection(title, apiFiles) { const packageName = path.basename(packageDir); lines.push(`- ${packageDir}`); lines.push(` API file: ${apiFile}`); - lines.push(` Regenerate: azpysdk apistub --md --extract-metadata ${packageName} --dest-dir ${packageDir}`); + lines.push(` Regenerate: azpysdk apistub --md --extract-metadata ${packageName} --dest-dir .`); } lines.push(""); return lines.join("\n"); diff --git a/scripts/api_md_workflow/README.md b/scripts/api_md_workflow/README.md index 0e7c355e590c..1bda32ffceb5 100644 --- a/scripts/api_md_workflow/README.md +++ b/scripts/api_md_workflow/README.md @@ -22,7 +22,7 @@ It runs on pull requests for changes under `sdk/**`. - Regenerates `api.md` for those packages. - Fails if the generated files differ from the committed files. - Fails if an affected package does not have a committed `api.md`. -- Prints the mismatched or missing packages and the `azpysdk apistub --md --extract-metadata <package-name> --dest-dir <package-dir>` command needed to regenerate each `api.md` file. +- Prints the mismatched or missing packages and the `azpysdk apistub --md --extract-metadata <package-name> --dest-dir .` command needed to regenerate each `api.md` file from the repository root. ## Script Layout @@ -49,7 +49,7 @@ Also writes `count=<n>` to `GITHUB_OUTPUT`. ### `regenerate.js` -Reads package directories from `API_MD_PACKAGES_FILE` and runs `azpysdk apistub --md --extract-metadata <package-name> --dest-dir <package-dir>` for each package. +Reads package directories from `API_MD_PACKAGES_FILE` and runs `azpysdk apistub --md --extract-metadata <package-name> --dest-dir <package-dir>` for each package from the repository root. This script is used by the consistency check. @@ -73,6 +73,14 @@ API review PR creation now uses a shared JavaScript orchestrator with a language This split allows the core workflow to be reused across other language repos while keeping generation behavior language-specific. +`create_api_review_pr.js` compares a baseline package release tag with a target API surface. The target can be a package release tag, an `origin` branch, or an `owner:branch` fork reference. When the target is a tag, the generated PR body identifies it as a target tag instead of a working branch. + +Example comparing two package release tags: + +```bash +node scripts/api_md_workflow/create_api_review_pr.js --package-name azure-ai-projects --base azure-ai-projects_2.1.0 --target azure-ai-projects_2.2.0 +``` + ### `api_md_workflow.config.json` Shared configuration for adapter selection across `api_md_workflow` scripts. @@ -100,4 +108,4 @@ Common variables include: 3. `find_affected.js` determines which packages were touched. 4. `regenerate.js` rebuilds `api.md` for those packages. 5. `find_mismatches.js` records any `api.md` drift, including missing or untracked `api.md` files. -6. If drift is found, the workflow fails and prints the affected packages plus the `azpysdk apistub --md --extract-metadata <package-name> --dest-dir <package-dir>` command to regenerate each `api.md` file locally. +6. If drift is found, the workflow fails and prints the affected packages plus the `azpysdk apistub --md --extract-metadata <package-name> --dest-dir .` command to regenerate each `api.md` file locally from the repository root. diff --git a/scripts/api_md_workflow/adapters/python.js b/scripts/api_md_workflow/adapters/python.js index f6e246b4f95a..e407211c43dd 100644 --- a/scripts/api_md_workflow/adapters/python.js +++ b/scripts/api_md_workflow/adapters/python.js @@ -131,7 +131,7 @@ function generateApiForPackage({ const pythonExecutable = runtimeExecutable || process.env.RUNTIME_EXECUTABLE; run( pythonExecutable, - ["-m", "azpysdk.main", "apistub", "--md", "--extract-metadata", "--install-deps", "--dest-dir", packageDir, packageName], + ["-m", "azpysdk.main", "apistub", "--md", "--extract-metadata", "--dest-dir", packageDir, packageName], { cwd: repoRoot, check: true, @@ -141,7 +141,7 @@ function generateApiForPackage({ return; } - run("azpysdk", ["apistub", "--md", "--extract-metadata", "--install-deps", "--dest-dir", packageDir, packageName], { + run("azpysdk", ["apistub", "--md", "--extract-metadata", "--dest-dir", packageDir, packageName], { cwd: repoRoot, check: true, logger: activeLogger, diff --git a/scripts/api_md_workflow/create_api_review_pr.js b/scripts/api_md_workflow/create_api_review_pr.js index d339306e83c7..e13f82b1060b 100644 --- a/scripts/api_md_workflow/create_api_review_pr.js +++ b/scripts/api_md_workflow/create_api_review_pr.js @@ -69,6 +69,10 @@ function parseArgs(argv) { throw new Error("Missing required --package-name"); } + if (!args.base) { + throw new Error("Missing required --base"); + } + return args; } @@ -117,19 +121,19 @@ function resolveGitExecutable() { return cachedGitExecutable; } -function git(args, options = {}) { +let git = function gitCommand(args, options = {}) { return run(resolveGitExecutable(), args, { ...options, env: buildGitAwareEnv(options.env), }); -} +}; -function gh(args, options = {}) { +let gh = function ghCommand(args, options = {}) { return run("gh", args, { ...options, env: buildGitAwareEnv(options.env), }); -} +}; function gitOut(args) { return git(args, { capture: true }).stdout.trim(); @@ -179,19 +183,33 @@ function validateBaseTag(packageName, baseTag) { return version; } +function resolveTargetTag(target) { + if (tagExists(target)) { + return target; + } + + git(["fetch", REMOTE, "tag", target], { check: false, capture: true }); + return tagExists(target) ? target : null; +} + function remoteBranchRef(branch) { git(["fetch", REMOTE, branch]); return `${REMOTE}/${branch}`; } function resolveTargetRef(target) { + const targetTag = resolveTargetTag(target); + if (targetTag) { + return targetTag; + } + if (!target.includes(":")) { return remoteBranchRef(target); } const [owner, branch] = target.split(":", 2); if (!owner || !branch) { - throw new Error(`ERROR: invalid --target '${target}'. Expected either 'branch' or 'owner:branch'.`); + throw new Error(`ERROR: invalid --target '${target}'. Expected 'tag', 'branch', or 'owner:branch'.`); } const forkUrl = `https://github.com/${owner}/azure-sdk-for-python.git`; @@ -575,13 +593,9 @@ function branchReferenceParts(headSelector) { }; } -function exactHeadSelector(headSelector) { - const { owner, branch } = branchReferenceParts(headSelector); - return `${owner}:${branch}`; -} - function findOpenPrForHead(headSelector) { - const selector = exactHeadSelector(headSelector); + const { owner, branch } = branchReferenceParts(headSelector); + const selector = `${owner}:${branch}`; const allPrs = []; const direct = gh( @@ -595,7 +609,7 @@ function findOpenPrForHead(headSelector) { "--state", "open", "--json", - "number,url,state,updatedAt", + "number,url,state,updatedAt,headRefName,headRepositoryOwner", "--limit", "50", ], @@ -609,7 +623,7 @@ function findOpenPrForHead(headSelector) { } } - const searchQuery = `repo:Azure/azure-sdk-for-python is:pr is:open head:${selector}`; + const searchQuery = `repo:Azure/azure-sdk-for-python is:pr is:open head:${branch}`; const search = gh( [ "pr", @@ -621,7 +635,7 @@ function findOpenPrForHead(headSelector) { "--state", "open", "--json", - "number,url,state,updatedAt", + "number,url,state,updatedAt,headRefName,headRepositoryOwner", "--limit", "50", ], @@ -641,7 +655,14 @@ function findOpenPrForHead(headSelector) { const deduped = new Map(); for (const pr of allPrs) { - if (pr && typeof pr === "object" && "number" in pr) { + if ( + pr && + typeof pr === "object" && + "number" in pr && + pr.headRefName === branch && + pr.headRepositoryOwner && + pr.headRepositoryOwner.login === owner + ) { deduped.set(pr.number, pr); } } @@ -746,15 +767,6 @@ function branchReferenceMarkdown(headSelector) { return `[branch \`${display}\`](${branchUrl})`; } -function workingReferenceMarkdown(headSelector) { - const pr = findOpenPrForHead(headSelector); - if (pr) { - return `[PR #${pr.number}](${pr.url})`; - } - - return branchReferenceMarkdown(headSelector); -} - function baselineReferenceMarkdown(baseTag) { if (!baseTag) { return "empty"; @@ -765,6 +777,29 @@ function baselineReferenceMarkdown(baseTag) { return `[tag \`${baseTag}\`](${commitUrl})`; } +function targetReferenceInfo(headSelector) { + const targetTag = resolveTargetTag(headSelector); + if (targetTag) { + return { + label: "Target tag", + markdown: baselineReferenceMarkdown(targetTag), + }; + } + + const pr = findOpenPrForHead(headSelector); + if (pr) { + return { + label: "Working PR", + markdown: `[PR #${pr.number}](${pr.url})`, + }; + } + + return { + label: "Working branch", + markdown: branchReferenceMarkdown(headSelector), + }; +} + function writeBytes(filePath, bytes) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, bytes); @@ -812,6 +847,7 @@ async function generateApiBytesForRef({ return result; } finally { // Restore the package directory to the current branch state + git(["reset", "--", packageRelative], { check: false }); git(["checkout", "HEAD", "--", packageRelative]); // Clean any untracked files that the generation may have left behind git(["clean", "-fd", "--", packageRelative], { check: false }); @@ -833,28 +869,22 @@ async function main() { git(["fetch", REMOTE, "main"]); - let baseVersion = "none"; - if (args.base) { - baseVersion = validateBaseTag(args.packageName, args.base); - } + const baseVersion = validateBaseTag(args.packageName, args.base); const targetRef = args.target ? resolveTargetRef(args.target) : MAIN_REF; try { - let baseResult = null; - if (args.base) { - logInfo(`\n=== Capturing baseline api.md from tag ${args.base} ===`); - baseResult = await generateApiBytesForRef({ - adapter, - repoRoot: REPO_ROOT, - packageName: args.packageName, - packageDir, - runtimeExecutable: args.runtimeExecutable, - ref: args.base, - refLabel: args.base, - logger, - }); - } + logInfo(`\n=== Capturing baseline api.md from tag ${args.base} ===`); + const baseResult = await generateApiBytesForRef({ + adapter, + repoRoot: REPO_ROOT, + packageName: args.packageName, + packageDir, + runtimeExecutable: args.runtimeExecutable, + ref: args.base, + refLabel: args.base, + logger, + }); logInfo(`\n=== Capturing target api.md from ${targetRef} ===`); const targetResult = await generateApiBytesForRef({ @@ -893,41 +923,13 @@ async function main() { } else { logInfo(`\n=== Creating base branch ${baseBranch} ===`); git(["checkout", "-B", baseBranch, MAIN_REF]); - - if (baseResult !== null) { - writeBytes(apiPath, baseResult.apiMd); - git(["add", apiRelative]); - if (baseResult.metadata) { - writeBytes(metaFilePath, baseResult.metadata); - git(["add", metaRelative]); - } - git(["commit", "-m", `[API Review] Baseline api.md for ${args.packageName} ${baseVersion}`]); - } else { - const tracked = git(["ls-files", "--error-unmatch", apiRelative], { - capture: true, - check: false, - }); - - if (tracked.status === 0) { - git(["rm", apiRelative]); - const metaTracked = git(["ls-files", "--error-unmatch", metaRelative], { - capture: true, - check: false, - }); - if (metaTracked.status === 0) { - git(["rm", metaRelative]); - } - git(["commit", "-m", `[API Review] Remove api.md for ${args.packageName} (empty baseline)`]); - } else { - if (fs.existsSync(apiPath)) { - fs.unlinkSync(apiPath); - } - if (fs.existsSync(metaFilePath)) { - fs.unlinkSync(metaFilePath); - } - git(["commit", "--allow-empty", "-m", `[API Review] Empty baseline for ${args.packageName}`]); - } + writeBytes(apiPath, baseResult.apiMd); + git(["add", apiRelative]); + if (baseResult.metadata) { + writeBytes(metaFilePath, baseResult.metadata); + git(["add", metaRelative]); } + git(["commit", "-m", `[API Review] Baseline api.md for ${args.packageName} ${baseVersion}`]); git(["push", "--force-with-lease", REMOTE, baseBranch]); } @@ -974,14 +976,14 @@ async function main() { } const title = `[API Review] ${args.packageName} ${targetVersion} (base ${baseVersion})`; - const workingSelector = args.target || originalBranch; - const workingRef = workingReferenceMarkdown(workingSelector); + const workingSelector = args.target || "main"; + const workingReference = targetReferenceInfo(workingSelector); const baselineRef = baselineReferenceMarkdown(args.base); const body = [ `Automated API review PR for ${args.packageName}.`, "", - `- **Working branch:** ${workingRef} (version ${targetVersion})`, + `- **${workingReference.label}:** ${workingReference.markdown} (version ${targetVersion})`, `- **Baseline:** ${baselineRef} (version ${baseVersion})`, "", "Generated by scripts/api_md_workflow/create_api_review_pr.js.", @@ -1034,13 +1036,27 @@ async function main() { } } -(async () => { - logger = await getDefaultLogger(); - try { - process.exit(await main()); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logError(message); - process.exit(1); - } -})(); +if (require.main === module) { + (async () => { + logger = await getDefaultLogger(); + try { + process.exit(await main()); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logError(message); + process.exit(1); + } + })(); +} else { + module.exports = { + __setCommandRunners({ git: gitRunner, gh: ghRunner }) { + if (gitRunner) { + git = gitRunner; + } + if (ghRunner) { + gh = ghRunner; + } + }, + targetReferenceInfo, + }; +} diff --git a/scripts/api_md_workflow/create_api_review_pr.test.js b/scripts/api_md_workflow/create_api_review_pr.test.js new file mode 100644 index 000000000000..e21bba47cbb1 --- /dev/null +++ b/scripts/api_md_workflow/create_api_review_pr.test.js @@ -0,0 +1,146 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const workflow = require("./create_api_review_pr"); + +function commandResult(stdout = "[]", status = 0) { + return { + status, + stdout, + stderr: "", + }; +} + +function stubGitNoTags() { + return commandResult("", 1); +} + +function stubGhWithSearchResults(results) { + return (args) => { + if (args.includes("--search")) { + return commandResult(JSON.stringify(results)); + } + + return commandResult("[]"); + }; +} + +test("targetReferenceInfo links matching open PR from direct head query", () => { + workflow.__setCommandRunners({ + git: stubGitNoTags, + gh: (args) => { + if (args.includes("--head")) { + return commandResult( + JSON.stringify([ + { + number: 45678, + url: "https://github.com/Azure/azure-sdk-for-python/pull/45678", + state: "OPEN", + updatedAt: "2026-06-05T00:00:00Z", + headRefName: "users/example/direct-feature", + headRepositoryOwner: { login: "example" }, + }, + ]), + ); + } + + return commandResult("[]"); + }, + }); + + assert.deepEqual(workflow.targetReferenceInfo("example:users/example/direct-feature"), { + label: "Working PR", + markdown: "[PR #45678](https://github.com/Azure/azure-sdk-for-python/pull/45678)", + }); +}); + +test("targetReferenceInfo links matching open PR for owner-qualified branch target", () => { + workflow.__setCommandRunners({ + git: stubGitNoTags, + gh: stubGhWithSearchResults([ + { + number: 12345, + url: "https://github.com/Azure/azure-sdk-for-python/pull/12345", + state: "OPEN", + updatedAt: "2026-06-05T00:00:00Z", + headRefName: "users/example/feature", + headRepositoryOwner: { login: "example" }, + }, + ]), + }); + + assert.deepEqual(workflow.targetReferenceInfo("example:users/example/feature"), { + label: "Working PR", + markdown: "[PR #12345](https://github.com/Azure/azure-sdk-for-python/pull/12345)", + }); +}); + +test("targetReferenceInfo keeps origin/main as branch when search returns fork PRs named main", () => { + workflow.__setCommandRunners({ + git: stubGitNoTags, + gh: stubGhWithSearchResults([ + { + number: 23456, + url: "https://github.com/Azure/azure-sdk-for-python/pull/23456", + state: "OPEN", + updatedAt: "2026-06-05T00:00:00Z", + headRefName: "main", + headRepositoryOwner: { login: "example" }, + }, + ]), + }); + + assert.deepEqual(workflow.targetReferenceInfo("origin/main"), { + label: "Working branch", + markdown: "[branch `origin/main`](https://github.com/Azure/azure-sdk-for-python/tree/main)", + }); +}); + +test("targetReferenceInfo keeps branch reference when no open PR matches both owner and branch", () => { + workflow.__setCommandRunners({ + git: stubGitNoTags, + gh: stubGhWithSearchResults([ + { + number: 34567, + url: "https://github.com/Azure/azure-sdk-for-python/pull/34567", + state: "OPEN", + updatedAt: "2026-06-05T00:00:00Z", + headRefName: "users/example/feature", + headRepositoryOwner: { login: "someone-else" }, + }, + ]), + }); + + assert.deepEqual(workflow.targetReferenceInfo("example:users/example/feature"), { + label: "Working branch", + markdown: "[branch `example:users/example/feature`](https://github.com/example/azure-sdk-for-python/tree/users%2Fexample%2Ffeature)", + }); +}); + +test("targetReferenceInfo treats existing target tag as tag and does not query PRs", () => { + let prLookupCount = 0; + + workflow.__setCommandRunners({ + git: (args) => { + if (args[0] === "rev-parse" && args.includes("refs/tags/azure-example_1.2.3")) { + return commandResult("", 0); + } + + if (args[0] === "rev-list") { + return commandResult("abc123def456\n", 0); + } + + return commandResult("", 1); + }, + gh: () => { + prLookupCount += 1; + return commandResult("[]"); + }, + }); + + assert.deepEqual(workflow.targetReferenceInfo("azure-example_1.2.3"), { + label: "Target tag", + markdown: "[tag `azure-example_1.2.3`](https://github.com/Azure/azure-sdk-for-python/commit/abc123def456)", + }); + assert.equal(prLookupCount, 0); +}); \ No newline at end of file diff --git a/sdk/template/azure-template/api.md b/sdk/template/azure-template/api.md index 85eb954408ab..638c13dae01a 100644 --- a/sdk/template/azure-template/api.md +++ b/sdk/template/azure-template/api.md @@ -9,4 +9,4 @@ namespace azure.template.template_code def azure.template.template_code.template_main() -> bool: ... -``` +``` \ No newline at end of file diff --git a/sdk/template/azure-template/sdk/template/azure-template/api.md b/sdk/template/azure-template/sdk/template/azure-template/api.md deleted file mode 100644 index 638c13dae01a..000000000000 --- a/sdk/template/azure-template/sdk/template/azure-template/api.md +++ /dev/null @@ -1,12 +0,0 @@ -```py -namespace azure.template - - def azure.template.template_main() -> bool: ... - - -namespace azure.template.template_code - - def azure.template.template_code.template_main() -> bool: ... - - -``` \ No newline at end of file diff --git a/sdk/template/azure-template/sdk/template/azure-template/api.metadata.yml b/sdk/template/azure-template/sdk/template/azure-template/api.metadata.yml deleted file mode 100644 index f29742a2b179..000000000000 --- a/sdk/template/azure-template/sdk/template/azure-template/api.metadata.yml +++ /dev/null @@ -1,3 +0,0 @@ -apiMdSha256: 9b0fa6154e3a859680da1a07f5106508983884de567522c5166fc57bacb9cb00 -parserVersion: 0.3.28 -pythonVersion: 3.12.9 From 8d07492f32bdb74896507e3d5a7a96f80d901597 Mon Sep 17 00:00:00 2001 From: Travis Prescott <trpresco@microsoft.com> Date: Fri, 5 Jun 2026 15:47:43 -0700 Subject: [PATCH 29/33] CI fixes. --- .github/skills/generate-api-markdown/SKILL.md | 5 +++-- .github/workflows/api-consistency.yml | 1 + eng/tools/azure-sdk-tools/tests/test_apistub.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/skills/generate-api-markdown/SKILL.md b/.github/skills/generate-api-markdown/SKILL.md index 4eebc5d43c38..dbf28fcbb551 100644 --- a/.github/skills/generate-api-markdown/SKILL.md +++ b/.github/skills/generate-api-markdown/SKILL.md @@ -7,7 +7,7 @@ description: Generate an API markdown file and token file using ApiView. Use thi ## Prerequisites -1. Activate your virtual environment with a Python version that is strictly less than the version limit specified in `eng/tools/azure-sdk-tools/azpysdk/apistub.py`. +1. Activate your virtual environment. 2. Install the required dependencies: ```bash cd <repo_root> @@ -19,5 +19,6 @@ description: Generate an API markdown file and token file using ApiView. Use thi 1. Navigate to the desired package directory 2. Run the command: ```bash - azpysdk apistub --md --extract-metadata . + azpysdk apistub --md --extract-metadata --install-deps --dest-dir . . + ``` 3. The command outputs the location of the generated markdown file. Provide this file to the user for review. \ No newline at end of file diff --git a/.github/workflows/api-consistency.yml b/.github/workflows/api-consistency.yml index 006c032d23dc..7bdf8a93d9ad 100644 --- a/.github/workflows/api-consistency.yml +++ b/.github/workflows/api-consistency.yml @@ -42,6 +42,7 @@ jobs: shell: bash run: | python -m pip install --upgrade pip + python -m pip install -r eng/apiview_reqs.txt --index-url=https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-python/pypi/simple/ python -m pip install ./eng/tools/azure-sdk-tools - name: Setup Node.js diff --git a/eng/tools/azure-sdk-tools/tests/test_apistub.py b/eng/tools/azure-sdk-tools/tests/test_apistub.py index b396629caeb8..ff7ad42e5cd0 100644 --- a/eng/tools/azure-sdk-tools/tests/test_apistub.py +++ b/eng/tools/azure-sdk-tools/tests/test_apistub.py @@ -91,7 +91,7 @@ def _make_args(self, dest_dir=None, generate_md=False): @patch("azpysdk.apistub.create_package_and_install") @patch("azpysdk.apistub.install_into_venv") @patch("azpysdk.apistub.set_envvar_defaults") - def test_dest_dir_creates_package_subfolder( + def test_dest_dir_uses_destination_directory( self, _env, _install, _create, _get_whl, _get_mapping, tmp_path, monkeypatch ): """When --dest-dir is given, output should go directly to <dest_dir>/.""" From da6a96ec81cf264263463c0787a1b1d30e4ea914 Mon Sep 17 00:00:00 2001 From: Travis Prescott <trpresco@microsoft.com> Date: Mon, 8 Jun 2026 08:40:17 -0700 Subject: [PATCH 30/33] Use shared helper method. --- .../api-md-consistency/api-md-consistency.js | 60 +++++++++++++------ 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/.github/workflows/src/api-md-consistency/api-md-consistency.js b/.github/workflows/src/api-md-consistency/api-md-consistency.js index bff427a272d9..7c397e67a86b 100644 --- a/.github/workflows/src/api-md-consistency/api-md-consistency.js +++ b/.github/workflows/src/api-md-consistency/api-md-consistency.js @@ -1,22 +1,48 @@ const fs = require("fs"); const path = require("path"); -const { spawnSync } = require("child_process"); +const { pathToFileURL } = require("url"); -function runNode(scriptRelativePath, workspace, core) { - const result = spawnSync("node", [scriptRelativePath], { - cwd: workspace, - env: process.env, - encoding: "utf-8", - }); +const SHARED_SRC_ROOT = path.resolve(__dirname, "..", "..", "..", "shared", "src"); +const sharedModuleCache = new Map(); - if (result.stdout) { - core.info(result.stdout.trimEnd()); +async function loadSharedModule(fileName) { + if (sharedModuleCache.has(fileName)) { + return sharedModuleCache.get(fileName); } - if (result.stderr) { - core.info(result.stderr.trimEnd()); - } - if (result.status !== 0) { - throw new Error(`Command failed (${result.status}): node ${scriptRelativePath}`); + + const filePath = path.join(SHARED_SRC_ROOT, fileName); + const modulePromise = import(pathToFileURL(filePath).href); + sharedModuleCache.set(fileName, modulePromise); + return modulePromise; +} + +async function runNode(scriptRelativePath, workspace, core) { + const { execFile, isExecError } = await loadSharedModule("exec.js"); + + try { + const result = await execFile("node", [scriptRelativePath], { + cwd: workspace, + logger: core, + }); + + if (result.stdout) { + core.info(result.stdout.trimEnd()); + } + if (result.stderr) { + core.info(result.stderr.trimEnd()); + } + } catch (error) { + if (isExecError(error)) { + if (error.stdout) { + core.info(error.stdout.trimEnd()); + } + if (error.stderr) { + core.info(error.stderr.trimEnd()); + } + } + + const status = isExecError(error) && Number.isInteger(error.code) ? error.code : 1; + throw new Error(`Command failed (${status}): node ${scriptRelativePath}`); } } @@ -53,7 +79,7 @@ function formatIssueSection(title, apiFiles) { module.exports = async function apiMdConsistency({ core }) { const workspace = process.env.GITHUB_WORKSPACE || process.cwd(); - runNode("scripts/api_md_workflow/find_affected.js", workspace, core); + await runNode("scripts/api_md_workflow/find_affected.js", workspace, core); const affected = readLines(process.env.API_MD_PACKAGES_FILE, workspace); const changedCount = affected.length; @@ -71,8 +97,8 @@ module.exports = async function apiMdConsistency({ core }) { }; } - runNode("scripts/api_md_workflow/regenerate.js", workspace, core); - runNode("scripts/api_md_workflow/find_mismatches.js", workspace, core); + await runNode("scripts/api_md_workflow/regenerate.js", workspace, core); + await runNode("scripts/api_md_workflow/find_mismatches.js", workspace, core); const mismatches = readLines(process.env.API_MD_MISMATCHES_FILE, workspace); const missing = readLines(process.env.API_MD_MISSING_FILE, workspace); From 8fdfca0cfc1de34f3f852b87ade732d6444f0f91 Mon Sep 17 00:00:00 2001 From: Travis Prescott <trpresco@microsoft.com> Date: Mon, 8 Jun 2026 10:19:29 -0700 Subject: [PATCH 31/33] Add metadata to review PRs. --- .../api_md_workflow/create_api_review_pr.js | 162 +++++++++++++- .../create_api_review_pr.test.js | 200 ++++++++++++++++++ 2 files changed, 352 insertions(+), 10 deletions(-) diff --git a/scripts/api_md_workflow/create_api_review_pr.js b/scripts/api_md_workflow/create_api_review_pr.js index e13f82b1060b..3374b7c87c54 100644 --- a/scripts/api_md_workflow/create_api_review_pr.js +++ b/scripts/api_md_workflow/create_api_review_pr.js @@ -9,6 +9,8 @@ const { loadAdapter, loadWorkflowConfig } = require("./adapter_config"); const REPO_ROOT = path.resolve(__dirname, "..", ".."); const REMOTE = "origin"; const MAIN_REF = `${REMOTE}/main`; +const SYNC_METADATA_MARKER = "api-md-review-sync"; +const SYNC_METADATA_WARNING = "DO NOT MODIFY THESE CONTENTS!"; let logger = console; function logInfo(message) { @@ -221,6 +223,14 @@ function packageRelDir(packageDir) { return path.relative(REPO_ROOT, packageDir).split(path.sep).join("/"); } +function normalizePackageDir(packageDir) { + if (path.isAbsolute(packageDir)) { + return packageRelDir(packageDir); + } + + return packageDir.split(path.sep).join("/"); +} + function apiMdPath(packageDir) { return path.join(packageDir, "api.md"); } @@ -593,6 +603,124 @@ function branchReferenceParts(headSelector) { }; } +function syncWorkingBranchInfo(headSelector) { + if (!headSelector) { + return null; + } + + const targetTag = resolveTargetTag(headSelector); + if (targetTag) { + return null; + } + + const { owner, branch } = branchReferenceParts(headSelector); + return { owner, branch }; +} + +function buildSyncMetadataObject({ packageName, packageDir, baseBranch, reviewBranch, headSelector }) { + const workingBranch = syncWorkingBranchInfo(headSelector); + if (!workingBranch) { + return null; + } + + const metadata = { + schemaVersion: 1, + repository: "Azure/azure-sdk-for-python", + packageName, + packageDir: normalizePackageDir(packageDir), + baseBranch, + reviewBranch, + workingOwner: workingBranch.owner, + workingBranch: workingBranch.branch, + }; + + const workingPr = findOpenPrForHead(headSelector); + metadata.workingPrNumber = workingPr && Number.isInteger(workingPr.number) ? workingPr.number : null; + + return metadata; +} + +function buildSyncMetadataBlock(metadata) { + if (!metadata) { + return null; + } + + return [ + `<!-- ${SYNC_METADATA_MARKER}`, + SYNC_METADATA_WARNING, + JSON.stringify(metadata, null, 2), + "-->", + ].join("\n"); +} + +function replaceSyncMetadataBlock(body, metadataBlock) { + const cleanedBody = String(body || "") + .replace(new RegExp(`<!--\\s*${SYNC_METADATA_MARKER}[\\s\\S]*?-->\\s*`, "g"), "") + .trimEnd(); + + if (!metadataBlock) { + return cleanedBody; + } + + return `${cleanedBody}\n\n${metadataBlock}`; +} + +function buildReviewPrBody({ packageName, targetVersion, baseVersion, workingReference, baselineRef, syncMetadataBlock }) { + const lines = [ + `Automated API review PR for ${packageName}.`, + "", + `- **${workingReference.label}:** ${workingReference.markdown} (version ${targetVersion})`, + `- **Baseline:** ${baselineRef} (version ${baseVersion})`, + ]; + + if (workingReference.label === "Target tag") { + lines.push("- **Update behavior:** Static tag-to-tag review; this PR cannot be automatically updated from a working branch."); + } + + lines.push("", "Generated by scripts/api_md_workflow/create_api_review_pr.js."); + + return replaceSyncMetadataBlock(lines.join("\n"), syncMetadataBlock); +} + +function updatePrBody(prNumber, body) { + return gh( + [ + "api", + `repos/Azure/azure-sdk-for-python/pulls/${prNumber}`, + "--method", + "PATCH", + "--field", + `body=${body}`, + ], + { check: false, capture: true }, + ); +} + +function ensurePrBodySyncMetadata(pr, metadataBlock) { + if (!metadataBlock || !pr || !Number.isInteger(pr.number)) { + return; + } + + const desiredBody = replaceSyncMetadataBlock(pr.body || "", metadataBlock); + if (desiredBody === (pr.body || "")) { + return; + } + + const result = updatePrBody(pr.number, desiredBody); + if (result.status === 0) { + logInfo(`Updated API review sync metadata on PR #${pr.number}.`); + return; + } + + const details = [ + result.stderr ? `stderr: ${result.stderr.replace(/\r?\n/g, " ").trim()}` : "", + result.stdout ? `stdout: ${result.stdout.replace(/\r?\n/g, " ").trim()}` : "", + ] + .filter(Boolean) + .join("\n "); + logWarning(`WARNING: failed to update API review sync metadata on PR #${pr.number}.` + (details ? `\n ${details}` : "")); +} + function findOpenPrForHead(headSelector) { const { owner, branch } = branchReferenceParts(headSelector); const selector = `${owner}:${branch}`; @@ -684,7 +812,7 @@ function findOpenPrForBranches(baseBranch, headBranch) { "--state", "open", "--json", - "number,url,state,updatedAt", + "number,url,state,updatedAt,body", "--limit", "20", ], @@ -707,7 +835,7 @@ function findOpenPrForBranches(baseBranch, headBranch) { "--search", `repo:Azure/azure-sdk-for-python is:pr is:open head:${headBranch} base:${baseBranch}`, "--json", - "number,url,state,updatedAt", + "number,url,state,updatedAt,body", "--limit", "20", ], @@ -979,19 +1107,28 @@ async function main() { const workingSelector = args.target || "main"; const workingReference = targetReferenceInfo(workingSelector); const baselineRef = baselineReferenceMarkdown(args.base); + const syncMetadata = buildSyncMetadataObject({ + packageName: args.packageName, + packageDir, + baseBranch, + reviewBranch, + headSelector: workingSelector, + }); + const syncMetadataBlock = buildSyncMetadataBlock(syncMetadata); - const body = [ - `Automated API review PR for ${args.packageName}.`, - "", - `- **${workingReference.label}:** ${workingReference.markdown} (version ${targetVersion})`, - `- **Baseline:** ${baselineRef} (version ${baseVersion})`, - "", - "Generated by scripts/api_md_workflow/create_api_review_pr.js.", - ].join("\n"); + const body = buildReviewPrBody({ + packageName: args.packageName, + targetVersion, + baseVersion, + workingReference, + baselineRef, + syncMetadataBlock, + }); if (baseSelection.reused && reviewSelection.reused) { const existingPr = findOpenPrForBranches(baseBranch, reviewBranch); if (existingPr) { + ensurePrBodySyncMetadata(existingPr, syncMetadataBlock); logInfo(`\n=== Reusing existing PR #${existingPr.number} ===`); logInfo(existingPr.url); return 0; @@ -1009,6 +1146,7 @@ async function main() { } else { const existingPr = findOpenPrForBranches(baseBranch, reviewBranch); if (existingPr) { + ensurePrBodySyncMetadata(existingPr, syncMetadataBlock); logInfo(`\n=== Reusing existing PR #${existingPr.number} ===`); logInfo(existingPr.url); return 0; @@ -1057,6 +1195,10 @@ if (require.main === module) { gh = ghRunner; } }, + buildSyncMetadataBlock, + buildSyncMetadataObject, + buildReviewPrBody, + replaceSyncMetadataBlock, targetReferenceInfo, }; } diff --git a/scripts/api_md_workflow/create_api_review_pr.test.js b/scripts/api_md_workflow/create_api_review_pr.test.js index e21bba47cbb1..25172a66f2e3 100644 --- a/scripts/api_md_workflow/create_api_review_pr.test.js +++ b/scripts/api_md_workflow/create_api_review_pr.test.js @@ -25,6 +25,14 @@ function stubGhWithSearchResults(results) { }; } +function parseSyncMetadataBlock(block) { + const jsonText = block + .replace(/^<!-- api-md-review-sync\n/, "") + .replace(/^DO NOT MODIFY THESE CONTENTS!\n/, "") + .replace(/\n-->$/, ""); + return JSON.parse(jsonText); +} + test("targetReferenceInfo links matching open PR from direct head query", () => { workflow.__setCommandRunners({ git: stubGitNoTags, @@ -143,4 +151,196 @@ test("targetReferenceInfo treats existing target tag as tag and does not query P markdown: "[tag `azure-example_1.2.3`](https://github.com/Azure/azure-sdk-for-python/commit/abc123def456)", }); assert.equal(prLookupCount, 0); +}); + +test("buildSyncMetadataObject creates hidden metadata for origin branch target", () => { + workflow.__setCommandRunners({ + git: stubGitNoTags, + gh: (args) => { + if (args.includes("--head")) { + return commandResult( + JSON.stringify([ + { + number: 47203, + url: "https://github.com/Azure/azure-sdk-for-python/pull/47203", + state: "OPEN", + updatedAt: "2026-06-05T00:00:00Z", + headRefName: "feature/api-change", + headRepositoryOwner: { login: "Azure" }, + }, + ]), + ); + } + + return commandResult("[]"); + }, + }); + + const metadata = workflow.buildSyncMetadataObject({ + packageName: "azure-example", + packageDir: "sdk/service/azure-example", + baseBranch: "apireview/base_azure-example_1.0.0", + reviewBranch: "apireview/review_azure-example_1.1.0", + headSelector: "feature/api-change", + }); + const block = workflow.buildSyncMetadataBlock(metadata); + + assert.ok(block.startsWith("<!-- api-md-review-sync\nDO NOT MODIFY THESE CONTENTS!\n")); + assert.ok(block.endsWith("\n-->")); + assert.deepEqual(parseSyncMetadataBlock(block), { + schemaVersion: 1, + repository: "Azure/azure-sdk-for-python", + packageName: "azure-example", + packageDir: "sdk/service/azure-example", + baseBranch: "apireview/base_azure-example_1.0.0", + reviewBranch: "apireview/review_azure-example_1.1.0", + workingOwner: "Azure", + workingBranch: "feature/api-change", + workingPrNumber: 47203, + }); +}); + +test("buildSyncMetadataObject records fork owner and branch target", () => { + workflow.__setCommandRunners({ + git: stubGitNoTags, + gh: stubGhWithSearchResults([ + { + number: 47204, + url: "https://github.com/Azure/azure-sdk-for-python/pull/47204", + state: "OPEN", + updatedAt: "2026-06-05T00:00:00Z", + headRefName: "users/example/feature", + headRepositoryOwner: { login: "example" }, + }, + ]), + }); + + const metadata = workflow.buildSyncMetadataObject({ + packageName: "azure-example", + packageDir: "sdk/service/azure-example", + baseBranch: "apireview/base_azure-example_1.0.0", + reviewBranch: "apireview/review_azure-example_1.1.0", + headSelector: "example:users/example/feature", + }); + + assert.equal(metadata.workingOwner, "example"); + assert.equal(metadata.workingBranch, "users/example/feature"); + assert.equal(metadata.workingPrNumber, 47204); +}); + +test("buildSyncMetadataObject omits metadata for tag targets", () => { + let prLookupCount = 0; + + workflow.__setCommandRunners({ + git: (args) => { + if (args[0] === "rev-parse" && args.includes("refs/tags/azure-example_1.2.3")) { + return commandResult("", 0); + } + + return commandResult("", 1); + }, + gh: () => { + prLookupCount += 1; + return commandResult("[]"); + }, + }); + + assert.equal( + workflow.buildSyncMetadataObject({ + packageName: "azure-example", + packageDir: "sdk/service/azure-example", + baseBranch: "apireview/base_azure-example_1.0.0", + reviewBranch: "apireview/review_azure-example_1.1.0", + headSelector: "azure-example_1.2.3", + }), + null, + ); + assert.equal(prLookupCount, 0); +}); + +test("buildSyncMetadataObject records main branch target", () => { + workflow.__setCommandRunners({ + git: stubGitNoTags, + gh: () => commandResult("[]"), + }); + + const metadata = workflow.buildSyncMetadataObject({ + packageName: "azure-example", + packageDir: "sdk/service/azure-example", + baseBranch: "apireview/base_azure-example_1.0.0", + reviewBranch: "apireview/review_azure-example_1.1.0", + headSelector: "main", + }); + + assert.equal(metadata.workingOwner, "Azure"); + assert.equal(metadata.workingBranch, "main"); + assert.equal(metadata.workingPrNumber, null); +}); + +test("buildSyncMetadataObject records null working PR for branch target without PR", () => { + workflow.__setCommandRunners({ + git: stubGitNoTags, + gh: () => commandResult("[]"), + }); + + const metadata = workflow.buildSyncMetadataObject({ + packageName: "azure-example", + packageDir: "sdk/service/azure-example", + baseBranch: "apireview/base_azure-example_1.0.0", + reviewBranch: "apireview/review_azure-example_1.1.0", + headSelector: "feature/no-pr", + }); + + assert.equal(metadata.workingOwner, "Azure"); + assert.equal(metadata.workingBranch, "feature/no-pr"); + assert.equal(metadata.workingPrNumber, null); +}); + +test("buildReviewPrBody calls out static tag-to-tag reviews", () => { + const body = workflow.buildReviewPrBody({ + packageName: "azure-example", + targetVersion: "1.2.3", + baseVersion: "1.2.2", + workingReference: { + label: "Target tag", + markdown: "[tag `azure-example_1.2.3`](https://github.com/Azure/azure-sdk-for-python/commit/abc123)", + }, + baselineRef: "[tag `azure-example_1.2.2`](https://github.com/Azure/azure-sdk-for-python/commit/def456)", + syncMetadataBlock: null, + }); + + assert.ok(body.includes("Static tag-to-tag review")); + assert.ok(body.includes("cannot be automatically updated from a working branch")); + assert.equal(body.includes("api-md-review-sync"), false); +}); + +test("replaceSyncMetadataBlock replaces stale hidden metadata", () => { + const oldBlock = workflow.buildSyncMetadataBlock({ + schemaVersion: 1, + repository: "Azure/azure-sdk-for-python", + packageName: "old-package", + packageDir: "sdk/service/old-package", + baseBranch: "apireview/base_old-package_1.0.0", + reviewBranch: "apireview/review_old-package_1.1.0", + workingOwner: "Azure", + workingBranch: "old-feature", + }); + const newBlock = workflow.buildSyncMetadataBlock({ + schemaVersion: 1, + repository: "Azure/azure-sdk-for-python", + packageName: "azure-example", + packageDir: "sdk/service/azure-example", + baseBranch: "apireview/base_azure-example_1.0.0", + reviewBranch: "apireview/review_azure-example_1.1.0", + workingOwner: "Azure", + workingBranch: "feature/api-change", + }); + + const body = workflow.replaceSyncMetadataBlock(`Review body\n\n${oldBlock}`, newBlock); + + assert.ok(body.startsWith("Review body\n\n<!-- api-md-review-sync")); + assert.ok(body.includes("DO NOT MODIFY THESE CONTENTS!")); + assert.ok(body.includes('"packageName": "azure-example"')); + assert.equal(body.includes("old-package"), false); + assert.equal((body.match(/api-md-review-sync/g) || []).length, 1); }); \ No newline at end of file From 341ee63919cff9821cdc9881e5b9a7ce24327c8e Mon Sep 17 00:00:00 2001 From: Travis Prescott <trpresco@microsoft.com> Date: Mon, 8 Jun 2026 14:29:02 -0700 Subject: [PATCH 32/33] Code review feedback. --- .github/skills/create-api-review-pr/SKILL.md | 10 ++++--- .../api_md_workflow/create_api_review_pr.js | 29 +++++++++---------- .../create_api_review_pr.test.js | 20 +++++++++++++ 3 files changed, 39 insertions(+), 20 deletions(-) diff --git a/.github/skills/create-api-review-pr/SKILL.md b/.github/skills/create-api-review-pr/SKILL.md index 135dfce1dc4d..1d71b4d6ab90 100644 --- a/.github/skills/create-api-review-pr/SKILL.md +++ b/.github/skills/create-api-review-pr/SKILL.md @@ -13,9 +13,9 @@ If the user asks to create an API review PR for a new package, explain that new ## Prerequisites -1. The user must have `gh` CLI authenticated (`gh auth login`). +1. The user must have `gh` CLI installed and authenticated (`gh auth login`). 2. The working tree must be clean (no uncommitted changes). -3. Node.js must be installed. +3. The latest Node.js LTS must be installed. 4. `azpysdk` must be installed (`pip install -e ./eng/tools/azure-sdk-tools`). ## Information to Gather @@ -23,7 +23,7 @@ If the user asks to create an API review PR for a new package, explain that new Ask the user for the following using `vscode_askQuestions`: ### 1. Package Name (required) -The Azure SDK package name (e.g. `azure-storage-blob`, `azure-ai-projects`). +The Azure SDK package name (e.g. `azure-storage-blob`, `azure-ai-projects`, `azure-servicebus`, `azure-planetarycomputer`). ### 2. Baseline (required) The release tag to use as the baseline for comparison. Tags follow the format `<package-name>_<version>` (e.g. `azure-storage-blob_12.29.0`). @@ -52,6 +52,8 @@ This is a long-running operation. The script may take several minutes because it If `create_api_review_pr.js` fails while running this skill, do not patch the script, modify package files, retry with workaround edits, or try to manually complete branch/PR creation. Stop the workflow, report the failure clearly, include the relevant error details, and suggest practical next steps. +If the script reports that there are no API differences, relay that message to the user and stop. Do not create branches or a PR manually. + Run the following command from the repository root: ```bash @@ -78,7 +80,7 @@ node scripts/api_md_workflow/create_api_review_pr.js --package-name azure-cosmos ## Post-Execution The script will: -1. Generate `API.md` for both baseline and target +1. Generate `api.md` for both baseline and target 2. Push `apireview/base_<package>_<version>` and `apireview/review_<package>_<version>` branches 3. Open a draft PR (or print a compare URL if `gh pr create` fails) diff --git a/scripts/api_md_workflow/create_api_review_pr.js b/scripts/api_md_workflow/create_api_review_pr.js index 3374b7c87c54..f4bf3a236a23 100644 --- a/scripts/api_md_workflow/create_api_review_pr.js +++ b/scripts/api_md_workflow/create_api_review_pr.js @@ -463,6 +463,10 @@ function desiredBranchState(result) { }; } +function apiResultsHaveApiDiff(baseResult, targetResult) { + return !Buffer.from(baseResult.apiMd).equals(Buffer.from(targetResult.apiMd)); +} + function branchStateMatchesDesired(actual, desired) { return ( actual.hasApiMd === desired.hasApiMd && @@ -1027,6 +1031,13 @@ async function main() { }); const targetVersion = targetResult.version; + if (!apiResultsHaveApiDiff(baseResult, targetResult)) { + logInfo( + `\nNo API differences found for ${args.packageName} between ${args.base} (version ${baseVersion}) and ${targetRef} (version ${targetVersion}). No API review branches or PR were created.`, + ); + return 0; + } + const apiPath = apiMdPath(packageDir); const apiRelative = apiMdRel(packageDir); const metaFilePath = metadataPath(packageDir); @@ -1083,22 +1094,7 @@ async function main() { writeBytes(metaFilePath, targetResult.metadata); git(["add", metaRelative]); } - - const diff = git(["diff", "--cached", "--quiet"], { - capture: true, - check: false, - }); - - if (diff.status === 0) { - git([ - "commit", - "--allow-empty", - "-m", - `[API Review] api.md for ${args.packageName} ${targetVersion} (no diff vs baseline)`, - ]); - } else { - git(["commit", "-m", `[API Review] api.md for ${args.packageName} ${targetVersion}`]); - } + git(["commit", "-m", `[API Review] api.md for ${args.packageName} ${targetVersion}`]); git(["push", "--force-with-lease", REMOTE, reviewBranch]); } @@ -1198,6 +1194,7 @@ if (require.main === module) { buildSyncMetadataBlock, buildSyncMetadataObject, buildReviewPrBody, + apiResultsHaveApiDiff, replaceSyncMetadataBlock, targetReferenceInfo, }; diff --git a/scripts/api_md_workflow/create_api_review_pr.test.js b/scripts/api_md_workflow/create_api_review_pr.test.js index 25172a66f2e3..cecd4677d03a 100644 --- a/scripts/api_md_workflow/create_api_review_pr.test.js +++ b/scripts/api_md_workflow/create_api_review_pr.test.js @@ -314,6 +314,26 @@ test("buildReviewPrBody calls out static tag-to-tag reviews", () => { assert.equal(body.includes("api-md-review-sync"), false); }); +test("apiResultsHaveApiDiff returns false for identical API markdown", () => { + assert.equal( + workflow.apiResultsHaveApiDiff( + { apiMd: Buffer.from("# API\n\nclass Same\n"), metadata: Buffer.from("apiMdSha256: old") }, + { apiMd: Buffer.from("# API\n\nclass Same\n"), metadata: Buffer.from("apiMdSha256: new") }, + ), + false, + ); +}); + +test("apiResultsHaveApiDiff returns true for changed API markdown", () => { + assert.equal( + workflow.apiResultsHaveApiDiff( + { apiMd: Buffer.from("# API\n\nclass Old\n") }, + { apiMd: Buffer.from("# API\n\nclass New\n") }, + ), + true, + ); +}); + test("replaceSyncMetadataBlock replaces stale hidden metadata", () => { const oldBlock = workflow.buildSyncMetadataBlock({ schemaVersion: 1, From 6c93b82306eb1357cd05230901184be39c1be277 Mon Sep 17 00:00:00 2001 From: Travis Prescott <trpresco@microsoft.com> Date: Mon, 8 Jun 2026 14:37:15 -0700 Subject: [PATCH 33/33] Add package-lock.json --- .github/shared/package-lock.json | 2932 ++++++++++++++++++++++++++++++ 1 file changed, 2932 insertions(+) create mode 100644 .github/shared/package-lock.json diff --git a/.github/shared/package-lock.json b/.github/shared/package-lock.json new file mode 100644 index 000000000000..4de254eb96ac --- /dev/null +++ b/.github/shared/package-lock.json @@ -0,0 +1,2932 @@ +{ + "name": "@azure-tools/specs-shared", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@azure-tools/specs-shared", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^15.1.3", + "debug": "^4.4.3", + "js-yaml": "^4.1.0", + "marked": "^18.0.0", + "simple-git": "^3.36.0", + "zod": "^4.3.5" + }, + "bin": { + "spec-model": "cmd/spec-model.js" + }, + "devDependencies": { + "@eslint/js": "^10.0.0", + "@tsconfig/node20": "^20.1.4", + "@types/debug": "^4.1.12", + "@types/js-yaml": "^4.0.9", + "@types/node": "^20.0.0", + "@types/semver": "^7.7.1", + "@vitest/coverage-v8": "^4.1.0", + "cross-env": "^10.1.0", + "eslint": "^10.0.0", + "globals": "^17.0.0", + "prettier": "3.8.3", + "prettier-plugin-organize-imports": "^4.2.0", + "semver": "^7.7.1", + "tinybench": "^6.0.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.0", + "vitest": "^4.1.0" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "15.3.5", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-15.3.5.tgz", + "integrity": "sha512-orNOYXw3hYXxxisXMldjzjBzqqTLBPbwOtHg7ovBPvfBHDue1qM9YJENZ3W2BQuS+7z4ThogMbEzEsov57Itkg==", + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@types/json-schema": "^7.0.15" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@simple-git/args-pathspec": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz", + "integrity": "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==", + "license": "MIT" + }, + "node_modules/@simple-git/argv-parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz", + "integrity": "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==", + "license": "MIT", + "dependencies": { + "@simple-git/args-pathspec": "^1.0.3" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node20": { + "version": "20.1.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.9.tgz", + "integrity": "sha512-IjlTv1RsvnPtUcjTqtVsZExKVq+KQx4g5pCP5tI7rAs6Xesl2qFwSz/tPDBC4JajkL/MlezBu3gPUwqRHl+RIg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.42", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz", + "integrity": "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", + "integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/type-utils": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.61.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz", + "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz", + "integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.61.0", + "@typescript-eslint/types": "^8.61.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz", + "integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz", + "integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz", + "integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz", + "integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz", + "integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.61.0", + "@typescript-eslint/tsconfig-utils": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz", + "integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz", + "integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", + "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.8", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.8", + "vitest": "4.1.8" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.3.tgz", + "integrity": "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", + "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-organize-imports": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.3.0.tgz", + "integrity": "sha512-FxFz0qFhyBsGdIsb697f/EkvHzi5SZOhWAjxcx2dLt+Q532bAlhswcXGYB1yzjZ69kW8UoadFBw7TyNwlq96Iw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": ">=2.0", + "typescript": ">=2.9", + "vue-tsc": "^2.1.0 || 3" + }, + "peerDependenciesMeta": { + "vue-tsc": { + "optional": true + } + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/semver": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", + "integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-git": { + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.36.0.tgz", + "integrity": "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==", + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "@simple-git/args-pathspec": "^1.0.3", + "@simple-git/argv-parser": "^1.1.0", + "debug": "^4.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.0.2.tgz", + "integrity": "sha512-FlHoQpcFvCzeXK5kVPvV7IVgW/hs/B36QWTz876iSdeJguBDfdTSRQmYmaHX+fQNt4hp+gEFB2XXw+8hT4/y8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz", + "integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.61.0", + "@typescript-eslint/parser": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +}