1717import os
1818import re
1919import 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
3727def _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+
4143def 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
0 commit comments