chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib import error, request
|
||||
|
||||
|
||||
def warn(message: str) -> None:
|
||||
print(message, file=sys.stderr)
|
||||
|
||||
|
||||
def parse_version(value: str | None) -> tuple[int, int, int] | None:
|
||||
if not value:
|
||||
return None
|
||||
match = re.match(r"^v?(\d+)\.(\d+)(?:\.(\d+))?", value)
|
||||
if not match:
|
||||
return None
|
||||
major = int(match.group(1))
|
||||
minor = int(match.group(2))
|
||||
patch = int(match.group(3) or 0)
|
||||
return major, minor, patch
|
||||
|
||||
|
||||
def latest_tag_version(exclude_version: tuple[int, int, int] | None) -> tuple[int, int, int] | None:
|
||||
try:
|
||||
output = subprocess.check_output(["git", "tag", "--list", "v*"], text=True)
|
||||
except Exception as exc:
|
||||
warn(f"Milestone assignment skipped (failed to list tags: {exc}).")
|
||||
return None
|
||||
versions: list[tuple[int, int, int]] = []
|
||||
for tag in output.splitlines():
|
||||
parsed = parse_version(tag)
|
||||
if not parsed:
|
||||
continue
|
||||
if exclude_version and parsed == exclude_version:
|
||||
continue
|
||||
versions.append(parsed)
|
||||
if not versions:
|
||||
return None
|
||||
return max(versions)
|
||||
|
||||
|
||||
def classify_bump(
|
||||
target: tuple[int, int, int] | None,
|
||||
previous: tuple[int, int, int] | None,
|
||||
) -> str | None:
|
||||
if not target or not previous:
|
||||
return None
|
||||
if target < previous:
|
||||
warn("Milestone assignment skipped (release version is behind latest tag).")
|
||||
return None
|
||||
if target[0] != previous[0]:
|
||||
return "major"
|
||||
if target[1] != previous[1]:
|
||||
return "minor"
|
||||
return "patch"
|
||||
|
||||
|
||||
def parse_milestone_title(title: str | None) -> tuple[int, int] | None:
|
||||
if not title:
|
||||
return None
|
||||
match = re.match(r"^(\d+)\.(\d+)\.x$", title)
|
||||
if not match:
|
||||
return None
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
|
||||
|
||||
def fetch_open_milestones(owner: str, repo: str, token: str) -> list[dict]:
|
||||
url = f"https://api.github.com/repos/{owner}/{repo}/milestones?state=open&per_page=100"
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
}
|
||||
req = request.Request(url, headers=headers)
|
||||
try:
|
||||
with request.urlopen(req) as response:
|
||||
return json.load(response)
|
||||
except error.HTTPError as exc:
|
||||
warn(f"Milestone assignment skipped (failed to list milestones: {exc.code}).")
|
||||
except Exception as exc:
|
||||
warn(f"Milestone assignment skipped (failed to list milestones: {exc}).")
|
||||
return []
|
||||
|
||||
|
||||
def select_milestone(milestones: list[dict], required_bump: str) -> str | None:
|
||||
parsed: list[dict] = []
|
||||
for milestone in milestones:
|
||||
parsed_title = parse_milestone_title(milestone.get("title"))
|
||||
if not parsed_title:
|
||||
continue
|
||||
parsed.append(
|
||||
{
|
||||
"milestone": milestone,
|
||||
"major": parsed_title[0],
|
||||
"minor": parsed_title[1],
|
||||
}
|
||||
)
|
||||
|
||||
parsed.sort(key=lambda entry: (entry["major"], entry["minor"]))
|
||||
if not parsed:
|
||||
warn("Milestone assignment skipped (no open milestones matching X.Y.x).")
|
||||
return None
|
||||
|
||||
majors = sorted({entry["major"] for entry in parsed})
|
||||
current_major = majors[0]
|
||||
next_major = majors[1] if len(majors) > 1 else None
|
||||
|
||||
current_major_entries = [entry for entry in parsed if entry["major"] == current_major]
|
||||
patch_target = current_major_entries[0]
|
||||
minor_target = current_major_entries[1] if len(current_major_entries) > 1 else patch_target
|
||||
|
||||
major_target = None
|
||||
if next_major is not None:
|
||||
next_major_entries = [entry for entry in parsed if entry["major"] == next_major]
|
||||
if next_major_entries:
|
||||
major_target = next_major_entries[0]
|
||||
|
||||
target_entry = None
|
||||
if required_bump == "major":
|
||||
target_entry = major_target
|
||||
elif required_bump == "minor":
|
||||
target_entry = minor_target
|
||||
else:
|
||||
target_entry = patch_target
|
||||
|
||||
if not target_entry:
|
||||
warn("Milestone assignment skipped (not enough open milestones for selection).")
|
||||
return None
|
||||
|
||||
return target_entry["milestone"].get("title")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--version", help="Release version (e.g., 0.6.6).")
|
||||
parser.add_argument(
|
||||
"--required-bump",
|
||||
choices=("major", "minor", "patch"),
|
||||
help="Override bump type (major/minor/patch).",
|
||||
)
|
||||
parser.add_argument("--repo", help="GitHub repository (owner/repo).")
|
||||
parser.add_argument("--token", help="GitHub token.")
|
||||
args = parser.parse_args()
|
||||
|
||||
required_bump = args.required_bump
|
||||
if not required_bump:
|
||||
target_version = parse_version(args.version)
|
||||
if not target_version:
|
||||
warn("Milestone assignment skipped (missing or invalid release version).")
|
||||
return 0
|
||||
previous_version = latest_tag_version(target_version)
|
||||
required_bump = classify_bump(target_version, previous_version)
|
||||
if not required_bump:
|
||||
warn("Milestone assignment skipped (unable to determine required bump).")
|
||||
return 0
|
||||
|
||||
token = args.token or os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
||||
if not token:
|
||||
warn("Milestone assignment skipped (missing GitHub token).")
|
||||
return 0
|
||||
|
||||
repo = args.repo or os.environ.get("GITHUB_REPOSITORY")
|
||||
if not repo or "/" not in repo:
|
||||
warn("Milestone assignment skipped (missing repository info).")
|
||||
return 0
|
||||
owner, name = repo.split("/", 1)
|
||||
|
||||
milestones = fetch_open_milestones(owner, name, token)
|
||||
if not milestones:
|
||||
return 0
|
||||
|
||||
milestone_title = select_milestone(milestones, required_bump)
|
||||
if milestone_title:
|
||||
print(milestone_title)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user