Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/VerifyPRTitle.yml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions scripts/ci/verify_pr_title/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
36 changes: 36 additions & 0 deletions scripts/ci/verify_pr_title/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from __future__ import annotations

import sys

from .runner import run

_EXPECTED_FORMAT = (
"[Component] (BREAKING CHANGE: | Hotfix[:] | Fix #<N>[:])? <description>\n"
"{Component} (BREAKING CHANGE: | Hotfix[:] | Fix #<N>[:])? <description>"
Comment on lines +8 to +9
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The printed "Expected format" string implies a required space after the prefix, but the current extraction/validation logic allows titles like "[Core]Fix #123: ..." (no space). Either enforce the space in the rules or adjust _EXPECTED_FORMAT so the CLI output matches what is actually accepted.

Suggested change
"[Component] (BREAKING CHANGE: | Hotfix[:] | Fix #<N>[:])? <description>\n"
"{Component} (BREAKING CHANGE: | Hotfix[:] | Fix #<N>[:])? <description>"
"[Component](BREAKING CHANGE: | Hotfix[:] | Fix #<N>[:])? <description>\n"
"{Component}(BREAKING CHANGE: | Hotfix[:] | Fix #<N>[:])? <description>"

Copilot uses AI. Check for mistakes.
)


def main() -> None:
if len(sys.argv) < 2:
print('Usage: python -m verify_pr_title "<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}")
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When printing Expected format, only the first line is indented; the second line starts at column 0 because _EXPECTED_FORMAT contains a newline. Consider formatting the output by splitting into lines and indenting each line for consistent readability.

Suggested change
print(f"Expected format:\n {_EXPECTED_FORMAT}")
expected_format_indented = "\n".join(f" {line}" for line in _EXPECTED_FORMAT.splitlines())
print(f"Expected format:\n{expected_format_indented}")

Copilot uses AI. Check for mistakes.
sys.exit(1)


if __name__ == "__main__":
main()
30 changes: 30 additions & 0 deletions scripts/ci/verify_pr_title/extractors.py
Original file line number Diff line number Diff line change
@@ -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)

Comment on lines +18 to +27
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extractor patterns for the component prefix (both ComponentPrefixExtractor and AfterPrefixExtractor) also accept whitespace-only bracket content due to ".+?". If you tighten the validation regex to require a non-whitespace character inside the brackets, these patterns should be updated in sync to avoid inconsistencies between extraction and validation.

Copilot uses AI. Check for mistakes.
def extract(self, title: str) -> str:
m = self._PATTERN.match(title)
return m.group(1) if m else title
17 changes: 17 additions & 0 deletions scripts/ci/verify_pr_title/rule.py
Original file line number Diff line number Diff line change
@@ -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)
44 changes: 44 additions & 0 deletions scripts/ci/verify_pr_title/rules.py
Original file line number Diff line number Diff line change
@@ -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"^(\[.+?\]|\{.+?\})",
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The component-prefix regex allows whitespace-only components (e.g. "[ ] Fix ...") because it uses ".+?". This contradicts the error message claiming the bracket must be non-empty. Tighten the pattern to require at least one non-whitespace character inside the brackets so whitespace-only prefixes are rejected.

Suggested change
pattern=r"^(\[.+?\]|\{.+?\})",
pattern=r"^(\[(?!\s*\])[^]]+\]|\{(?!\s*\})[^}]+\})",

Copilot uses AI. Check for mistakes.
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: <description>\n"
" Hotfix[:] <description>\n"
" Fix #<N>[:] <description>\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"
),
),
),
]
13 changes: 13 additions & 0 deletions scripts/ci/verify_pr_title/runner.py
Original file line number Diff line number Diff line change
@@ -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]]:
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

run() uses a module-level list (RULES) as a default argument. Even if you don’t mutate it today, this makes the function susceptible to accidental external mutation and harder to reason about in tests. Prefer a None default and assign RULES inside the function.

Suggested change
def run(title: str, rules: list[Rule] = RULES) -> list[tuple[str, str]]:
def run(title: str, rules: list[Rule] | None = None) -> list[tuple[str, str]]:
if rules is None:
rules = RULES

Copilot uses AI. Check for mistakes.
failures: list[tuple[str, str]] = []
for rule in rules:
passed, error = rule.check(title)
if not passed:
failures.append((rule.name, error))
return failures
43 changes: 43 additions & 0 deletions scripts/ci/verify_pr_title/verifiers.py
Original file line number Diff line number Diff line change
@@ -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
Loading