From 04088b622a1c9deef7c83d19e3ae579fe96c71df Mon Sep 17 00:00:00 2001 From: Khang Nguyen Date: Wed, 1 Apr 2026 16:03:31 +1100 Subject: [PATCH] Add Verify PR title check --- .github/workflows/VerifyPRTitle.yml | 26 ++++++++++++++ scripts/ci/verify_pr_title/__init__.py | 19 ++++++++++ scripts/ci/verify_pr_title/__main__.py | 36 +++++++++++++++++++ scripts/ci/verify_pr_title/extractors.py | 30 ++++++++++++++++ scripts/ci/verify_pr_title/rule.py | 17 +++++++++ scripts/ci/verify_pr_title/rules.py | 44 ++++++++++++++++++++++++ scripts/ci/verify_pr_title/runner.py | 13 +++++++ scripts/ci/verify_pr_title/verifiers.py | 43 +++++++++++++++++++++++ 8 files changed, 228 insertions(+) create mode 100644 .github/workflows/VerifyPRTitle.yml create mode 100644 scripts/ci/verify_pr_title/__init__.py create mode 100644 scripts/ci/verify_pr_title/__main__.py create mode 100644 scripts/ci/verify_pr_title/extractors.py create mode 100644 scripts/ci/verify_pr_title/rule.py create mode 100644 scripts/ci/verify_pr_title/rules.py create mode 100644 scripts/ci/verify_pr_title/runner.py create mode 100644 scripts/ci/verify_pr_title/verifiers.py diff --git a/.github/workflows/VerifyPRTitle.yml b/.github/workflows/VerifyPRTitle.yml new file mode 100644 index 00000000000..9dd8244ea4e --- /dev/null +++ b/.github/workflows/VerifyPRTitle.yml @@ -0,0 +1,26 @@ +name: Verify PR Title + +on: + pull_request_target: + types: [opened, edited, synchronize] + branches: + - main + +permissions: {} + +jobs: + verify-pr-title: + runs-on: ubuntu-latest + name: Verify PR title format + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Verify PR title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: python -m verify_pr_title "$PR_TITLE" + working-directory: scripts/ci diff --git a/scripts/ci/verify_pr_title/__init__.py b/scripts/ci/verify_pr_title/__init__.py new file mode 100644 index 00000000000..1fc78e979ed --- /dev/null +++ b/scripts/ci/verify_pr_title/__init__.py @@ -0,0 +1,19 @@ +from .extractors import AfterPrefixExtractor, ComponentPrefixExtractor, Extractor, FullTitleExtractor +from .rule import Rule +from .rules import RULES +from .runner import run +from .verifiers import NegativeRegexVerifier, NonEmptyVerifier, RegexVerifier, Verifier + +__all__ = [ + "Rule", + "Extractor", + "Verifier", + "FullTitleExtractor", + "ComponentPrefixExtractor", + "AfterPrefixExtractor", + "RegexVerifier", + "NegativeRegexVerifier", + "NonEmptyVerifier", + "RULES", + "run", +] diff --git a/scripts/ci/verify_pr_title/__main__.py b/scripts/ci/verify_pr_title/__main__.py new file mode 100644 index 00000000000..61c52f851b9 --- /dev/null +++ b/scripts/ci/verify_pr_title/__main__.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import sys + +from .runner import run + +_EXPECTED_FORMAT = ( + "[Component] (BREAKING CHANGE: | Hotfix[:] | Fix #[:])? \n" + "{Component} (BREAKING CHANGE: | Hotfix[:] | Fix #[:])? " +) + + +def main() -> None: + if len(sys.argv) < 2: + print('Usage: python -m verify_pr_title ""', file=sys.stderr) + sys.exit(2) + + title = sys.argv[1] + failures = run(title) + + if not failures: + print(f"PR title validation passed: '{title}'") + sys.exit(0) + + print(f"PR title validation failed for: '{title}'\n") + for name, error in failures: + print(f" Rule: {name}") + for line in error.splitlines(): + print(f" {line}") + print() + print(f"Expected format:\n {_EXPECTED_FORMAT}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/ci/verify_pr_title/extractors.py b/scripts/ci/verify_pr_title/extractors.py new file mode 100644 index 00000000000..44f2cf3e119 --- /dev/null +++ b/scripts/ci/verify_pr_title/extractors.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import re +from abc import ABC, abstractmethod + + +class Extractor(ABC): + @abstractmethod + def extract(self, title: str) -> str: ... + + +class FullTitleExtractor(Extractor): + def extract(self, title: str) -> str: + return title + + +class ComponentPrefixExtractor(Extractor): + _PATTERN = re.compile(r"^(\[.+?\]|\{.+?\})") + + def extract(self, title: str) -> str: + m = self._PATTERN.match(title) + return m.group(1) if m else "" + + +class AfterPrefixExtractor(Extractor): + _PATTERN = re.compile(r"^(?:\[.+?\]|\{.+?\})\s*(.*)", re.DOTALL) + + def extract(self, title: str) -> str: + m = self._PATTERN.match(title) + return m.group(1) if m else title diff --git a/scripts/ci/verify_pr_title/rule.py b/scripts/ci/verify_pr_title/rule.py new file mode 100644 index 00000000000..2b828b28638 --- /dev/null +++ b/scripts/ci/verify_pr_title/rule.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from .extractors import Extractor +from .verifiers import Verifier + + +@dataclass +class Rule: + name: str + extractor: Extractor + verifier: Verifier + + def check(self, title: str) -> tuple[bool, str]: + value = self.extractor.extract(title) + return self.verifier.verify(value) diff --git a/scripts/ci/verify_pr_title/rules.py b/scripts/ci/verify_pr_title/rules.py new file mode 100644 index 00000000000..558f70dfe0b --- /dev/null +++ b/scripts/ci/verify_pr_title/rules.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from .extractors import AfterPrefixExtractor, FullTitleExtractor +from .rule import Rule +from .verifiers import RegexVerifier + +RULES: list[Rule] = [ + Rule( + name="Component prefix present", + extractor=FullTitleExtractor(), + verifier=RegexVerifier( + pattern=r"^(\[.+?\]|\{.+?\})", + error_message=( + "Title must start with a non-empty [Component] or {Component} bracket.\n" + " [Component] – customer-facing change (included in HISTORY.rst)\n" + " {Component} – non-customer-facing change (excluded from HISTORY.rst)\n" + " Examples: [Storage], {Misc.}, [API Management]" + ), + ), + ), + Rule( + name="Non-empty description after prefix", + extractor=AfterPrefixExtractor(), + verifier=RegexVerifier( + pattern=( + r"^\s*(?:(?:BREAKING CHANGE:|Hotfix:?|Fix\s+#\d+:?)\s+)\S" + r"|" + r"^\s*(?!BREAKING CHANGE:|Hotfix:?|Fix\s+#\d+:?)\S" + ), + error_message=( + "Title must contain a description after the component prefix.\n" + " Optionally preceded by a recognised keyword:\n" + " BREAKING CHANGE: \n" + " Hotfix[:] \n" + " Fix #[:] \n" + " Examples:\n" + " [Storage] az storage blob upload: Add --overwrite flag\n" + " [Compute] BREAKING CHANGE: Remove deprecated --sku parameter\n" + " [Core] Fix #12345: az account show fails on managed identity\n" + " {Misc.} Fix typo in help text" + ), + ), + ), +] diff --git a/scripts/ci/verify_pr_title/runner.py b/scripts/ci/verify_pr_title/runner.py new file mode 100644 index 00000000000..3fa9a994201 --- /dev/null +++ b/scripts/ci/verify_pr_title/runner.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from .rule import Rule +from .rules import RULES + + +def run(title: str, rules: list[Rule] = RULES) -> list[tuple[str, str]]: + failures: list[tuple[str, str]] = [] + for rule in rules: + passed, error = rule.check(title) + if not passed: + failures.append((rule.name, error)) + return failures diff --git a/scripts/ci/verify_pr_title/verifiers.py b/scripts/ci/verify_pr_title/verifiers.py new file mode 100644 index 00000000000..518c7c730ac --- /dev/null +++ b/scripts/ci/verify_pr_title/verifiers.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import re +from abc import ABC, abstractmethod + + +class Verifier(ABC): + @abstractmethod + def verify(self, value: str) -> tuple[bool, str]: ... + + +class RegexVerifier(Verifier): + def __init__(self, pattern: str, error_message: str, *, fullmatch: bool = False) -> None: + self._regex = re.compile(pattern) + self._error_message = error_message + self._fullmatch = fullmatch + + def verify(self, value: str) -> tuple[bool, str]: + fn = self._regex.fullmatch if self._fullmatch else self._regex.search + if fn(value): + return True, "" + return False, self._error_message + + +class NegativeRegexVerifier(Verifier): + def __init__(self, pattern: str, error_message: str) -> None: + self._regex = re.compile(pattern) + self._error_message = error_message + + def verify(self, value: str) -> tuple[bool, str]: + if not self._regex.search(value): + return True, "" + return False, self._error_message + + +class NonEmptyVerifier(Verifier): + def __init__(self, error_message: str) -> None: + self._error_message = error_message + + def verify(self, value: str) -> tuple[bool, str]: + if value.strip(): + return True, "" + return False, self._error_message