Skip to content

Commit 04088b6

Browse files
committed
Add Verify PR title check
1 parent ddb8148 commit 04088b6

8 files changed

Lines changed: 228 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Verify PR Title
2+
3+
on:
4+
pull_request_target:
5+
types: [opened, edited, synchronize]
6+
branches:
7+
- main
8+
9+
permissions: {}
10+
11+
jobs:
12+
verify-pr-title:
13+
runs-on: ubuntu-latest
14+
name: Verify PR title format
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- uses: actions/setup-python@v5
19+
with:
20+
python-version: '3.11'
21+
22+
- name: Verify PR title
23+
env:
24+
PR_TITLE: ${{ github.event.pull_request.title }}
25+
run: python -m verify_pr_title "$PR_TITLE"
26+
working-directory: scripts/ci
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from .extractors import AfterPrefixExtractor, ComponentPrefixExtractor, Extractor, FullTitleExtractor
2+
from .rule import Rule
3+
from .rules import RULES
4+
from .runner import run
5+
from .verifiers import NegativeRegexVerifier, NonEmptyVerifier, RegexVerifier, Verifier
6+
7+
__all__ = [
8+
"Rule",
9+
"Extractor",
10+
"Verifier",
11+
"FullTitleExtractor",
12+
"ComponentPrefixExtractor",
13+
"AfterPrefixExtractor",
14+
"RegexVerifier",
15+
"NegativeRegexVerifier",
16+
"NonEmptyVerifier",
17+
"RULES",
18+
"run",
19+
]
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
5+
from .runner import run
6+
7+
_EXPECTED_FORMAT = (
8+
"[Component] (BREAKING CHANGE: | Hotfix[:] | Fix #<N>[:])? <description>\n"
9+
"{Component} (BREAKING CHANGE: | Hotfix[:] | Fix #<N>[:])? <description>"
10+
)
11+
12+
13+
def main() -> None:
14+
if len(sys.argv) < 2:
15+
print('Usage: python -m verify_pr_title "<PR title>"', file=sys.stderr)
16+
sys.exit(2)
17+
18+
title = sys.argv[1]
19+
failures = run(title)
20+
21+
if not failures:
22+
print(f"PR title validation passed: '{title}'")
23+
sys.exit(0)
24+
25+
print(f"PR title validation failed for: '{title}'\n")
26+
for name, error in failures:
27+
print(f" Rule: {name}")
28+
for line in error.splitlines():
29+
print(f" {line}")
30+
print()
31+
print(f"Expected format:\n {_EXPECTED_FORMAT}")
32+
sys.exit(1)
33+
34+
35+
if __name__ == "__main__":
36+
main()
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from abc import ABC, abstractmethod
5+
6+
7+
class Extractor(ABC):
8+
@abstractmethod
9+
def extract(self, title: str) -> str: ...
10+
11+
12+
class FullTitleExtractor(Extractor):
13+
def extract(self, title: str) -> str:
14+
return title
15+
16+
17+
class ComponentPrefixExtractor(Extractor):
18+
_PATTERN = re.compile(r"^(\[.+?\]|\{.+?\})")
19+
20+
def extract(self, title: str) -> str:
21+
m = self._PATTERN.match(title)
22+
return m.group(1) if m else ""
23+
24+
25+
class AfterPrefixExtractor(Extractor):
26+
_PATTERN = re.compile(r"^(?:\[.+?\]|\{.+?\})\s*(.*)", re.DOTALL)
27+
28+
def extract(self, title: str) -> str:
29+
m = self._PATTERN.match(title)
30+
return m.group(1) if m else title

scripts/ci/verify_pr_title/rule.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
5+
from .extractors import Extractor
6+
from .verifiers import Verifier
7+
8+
9+
@dataclass
10+
class Rule:
11+
name: str
12+
extractor: Extractor
13+
verifier: Verifier
14+
15+
def check(self, title: str) -> tuple[bool, str]:
16+
value = self.extractor.extract(title)
17+
return self.verifier.verify(value)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from __future__ import annotations
2+
3+
from .extractors import AfterPrefixExtractor, FullTitleExtractor
4+
from .rule import Rule
5+
from .verifiers import RegexVerifier
6+
7+
RULES: list[Rule] = [
8+
Rule(
9+
name="Component prefix present",
10+
extractor=FullTitleExtractor(),
11+
verifier=RegexVerifier(
12+
pattern=r"^(\[.+?\]|\{.+?\})",
13+
error_message=(
14+
"Title must start with a non-empty [Component] or {Component} bracket.\n"
15+
" [Component] – customer-facing change (included in HISTORY.rst)\n"
16+
" {Component} – non-customer-facing change (excluded from HISTORY.rst)\n"
17+
" Examples: [Storage], {Misc.}, [API Management]"
18+
),
19+
),
20+
),
21+
Rule(
22+
name="Non-empty description after prefix",
23+
extractor=AfterPrefixExtractor(),
24+
verifier=RegexVerifier(
25+
pattern=(
26+
r"^\s*(?:(?:BREAKING CHANGE:|Hotfix:?|Fix\s+#\d+:?)\s+)\S"
27+
r"|"
28+
r"^\s*(?!BREAKING CHANGE:|Hotfix:?|Fix\s+#\d+:?)\S"
29+
),
30+
error_message=(
31+
"Title must contain a description after the component prefix.\n"
32+
" Optionally preceded by a recognised keyword:\n"
33+
" BREAKING CHANGE: <description>\n"
34+
" Hotfix[:] <description>\n"
35+
" Fix #<N>[:] <description>\n"
36+
" Examples:\n"
37+
" [Storage] az storage blob upload: Add --overwrite flag\n"
38+
" [Compute] BREAKING CHANGE: Remove deprecated --sku parameter\n"
39+
" [Core] Fix #12345: az account show fails on managed identity\n"
40+
" {Misc.} Fix typo in help text"
41+
),
42+
),
43+
),
44+
]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from __future__ import annotations
2+
3+
from .rule import Rule
4+
from .rules import RULES
5+
6+
7+
def run(title: str, rules: list[Rule] = RULES) -> list[tuple[str, str]]:
8+
failures: list[tuple[str, str]] = []
9+
for rule in rules:
10+
passed, error = rule.check(title)
11+
if not passed:
12+
failures.append((rule.name, error))
13+
return failures
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from abc import ABC, abstractmethod
5+
6+
7+
class Verifier(ABC):
8+
@abstractmethod
9+
def verify(self, value: str) -> tuple[bool, str]: ...
10+
11+
12+
class RegexVerifier(Verifier):
13+
def __init__(self, pattern: str, error_message: str, *, fullmatch: bool = False) -> None:
14+
self._regex = re.compile(pattern)
15+
self._error_message = error_message
16+
self._fullmatch = fullmatch
17+
18+
def verify(self, value: str) -> tuple[bool, str]:
19+
fn = self._regex.fullmatch if self._fullmatch else self._regex.search
20+
if fn(value):
21+
return True, ""
22+
return False, self._error_message
23+
24+
25+
class NegativeRegexVerifier(Verifier):
26+
def __init__(self, pattern: str, error_message: str) -> None:
27+
self._regex = re.compile(pattern)
28+
self._error_message = error_message
29+
30+
def verify(self, value: str) -> tuple[bool, str]:
31+
if not self._regex.search(value):
32+
return True, ""
33+
return False, self._error_message
34+
35+
36+
class NonEmptyVerifier(Verifier):
37+
def __init__(self, error_message: str) -> None:
38+
self._error_message = error_message
39+
40+
def verify(self, value: str) -> tuple[bool, str]:
41+
if value.strip():
42+
return True, ""
43+
return False, self._error_message

0 commit comments

Comments
 (0)