Skip to content

Commit e715ac9

Browse files
committed
refactor: split PR title validation into separate reusable workflow and enhance policy configuration
1 parent 2db618d commit e715ac9

4 files changed

Lines changed: 88 additions & 43 deletions

File tree

.github/policy/pr_title.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"allow_dev": true,
3+
"task_regex": "^\\[TASK\\]\\s+(?P<task>\\d+)-(?P<variant>\\d+)\\.\\s+(?P<lastname>[А-ЯA-ZЁ][а-яa-zё]+)\\s+(?P<firstname>[А-ЯA-ZЁ][а-яa-zё]+)\\s+(?P<middlename>[А-ЯA-ZЁ][а-яa-zё]+)\\.\\s+(?P<group>.+?)\\.\\s+(?P<taskname>\\S.*)$",
4+
"dev_regex": "^\\[DEV\\]\\s+\\S.*$",
5+
"examples": {
6+
"task_ru": "[TASK] 2-12. Иванов Иван Иванович. 2341-а234. Вычисление суммы элементов вектора.",
7+
"task_en": "[TASK] 2-12. Ivanov Ivan Ivanovich. 2341-a234. Vector elements sum calculation.",
8+
"dev": "[DEV] Update docs for lab 2"
9+
}
10+
}

.github/scripts/validate_pr.py

Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,29 @@
1717
import os
1818
import re
1919
import sys
20-
from typing import List, Optional
21-
22-
23-
TITLE_TASK_REGEX = r"""
24-
^\[TASK\]\s+
25-
(?P<task>\d+)-(?P<variant>\d+)\.\s+
26-
(?P<lastname>[А-ЯA-ZЁ][а-яa-zё]+)\s+
27-
(?P<firstname>[А-ЯA-ZЁ][а-яa-zё]+)\s+
28-
(?P<middlename>[А-ЯA-ZЁ][а-яa-zё]+)\.\s+
29-
(?P<group>.+?)\.\s+
30-
(?P<taskname>\S.*)
31-
$
32-
"""
20+
from typing import List, Optional, Tuple
21+
3322

34-
TITLE_DEV_REGEX = r"^\[DEV\]\s+\S.*$"
23+
DEFAULT_TITLE_TASK_REGEX = None # No built-in defaults — must come from file
24+
DEFAULT_TITLE_DEV_REGEX = None # No built-in defaults — must come from file
3525

3626

3727
def _trim(s: Optional[str]) -> str:
3828
return (s or "").strip()
3929

4030

31+
def _load_title_config() -> Tuple[Optional[dict], List[str]]:
32+
policy_path = os.path.join(".github", "policy", "pr_title.json")
33+
if os.path.exists(policy_path):
34+
try:
35+
with open(policy_path, "r", encoding="utf-8") as f:
36+
return json.load(f), [policy_path]
37+
except Exception:
38+
# Invalid JSON — treat as error (no defaults)
39+
return None, [policy_path]
40+
return None, [policy_path]
41+
42+
4143
def validate_title(title: str) -> List[str]:
4244
"""Validate PR title. Returns a list of error messages (empty if valid)."""
4345
title = (title or "").strip()
@@ -46,23 +48,47 @@ def validate_title(title: str) -> List[str]:
4648
"Empty PR title. Use '[TASK] …' for tasks or '[DEV] …' for development.",
4749
]
4850

51+
# Load policy config (required)
52+
cfg, candidates = _load_title_config()
53+
if not cfg:
54+
return [
55+
"PR title policy config not found or invalid.",
56+
f"Expected one of: {', '.join(candidates)}",
57+
]
58+
59+
# Validate required keys (no built-in defaults)
60+
errors: List[str] = []
61+
task_regex = cfg.get("task_regex")
62+
dev_regex = cfg.get("dev_regex")
63+
allow_dev = cfg.get("allow_dev")
64+
examples = cfg.get("examples") if isinstance(cfg.get("examples"), dict) else {}
65+
66+
if not isinstance(task_regex, str) or not task_regex.strip():
67+
errors.append("Missing or empty 'task_regex' in policy config.")
68+
if not isinstance(dev_regex, str) or not dev_regex.strip():
69+
errors.append("Missing or empty 'dev_regex' in policy config.")
70+
if not isinstance(allow_dev, bool):
71+
errors.append("Missing or non-boolean 'allow_dev' in policy config.")
72+
if errors:
73+
return errors
74+
4975
# Accept development titles with a simple rule
50-
if re.match(TITLE_DEV_REGEX, title, flags=re.UNICODE):
76+
if allow_dev and re.match(dev_regex, title, flags=re.UNICODE | re.VERBOSE):
5177
return []
5278

5379
# Accept strict course task titles
54-
if re.match(TITLE_TASK_REGEX, title, flags=re.UNICODE | re.VERBOSE):
80+
if re.match(task_regex, title, flags=re.UNICODE | re.VERBOSE):
5581
return []
5682

57-
example_task_ru = "[TASK] 2-12. Иванов Иван Иванович. 2341-а234. Вычисление суммы элементов вектора."
58-
example_task_en = "[TASK] 2-12. Ivanov Ivan Ivanovich. 2341-a234. Vector elements sum calculation."
59-
example_dev = "[DEV] Update docs for lab 2"
83+
example_task_ru = examples.get("task_ru")
84+
example_task_en = examples.get("task_en")
85+
example_dev = examples.get("dev")
6086
return [
6187
"Invalid PR title.",
62-
"Allowed formats:",
63-
f"- Task: {example_task_ru}",
64-
f"- Task: {example_task_en}",
65-
f"- Dev: {example_dev}",
88+
"Allowed formats (see policy config):",
89+
*( [f"- Task (RU): {example_task_ru}"] if example_task_ru else [] ),
90+
*( [f"- Task (EN): {example_task_en}"] if example_task_en else [] ),
91+
*( [f"- Dev: {example_dev}"] if example_dev else [] ),
6692
]
6793

6894

.github/workflows/main.yml

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,26 +20,7 @@ concurrency:
2020
2121
jobs:
2222
pr_title:
23-
name: PR Title Gate
24-
runs-on: ubuntu-latest
25-
steps:
26-
- name: Non-PR event — skip title validation
27-
if: ${{ github.event_name != 'pull_request' }}
28-
run: echo "Not a PR event; skipping title check"
29-
30-
- name: Checkout
31-
if: ${{ github.event_name == 'pull_request' }}
32-
uses: actions/checkout@v4
33-
34-
- name: Set up Python
35-
if: ${{ github.event_name == 'pull_request' }}
36-
uses: actions/setup-python@v5
37-
with:
38-
python-version: '3.11'
39-
40-
- name: Validate PR title
41-
if: ${{ github.event_name == 'pull_request' }}
42-
run: python .github/scripts/validate_pr.py
23+
uses: ./.github/workflows/pr-title.yml
4324

4425
pre-commit:
4526
if: ${{ github.event_name != 'pull_request' || github.event.action != 'edited' || github.event.changes.title }}

.github/workflows/pr-title.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: PR Title Gate
2+
3+
on:
4+
workflow_call:
5+
6+
jobs:
7+
pr_title:
8+
name: Validate PR Title
9+
runs-on: ubuntu-latest
10+
steps:
11+
- name: Skip on non-PR events
12+
if: ${{ github.event_name != 'pull_request' }}
13+
run: echo "Not a PR event; skipping title check"
14+
15+
- name: Checkout
16+
if: ${{ github.event_name == 'pull_request' }}
17+
uses: actions/checkout@v4
18+
19+
- name: Set up Python
20+
if: ${{ github.event_name == 'pull_request' }}
21+
uses: actions/setup-python@v5
22+
with:
23+
python-version: '3.11'
24+
25+
- name: Validate PR title
26+
if: ${{ github.event_name == 'pull_request' }}
27+
run: python .github/scripts/validate_pr.py
28+

0 commit comments

Comments
 (0)