Skip to content

Commit 7c45b15

Browse files
committed
Add PR creation script.
1 parent fd2ab3d commit 7c45b15

2 files changed

Lines changed: 373 additions & 2 deletions

File tree

scripts/create_api_review_pr.py

Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
#!/usr/bin/env python
2+
"""Create an API review PR for an Azure SDK Python package.
3+
4+
Workflow:
5+
1. Validate that ``--package-name`` exists under ``sdk/*/``.
6+
2. Build the BASE branch (``base_{package}_{base_version}``):
7+
- If ``--base`` is a tag (e.g. ``azure-ai-projects_1.0.0b1``): check out
8+
the tag, generate API.md, then create the base branch off the latest
9+
``origin/main`` and commit the captured API.md onto it.
10+
- If ``--base`` is omitted: create the base branch off ``origin/main``
11+
and delete any existing API.md for the package (no-op if absent).
12+
3. Build the REVIEW branch (``review_{package}_{target_version}``):
13+
- If ``--target`` is omitted: use the latest ``origin/main``.
14+
- Otherwise: check out the given branch.
15+
Generate API.md on that ref, then commit it on a branch created off
16+
the base branch.
17+
4. Push both branches to ``origin`` and open a PR with title:
18+
``[API Review] {package} {target_version} (base {base_version})``
19+
20+
Usage::
21+
22+
python scripts/create_api_review_pr.py --package-name azure-ai-projects
23+
python scripts/create_api_review_pr.py --package-name azure-ai-projects \\
24+
--base azure-ai-projects_1.0.0b1
25+
python scripts/create_api_review_pr.py --package-name azure-ai-projects \\
26+
--base azure-ai-projects_1.0.0b1 --target my-feature-branch
27+
28+
Requires ``gh`` (GitHub CLI) authenticated against the repository, plus push
29+
access on the ``origin`` remote.
30+
"""
31+
32+
from __future__ import annotations
33+
34+
import argparse
35+
import glob
36+
import os
37+
import re
38+
import shutil
39+
import subprocess
40+
import sys
41+
import tempfile
42+
from typing import Optional
43+
44+
45+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
46+
GENERATE_SCRIPT = os.path.join(REPO_ROOT, "scripts", "generate_api_text.py")
47+
EXPORT_SCRIPT = os.path.join(REPO_ROOT, "eng", "common", "scripts", "Export-APIViewMarkdown.ps1")
48+
REMOTE = "origin"
49+
MAIN_REF = f"{REMOTE}/main"
50+
51+
52+
# ---------------------------------------------------------------------------
53+
# Shell helpers
54+
# ---------------------------------------------------------------------------
55+
56+
def run(cmd, *, cwd: str = REPO_ROOT, check: bool = True, capture: bool = False, env: Optional[dict] = None) -> subprocess.CompletedProcess:
57+
"""Run a command, echoing it first."""
58+
printable = " ".join(cmd) if isinstance(cmd, list) else cmd
59+
print(f"$ {printable}")
60+
return subprocess.run(
61+
cmd,
62+
cwd=cwd,
63+
check=check,
64+
text=True,
65+
capture_output=capture,
66+
env=env,
67+
)
68+
69+
70+
def git(*args: str, capture: bool = False, check: bool = True) -> subprocess.CompletedProcess:
71+
return run(["git", *args], capture=capture, check=check)
72+
73+
74+
def git_out(*args: str) -> str:
75+
return git(*args, capture=True).stdout.strip()
76+
77+
78+
# ---------------------------------------------------------------------------
79+
# Package + ref helpers
80+
# ---------------------------------------------------------------------------
81+
82+
def find_package_dir(package_name: str) -> str:
83+
"""Locate ``sdk/*/{package_name}`` containing a pyproject.toml or setup.py."""
84+
pattern = os.path.join(REPO_ROOT, "sdk", "*", package_name)
85+
matches = [
86+
m for m in glob.glob(pattern)
87+
if os.path.isdir(m)
88+
and (
89+
os.path.exists(os.path.join(m, "pyproject.toml"))
90+
or os.path.exists(os.path.join(m, "setup.py"))
91+
)
92+
]
93+
if not matches:
94+
raise SystemExit(f"ERROR: package '{package_name}' not found under sdk/*/")
95+
if len(matches) > 1:
96+
raise SystemExit(f"ERROR: multiple matches for '{package_name}': {matches}")
97+
return matches[0]
98+
99+
100+
def package_rel_dir(package_dir: str) -> str:
101+
"""Repo-relative POSIX path for the package directory."""
102+
return os.path.relpath(package_dir, REPO_ROOT).replace(os.sep, "/")
103+
104+
105+
def api_md_path(package_dir: str) -> str:
106+
return os.path.join(package_dir, "API.md")
107+
108+
109+
def api_md_rel(package_dir: str) -> str:
110+
return f"{package_rel_dir(package_dir)}/API.md"
111+
112+
113+
_VERSION_RE = re.compile(r"""^\s*VERSION\s*[:=]\s*["']([^"']+)["']""", re.MULTILINE)
114+
115+
116+
def read_version(package_dir: str) -> str:
117+
"""Find and parse a ``_version.py`` (or ``version.py``) inside ``package_dir``."""
118+
candidates = []
119+
candidates.extend(glob.glob(os.path.join(package_dir, "**", "_version.py"), recursive=True))
120+
candidates.extend(glob.glob(os.path.join(package_dir, "**", "version.py"), recursive=True))
121+
for path in candidates:
122+
try:
123+
text = open(path, "r", encoding="utf-8").read()
124+
except OSError:
125+
continue
126+
m = _VERSION_RE.search(text)
127+
if m:
128+
return m.group(1)
129+
raise SystemExit(f"ERROR: could not find a version string in {package_dir}")
130+
131+
132+
def tag_exists(tag: str) -> bool:
133+
result = git("rev-parse", "--verify", "--quiet", f"refs/tags/{tag}", capture=True, check=False)
134+
return result.returncode == 0
135+
136+
137+
def ensure_clean_worktree() -> None:
138+
status = git_out("status", "--porcelain")
139+
if status:
140+
raise SystemExit(
141+
"ERROR: working tree is not clean. Commit or stash changes before running.\n"
142+
+ status
143+
)
144+
145+
146+
def current_branch() -> str:
147+
return git_out("rev-parse", "--abbrev-ref", "HEAD")
148+
149+
150+
def remote_branch_ref(branch: str) -> str:
151+
"""Return the ref name for a branch on ``REMOTE``, fetching it first."""
152+
git("fetch", REMOTE, branch)
153+
return f"{REMOTE}/{branch}"
154+
155+
156+
# ---------------------------------------------------------------------------
157+
# API.md generation
158+
# ---------------------------------------------------------------------------
159+
160+
def generate_api_md(package_name: str, package_dir: str) -> bytes:
161+
"""Run ``generate_api_text.py`` for the package and return the bytes of the
162+
resulting API.md. The file is also left on disk at its canonical location.
163+
"""
164+
print(f"--- Generating API.md for {package_name} on {current_branch_or_sha()} ---")
165+
run([sys.executable, GENERATE_SCRIPT, package_name])
166+
path = api_md_path(package_dir)
167+
if not os.path.exists(path):
168+
raise SystemExit(f"ERROR: generate_api_text.py did not produce {path}")
169+
with open(path, "rb") as f:
170+
return f.read()
171+
172+
173+
def current_branch_or_sha() -> str:
174+
name = git_out("rev-parse", "--abbrev-ref", "HEAD")
175+
if name == "HEAD":
176+
return git_out("rev-parse", "--short", "HEAD")
177+
return name
178+
179+
180+
# ---------------------------------------------------------------------------
181+
# Main workflow
182+
# ---------------------------------------------------------------------------
183+
184+
def parse_args() -> argparse.Namespace:
185+
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
186+
p.add_argument("--package-name", required=True,
187+
help="Package directory name under sdk/*/ (e.g. azure-ai-projects)")
188+
p.add_argument("--base", default=None,
189+
help="Tag to use as the API.md baseline, formatted as "
190+
"'{package-name}_{version}'. Omit to make the baseline empty.")
191+
p.add_argument("--target", default=None,
192+
help="Branch containing the API to review. Omit to use the latest origin/main.")
193+
return p.parse_args()
194+
195+
196+
def validate_base_tag(package_name: str, base: str) -> str:
197+
"""Validate the ``--base`` tag format/existence and return the version."""
198+
if not base.startswith(f"{package_name}_"):
199+
raise SystemExit(
200+
f"ERROR: --base tag '{base}' must start with '{package_name}_'."
201+
)
202+
version = base[len(package_name) + 1:]
203+
if not version:
204+
raise SystemExit(f"ERROR: --base tag '{base}' is missing the version suffix.")
205+
if not tag_exists(base):
206+
raise SystemExit(f"ERROR: tag '{base}' does not exist in this repository.")
207+
return version
208+
209+
210+
def write_bytes(path: str, data: bytes) -> None:
211+
os.makedirs(os.path.dirname(path), exist_ok=True)
212+
with open(path, "wb") as f:
213+
f.write(data)
214+
215+
216+
def main() -> int:
217+
args = parse_args()
218+
package_name = args.package_name
219+
220+
package_dir = find_package_dir(package_name)
221+
print(f"Found package at: {package_dir}")
222+
223+
ensure_clean_worktree()
224+
original_branch = current_branch()
225+
if original_branch == "HEAD":
226+
raise SystemExit("ERROR: refusing to run from a detached HEAD.")
227+
228+
# Always fetch main once up-front.
229+
git("fetch", REMOTE, "main")
230+
231+
# ---- Validate inputs --------------------------------------------------
232+
base_version = "none"
233+
if args.base is not None:
234+
base_version = validate_base_tag(package_name, args.base)
235+
236+
target_ref: str
237+
if args.target is None:
238+
target_ref = MAIN_REF
239+
else:
240+
target_ref = remote_branch_ref(args.target)
241+
242+
# Cache the generate + export scripts (they may not exist on older refs we check out).
243+
tmp_script_dir = tempfile.mkdtemp(prefix="apirev_script_")
244+
cached_script = os.path.join(tmp_script_dir, "generate_api_text.py")
245+
cached_export = os.path.join(tmp_script_dir, "Export-APIViewMarkdown.ps1")
246+
shutil.copy2(GENERATE_SCRIPT, cached_script)
247+
shutil.copy2(EXPORT_SCRIPT, cached_export)
248+
249+
try:
250+
# ---- Step 1: capture base API.md content (if base is a tag) ------
251+
base_api_bytes: Optional[bytes] = None
252+
if args.base is not None:
253+
print(f"\n=== Capturing baseline API.md from tag {args.base} ===")
254+
git("checkout", "--detach", args.base)
255+
base_api_bytes = _generate_with_cached_script(
256+
cached_script, cached_export, package_name, package_dir
257+
)
258+
259+
# ---- Step 2: capture target API.md content -----------------------
260+
print(f"\n=== Capturing target API.md from {target_ref} ===")
261+
git("checkout", "--detach", target_ref)
262+
target_version = read_version(package_dir)
263+
target_api_bytes = _generate_with_cached_script(
264+
cached_script, cached_export, package_name, package_dir
265+
)
266+
267+
# ---- Step 3: build base branch off origin/main -------------------
268+
base_branch = f"base_{package_name}_{base_version}"
269+
review_branch = f"review_{package_name}_{target_version}"
270+
271+
print(f"\n=== Creating base branch {base_branch} ===")
272+
git("checkout", "-B", base_branch, MAIN_REF)
273+
274+
api_path = api_md_path(package_dir)
275+
api_relative = api_md_rel(package_dir)
276+
277+
if base_api_bytes is not None:
278+
write_bytes(api_path, base_api_bytes)
279+
git("add", api_relative)
280+
git("commit", "-m",
281+
f"[API Review] Baseline API.md for {package_name} {base_version}")
282+
else:
283+
if os.path.exists(api_path):
284+
git("rm", api_relative)
285+
git("commit", "-m",
286+
f"[API Review] Remove API.md for {package_name} (empty baseline)")
287+
else:
288+
git("commit", "--allow-empty", "-m",
289+
f"[API Review] Empty baseline for {package_name}")
290+
291+
git("push", "--force-with-lease", REMOTE, base_branch)
292+
293+
# ---- Step 4: build review branch off base branch -----------------
294+
print(f"\n=== Creating review branch {review_branch} ===")
295+
git("checkout", "-B", review_branch, base_branch)
296+
write_bytes(api_path, target_api_bytes)
297+
git("add", api_relative)
298+
# If the bytes happen to be identical to the base, commit empty so we
299+
# still have something to PR.
300+
diff = git("diff", "--cached", "--quiet", capture=True, check=False)
301+
if diff.returncode == 0:
302+
git("commit", "--allow-empty", "-m",
303+
f"[API Review] API.md for {package_name} {target_version} (no diff vs baseline)")
304+
else:
305+
git("commit", "-m",
306+
f"[API Review] API.md for {package_name} {target_version}")
307+
308+
git("push", "--force-with-lease", REMOTE, review_branch)
309+
310+
# ---- Step 5: open PR --------------------------------------------
311+
title = f"[API Review] {package_name} {target_version} (base {base_version})"
312+
body_lines = [
313+
f"Automated API review PR for `{package_name}`.",
314+
"",
315+
f"- **Target:** `{args.target or 'origin/main'}` (version `{target_version}`)",
316+
f"- **Baseline:** {'tag `' + args.base + '`' if args.base else '_empty_'} "
317+
f"(version `{base_version}`)",
318+
"",
319+
"Generated by `scripts/create_api_review_pr.py`.",
320+
]
321+
body = "\n".join(body_lines)
322+
323+
print(f"\n=== Opening PR ===")
324+
compare_url = (
325+
f"https://github.com/Azure/azure-sdk-for-python/compare/"
326+
f"{base_branch}...{review_branch}?expand=1"
327+
)
328+
pr_result = run([
329+
"gh", "pr", "create",
330+
"--repo", "Azure/azure-sdk-for-python",
331+
"--base", base_branch,
332+
"--head", review_branch,
333+
"--title", title,
334+
"--body", body,
335+
"--draft",
336+
], check=False)
337+
if pr_result.returncode != 0:
338+
print(
339+
"\nWARNING: `gh pr create` failed. Both branches were pushed "
340+
"successfully -- open the PR manually here:\n"
341+
f" {compare_url}\n"
342+
f" Title: {title}"
343+
)
344+
345+
return 0
346+
347+
finally:
348+
# Restore the user's original branch.
349+
try:
350+
git("checkout", original_branch, check=False)
351+
finally:
352+
shutil.rmtree(tmp_script_dir, ignore_errors=True)
353+
354+
355+
def _generate_with_cached_script(cached_script: str, cached_export: str, package_name: str, package_dir: str) -> bytes:
356+
"""Run the cached copy of generate_api_text.py against the currently
357+
checked-out ref and return the bytes of the resulting API.md."""
358+
print(f"--- Generating API.md on {current_branch_or_sha()} ---")
359+
env = os.environ.copy()
360+
env["AZSDK_REPO_ROOT"] = REPO_ROOT
361+
env["AZSDK_EXPORT_SCRIPT"] = cached_export
362+
run([sys.executable, cached_script, package_name], env=env)
363+
path = api_md_path(package_dir)
364+
if not os.path.exists(path):
365+
raise SystemExit(f"ERROR: did not produce {path}")
366+
with open(path, "rb") as f:
367+
return f.read()
368+
369+
370+
if __name__ == "__main__":
371+
sys.exit(main())

scripts/generate_api_text.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@
1414
import tempfile
1515

1616

17-
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
17+
REPO_ROOT = os.environ.get("AZSDK_REPO_ROOT") or os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
1818
APIVIEW_REQS = os.path.join(REPO_ROOT, "eng", "apiview_reqs.txt")
1919
AZURE_SDK_INDEX = "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-python/pypi/simple/"
20-
EXPORT_SCRIPT = os.path.join(REPO_ROOT, "eng", "common", "scripts", "Export-APIViewMarkdown.ps1")
20+
EXPORT_SCRIPT = os.environ.get("AZSDK_EXPORT_SCRIPT") or os.path.join(REPO_ROOT, "eng", "common", "scripts", "Export-APIViewMarkdown.ps1")
2121

2222

2323
def find_package_dir(package_name: str) -> str:

0 commit comments

Comments
 (0)