diff --git a/.github/workflows/milestone-management.yml b/.github/workflows/milestone-management.yml new file mode 100644 index 00000000..392b73e5 --- /dev/null +++ b/.github/workflows/milestone-management.yml @@ -0,0 +1,228 @@ +# +# Milestone management for release tracking +# +# - Auto-assigns milestones to merged PRs +# - Supports main plus release/test branch naming conventions +# - Can create/open upcoming release milestones on demand + +name: Milestone Management + +on: + pull_request_target: + types: [closed] + workflow_dispatch: + inputs: + versions: + description: "Comma- or newline-separated milestone versions in X.Y.Z format" + required: true + type: string + +permissions: + issues: write + pull-requests: read + +jobs: + assign-milestone: + if: github.event_name == 'pull_request_target' && github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Assign milestone to merged PR + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/; + + function parseVersion(version) { + const match = version.match(SEMVER_RE); + if (!match) { + return null; + } + return match.slice(1).map(part => Number(part)); + } + + function compareVersions(left, right) { + const leftParts = parseVersion(left); + const rightParts = parseVersion(right); + for (let index = 0; index < 3; index += 1) { + if (leftParts[index] !== rightParts[index]) { + return leftParts[index] - rightParts[index]; + } + } + return 0; + } + + function milestoneTitleForBranch(branchName) { + const exactPatterns = [ + /^release[-/](\d+\.\d+\.\d+)$/, + /^apache-burr-(\d+\.\d+\.\d+)-release$/, + /^v(\d+\.\d+\.\d+)-test$/, + ]; + for (const pattern of exactPatterns) { + const match = branchName.match(pattern); + if (match) { + return match[1]; + } + } + + const minorPatterns = [ + /^release[-/](\d+\.\d+)$/, + /^v(\d+\.\d+)-test$/, + ]; + for (const pattern of minorPatterns) { + const match = branchName.match(pattern); + if (match) { + return `${match[1]}.0`; + } + } + + return null; + } + + async function listMilestones(state) { + return github.paginate(github.rest.issues.listMilestones, { + owner: context.repo.owner, + repo: context.repo.repo, + state, + per_page: 100, + }); + } + + async function findMilestoneByTitle(title, state = "open") { + const milestones = await listMilestones(state); + return milestones.find(milestone => milestone.title === title) || null; + } + + async function findCurrentOpenMilestone() { + const openMilestones = await listMilestones("open"); + const releaseMilestones = openMilestones + .filter(milestone => SEMVER_RE.test(milestone.title)) + .sort((left, right) => compareVersions(left.title, right.title)); + + return releaseMilestones[0] || null; + } + + const pr = context.payload.pull_request; + if (pr.milestone) { + console.log(`#${pr.number}: already has milestone ${pr.milestone.title}, leaving unchanged`); + return; + } + + let milestone = null; + if (pr.base.ref === "main") { + milestone = await findCurrentOpenMilestone(); + if (!milestone) { + throw new Error( + "No open X.Y.Z milestone found for main. Create one with the workflow_dispatch path first." + ); + } + } else { + const title = milestoneTitleForBranch(pr.base.ref); + if (!title) { + console.log(`#${pr.number}: base branch ${pr.base.ref} is not a managed release branch, skipping`); + return; + } + + milestone = await findMilestoneByTitle(title, "open"); + if (!milestone) { + throw new Error( + `Expected an open milestone named ${title} for base branch ${pr.base.ref}, but none exists.` + ); + } + } + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + milestone: milestone.number, + }); + + console.log(`#${pr.number}: assigned milestone ${milestone.title}`); + + create-milestones: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - name: Create or open upcoming release milestones + uses: actions/github-script@v7 + env: + INPUT_VERSIONS: ${{ github.event.inputs.versions }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/; + + function parseRequestedVersions(rawValue) { + return [...new Set( + rawValue + .split(/[\n,]/) + .map(value => value.trim()) + .filter(Boolean) + )]; + } + + const requestedVersions = parseRequestedVersions(process.env.INPUT_VERSIONS || ""); + if (requestedVersions.length === 0) { + throw new Error("Provide at least one X.Y.Z version."); + } + + for (const version of requestedVersions) { + if (!SEMVER_RE.test(version)) { + throw new Error(`Invalid milestone version: ${version}. Expected X.Y.Z.`); + } + } + + const existingMilestones = await github.paginate(github.rest.issues.listMilestones, { + owner: context.repo.owner, + repo: context.repo.repo, + state: "all", + per_page: 100, + }); + + for (const version of requestedVersions) { + const existing = existingMilestones.find(milestone => milestone.title === version); + + if (!existing) { + await github.rest.issues.createMilestone({ + owner: context.repo.owner, + repo: context.repo.repo, + title: version, + state: "open", + description: `Release tracking for ${version}`, + }); + console.log(`Created milestone ${version}`); + continue; + } + + if (existing.state === "closed") { + await github.rest.issues.updateMilestone({ + owner: context.repo.owner, + repo: context.repo.repo, + milestone_number: existing.number, + title: version, + state: "open", + }); + console.log(`Re-opened milestone ${version}`); + continue; + } + + console.log(`Milestone ${version} already exists and is open`); + } diff --git a/scripts/README.md b/scripts/README.md index 3a07b126..b999302e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -230,3 +230,16 @@ For local wheel building/testing (simpler, no signing): python scripts/build_artifacts.py build-ui # Build UI only python scripts/build_artifacts.py wheel # Build wheel with UI ``` + +## Milestone Management + +Merged PRs are tracked against release milestones so changelog generation can tell which changes shipped in which release. + +- Milestone titles use the `X.Y.Z` naming convention, for example `0.43.0` +- PRs merged to `main` are assigned the earliest open `X.Y.Z` milestone +- PRs merged to release/test branches use the milestone implied by the branch name, including: + - `release-X.Y.Z` + - `apache-burr-X.Y.Z-release` + - `vX.Y-test` or `vX.Y.Z-test` + +The `.github/workflows/milestone-management.yml` workflow also supports manual milestone creation via `workflow_dispatch`. Provide one or more comma- or newline-separated `X.Y.Z` versions in the `versions` input to create or re-open upcoming release milestones. diff --git a/scripts/milestone_management_preview.py b/scripts/milestone_management_preview.py new file mode 100644 index 00000000..01ddef22 --- /dev/null +++ b/scripts/milestone_management_preview.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Preview milestone selection for the milestone-management workflow. + +Examples: + python scripts/milestone_management_preview.py --base-branch main --open-milestones 0.43.0 0.43.1 + python scripts/milestone_management_preview.py --base-branch release-0.43.1 --open-milestones 0.43.0 0.43.1 + python scripts/milestone_management_preview.py --base-branch v0.43-test --open-milestones 0.43.0 +""" + +import argparse +import re +import sys + +SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") + + +def parse_version(version: str) -> tuple[int, int, int]: + match = SEMVER_RE.match(version) + if not match: + raise ValueError(f"Invalid milestone version: {version!r}. Expected X.Y.Z.") + return tuple(int(part) for part in match.groups()) + + +def milestone_title_for_branch(branch_name: str) -> str | None: + exact_patterns = ( + re.compile(r"^release[-/](\d+\.\d+\.\d+)$"), + re.compile(r"^apache-burr-(\d+\.\d+\.\d+)-release$"), + re.compile(r"^v(\d+\.\d+\.\d+)-test$"), + ) + for pattern in exact_patterns: + match = pattern.match(branch_name) + if match: + return match.group(1) + + minor_patterns = ( + re.compile(r"^release[-/](\d+\.\d+)$"), + re.compile(r"^v(\d+\.\d+)-test$"), + ) + for pattern in minor_patterns: + match = pattern.match(branch_name) + if match: + return f"{match.group(1)}.0" + + return None + + +def find_current_open_milestone(open_milestones: list[str]) -> str: + valid_milestones = [title for title in open_milestones if SEMVER_RE.match(title)] + if not valid_milestones: + raise ValueError("No open X.Y.Z milestone found for main.") + return min(valid_milestones, key=parse_version) + + +def choose_milestone(base_branch: str, open_milestones: list[str]) -> str | None: + if base_branch == "main": + return find_current_open_milestone(open_milestones) + + title = milestone_title_for_branch(base_branch) + if title is None: + return None + if title not in open_milestones: + raise ValueError( + f"Expected an open milestone named {title!r} for base branch {base_branch!r}, but none exists." + ) + return title + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--base-branch", + required=True, + help="The PR base branch to evaluate, for example main or release-0.43.0", + ) + parser.add_argument( + "--open-milestones", + nargs="+", + required=True, + help="Open milestone titles visible to the workflow, for example 0.43.0 0.43.1", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + milestone = choose_milestone(args.base_branch, args.open_milestones) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 1 + + if milestone is None: + print( + f"Base branch {args.base_branch!r} is not a managed release branch. " + "The workflow would skip milestone assignment." + ) + return 0 + + print(milestone) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_milestone_management_preview.py b/tests/test_milestone_management_preview.py new file mode 100644 index 00000000..c4624c06 --- /dev/null +++ b/tests/test_milestone_management_preview.py @@ -0,0 +1,64 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import importlib.util +from pathlib import Path + +import pytest + + +def _load_preview_module(): + module_path = ( + Path(__file__).resolve().parent.parent / "scripts" / "milestone_management_preview.py" + ) + spec = importlib.util.spec_from_file_location("milestone_management_preview", module_path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +preview = _load_preview_module() + + +def test_main_branch_uses_earliest_open_milestone(): + milestone = preview.choose_milestone("main", ["0.43.1", "0.43.0", "not-a-release"]) + + assert milestone == "0.43.0" + + +def test_release_branch_maps_to_exact_version(): + milestone = preview.choose_milestone("release-0.43.1", ["0.43.0", "0.43.1"]) + + assert milestone == "0.43.1" + + +def test_minor_test_branch_maps_to_patch_zero_milestone(): + milestone = preview.choose_milestone("v0.43-test", ["0.43.0", "0.43.1"]) + + assert milestone == "0.43.0" + + +def test_unmanaged_branch_skips_assignment(): + milestone = preview.choose_milestone("feature/example", ["0.43.0"]) + + assert milestone is None + + +def test_missing_release_branch_milestone_raises_error(): + with pytest.raises(ValueError, match="Expected an open milestone named '0.43.1'"): + preview.choose_milestone("release-0.43.1", ["0.43.0"])