1414import os
1515import re
1616import sys
17- import textwrap
1817from typing import List , Dict , Tuple , Optional
1918from urllib .request import Request , urlopen
2019from urllib .parse import quote
2120
2221
2322# --- Title validation regex (strict) ---
24- TITLE_REGEX = r'''
23+ TITLE_REGEX = r"""
2524^(?:\[TASK\]\s*)?
2625(?P<task>\d+)-(?P<variant>\d+)\.\s+
2726(?P<lastname>[А-ЯA-ZЁ][а-яa-zё]+)\s+
3029(?P<group>.+?)\.\s+
3130(?P<taskname>\S.*)
3231$
33- '''
32+ """
3433
3534
36- SUBJECT_REGEX = r' ^(feat|fix|perf|test|refactor|docs|build|chore)\(([a-z]+)\|([a-z0-9_]+)\): [A-Za-z0-9].*$'
37- ALLOWED_TECH = {' seq' , ' omp' , ' mpi' , ' stl' , ' tbb' , ' all' }
35+ SUBJECT_REGEX = r" ^(feat|fix|perf|test|refactor|docs|build|chore)\(([a-z]+)\|([a-z0-9_]+)\): [A-Za-z0-9].*$"
36+ ALLOWED_TECH = {" seq" , " omp" , " mpi" , " stl" , " tbb" , " all" }
3837
3938
4039def print_section (title : str ) -> None :
@@ -75,7 +74,8 @@ def validate_title(title: str) -> List[str]:
7574 # 1) Optional prefix is allowed; strip it for partial checks
7675 work = title
7776 if work .startswith ("[TASK]" ):
78- work = work [len ("[TASK]" ) :].lstrip ()
77+ task_prefix_len = len ("[TASK]" )
78+ work = work [task_prefix_len :].lstrip ()
7979
8080 # 2) Task/variant with dot
8181 m = re .match (r"^(\d+)-(\d+)\.\s+" , work )
@@ -87,7 +87,8 @@ def validate_title(title: str) -> List[str]:
8787 )
8888 return errors
8989
90- rest = work [m .end () :]
90+ pos = m .end ()
91+ rest = work [pos :]
9192
9293 # 3) Full name with dot after patronymic
9394 m = re .match (
@@ -103,7 +104,8 @@ def validate_title(title: str) -> List[str]:
103104 )
104105 return errors
105106
106- rest = rest [m .end () :]
107+ pos = m .end ()
108+ rest = rest [pos :]
107109
108110 # 4) Group with dot
109111 m = re .match (r"^(.+?)\.\s+" , rest , flags = re .UNICODE )
@@ -115,7 +117,8 @@ def validate_title(title: str) -> List[str]:
115117 )
116118 return errors
117119
118- rest = rest [m .end () :]
120+ pos = m .end ()
121+ rest = rest [pos :]
119122
120123 # 5) Task name validity is enforced by the full regex (non-whitespace start)
121124
@@ -144,7 +147,10 @@ def _split_sections_by_headers(body: str) -> Dict[str, Tuple[int, int]]:
144147 for i , m in enumerate (matches ):
145148 start = m .start ()
146149 end = matches [i + 1 ].start () if i + 1 < len (matches ) else len (body )
147- header_line = body [m .start () : body .find ("\n " , m .start ()) if "\n " in body [m .start () :] else end ]
150+ next_newline = body .find ("\n " , m .start ())
151+ if next_newline == - 1 :
152+ next_newline = end
153+ header_line = body [m .start () : next_newline ]
148154 sections [header_line .strip ()] = (start , end )
149155 return sections
150156
@@ -185,7 +191,9 @@ def validate_body(body: str) -> List[str]:
185191 return errors
186192
187193 if "<!--" in body :
188- errors .append ("Found HTML comments '<!-- ... -->'. Remove all guidance comments." )
194+ errors .append (
195+ "Found HTML comments '<!-- ... -->'. Remove all guidance comments."
196+ )
189197
190198 sections_map = _split_sections_by_headers (body )
191199
@@ -209,8 +217,8 @@ def validate_body(body: str) -> List[str]:
209217
210218 if empty_labels :
211219 errors .append ("Empty required fields (add text after the colon):" )
212- for l in empty_labels :
213- errors .append (f"✗ { l } " )
220+ for label_entry in empty_labels :
221+ errors .append (f"✗ { label_entry } " )
214222
215223 return errors
216224
@@ -273,7 +281,14 @@ def validate_commit_message(message: str) -> List[str]:
273281 body = "\n " .join (lines [2 :]) if len (lines ) >= 2 else ""
274282
275283 # Body tokens at start of line
276- required_tokens = [r"^\[What\]" , r"^\[Why\]" , r"^\[How\]" , r"^Scope:" , r"^Tests:" , r"^Local runs:" ]
284+ required_tokens = [
285+ r"^\[What\]" ,
286+ r"^\[Why\]" ,
287+ r"^\[How\]" ,
288+ r"^Scope:" ,
289+ r"^Tests:" ,
290+ r"^Local runs:" ,
291+ ]
277292 for tok in required_tokens :
278293 if not re .search (tok , body , flags = re .MULTILINE ):
279294 errors .append (f"Missing required body section: '{ tok .strip ('^' )} '." )
@@ -286,7 +301,9 @@ def validate_commit_message(message: str) -> List[str]:
286301 else :
287302 required_scope = ["Task" , "Variant" , "Technology" , "Folder" ]
288303 for key in required_scope :
289- if not re .search (rf"^\s*[-*]?\s*{ key } \s*:\s*.+$" , scope_block , flags = re .MULTILINE ):
304+ if not re .search (
305+ rf"^\s*[-*]?\s*{ key } \s*:\s*.+$" , scope_block , flags = re .MULTILINE
306+ ):
290307 errors .append (f"In 'Scope:' section missing or empty field '{ key } :'." )
291308
292309 return errors
@@ -304,9 +321,16 @@ def _load_event_payload(path: Optional[str]) -> Optional[dict]:
304321
305322def main () -> int :
306323 parser = argparse .ArgumentParser (description = "PR/commit compliance validator" )
307- parser .add_argument ("--repo" , type = str , default = os .environ .get ("GITHUB_REPOSITORY" ), help = "owner/repo" )
324+ parser .add_argument (
325+ "--repo" ,
326+ type = str ,
327+ default = os .environ .get ("GITHUB_REPOSITORY" ),
328+ help = "owner/repo" ,
329+ )
308330 parser .add_argument ("--pr" , type = int , default = None , help = "PR number" )
309- parser .add_argument ("--checks" , type = str , choices = ["title" , "body" , "commits" , "all" ], default = "all" )
331+ parser .add_argument (
332+ "--checks" , type = str , choices = ["title" , "body" , "commits" , "all" ], default = "all"
333+ )
310334 parser .add_argument ("--fail-on-warn" , action = "store_true" )
311335 parser .add_argument ("--verbose" , action = "store_true" )
312336
@@ -323,7 +347,9 @@ def main() -> int:
323347
324348 if payload and not pr_number :
325349 pr_number = payload .get ("number" ) or (
326- payload .get ("pull_request" , {}).get ("number" ) if payload .get ("pull_request" ) else None
350+ payload .get ("pull_request" , {}).get ("number" )
351+ if payload .get ("pull_request" )
352+ else None
327353 )
328354
329355 # Collect title/body from payload when available
@@ -346,7 +372,9 @@ def main() -> int:
346372 if args .checks in ("title" , "all" ):
347373 print_section ("PR TITLE" )
348374 if pr_title is None :
349- print ("Could not get PR title from event payload. Ensure a pull_request context or supply it manually." )
375+ print (
376+ "Could not get PR title from event payload. Ensure a pull_request context or supply it manually."
377+ )
350378 total_errors .append ("No title data" )
351379 else :
352380 errs = validate_title (pr_title )
@@ -363,7 +391,9 @@ def main() -> int:
363391 if args .checks in ("body" , "all" ):
364392 print_section ("PR BODY" )
365393 if pr_body is None :
366- print ("Could not get PR body from event payload. Ensure a pull_request context or supply it manually." )
394+ print (
395+ "Could not get PR body from event payload. Ensure a pull_request context or supply it manually."
396+ )
367397 total_errors .append ("No body data" )
368398 else :
369399 errs = validate_body (pr_body )
@@ -380,7 +410,9 @@ def main() -> int:
380410 if args .checks in ("commits" , "all" ):
381411 print_section ("COMMITS" )
382412 if not (owner and repo and pr_number ):
383- print ("Commit validation requires --repo owner/repo and --pr <number> or a GitHub event payload." )
413+ print (
414+ "Commit validation requires --repo owner/repo and --pr <number> or a GitHub event payload."
415+ )
384416 total_errors .append ("Insufficient params for commits fetch" )
385417 else :
386418 token = os .environ .get ("GITHUB_TOKEN" )
@@ -444,15 +476,29 @@ def main() -> int:
444476 assert validate_title (t ), f"Expected invalid title: { t } "
445477
446478 # Commit subjects
447- assert re .match (SUBJECT_REGEX , "feat(omp|nesterov_a_vector_sum): implement parallel vector sum" )
479+ assert re .match (
480+ SUBJECT_REGEX ,
481+ "feat(omp|nesterov_a_vector_sum): implement parallel vector sum" ,
482+ )
448483 assert not re .match (SUBJECT_REGEX , "feature(omp|x): bad type" )
449484 # Technology validation is performed outside the regex
450485 errs = validate_commit_message (
451- "feat(cuda|nesterov_a_vector_sum): add cuda impl\n \n [What]\n [Why]\n [How]\n Scope:\n - Task: 1\n - Variant: 2\n - Technology: cuda\n - Folder: nesterov_a_vector_sum\n Tests:\n Local runs:\n "
486+ (
487+ "feat(cuda|nesterov_a_vector_sum): add cuda impl"
488+ "\n \n [What]\n [Why]\n [How]\n Scope:\n "
489+ "- Task: 1\n - Variant: 2\n - Technology: cuda\n - Folder: nesterov_a_vector_sum\n "
490+ "Tests:\n Local runs:\n "
491+ )
452492 )
453493 assert any ("Disallowed technology" in e for e in errs )
454494 too_long = "feat(omp|nesterov_a_vector_sum): " + "x" * 73
455- errs = validate_commit_message (too_long + "\n \n [What]\n [Why]\n [How]\n Scope:\n - Task: 1\n - Variant: 2\n - Technology: omp\n - Folder: nesterov_a_vector_sum\n Tests:\n Local runs:\n " )
495+ errs = validate_commit_message (
496+ (
497+ too_long + "\n \n [What]\n [Why]\n [How]\n Scope:\n "
498+ "- Task: 1\n - Variant: 2\n - Technology: omp\n - Folder: nesterov_a_vector_sum\n "
499+ "Tests:\n Local runs:\n "
500+ )
501+ )
456502 assert any ("exceeds 72" in e for e in errs )
457503 print ("Self-tests passed" )
458504
0 commit comments