185 lines
5.8 KiB
Python
185 lines
5.8 KiB
Python
#!/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())
|