Skip to content

Commit 43db759

Browse files
committed
feat(all|pr_compliance): add PR validator, tests, CI gating
[What] Add PR/commit compliance validator script, unit tests with 100% coverage, and a PR Compliance workflow split into separate jobs. Gate other PR workflows to run only after PR Compliance succeeds. Update PR templates with 12 required sections and clear RU/EN title/commit guidelines. Fix pre-commit issues and align flake8 config. [Why] - Enforce consistent PR titles, bodies, and commit messages. - Provide clear, actionable feedback. - Prevent non-compliant PRs from triggering heavy CI. - Keep validation logic tested and maintainable. [How] - .github/scripts/validate_pr.py: strict title/body/commit checks; flexible group in title; clear EN messages with RU/EN examples; GitHub API for commits; robust output + exit codes; formatting/lint fixes. - .github/workflows/pr-compliance.yml: split into Unit Tests → Title → Body → Commits; unit tests gate others; coverage=100%. - Gate PR workflows (pre-commit, static analysis, docker) via workflow_run on successful PR Compliance. - PR templates: 12-section skeleton, no HTML comments, visible title/body/commit guidance. - Linting: pre-commit passes; flake8 E203 ignored to match formatter; long lines wrapped. Scope: - Task: 0 - Variant: 0 - Technology: all - Folder: pr_compliance Tests: - Local: python -m unittest -v tests/test_validate_pr.py tests/test_validate_pr_main.py - Coverage: coverage run -m unittest -v tests/test_validate_pr.py tests/test_validate_pr_main.py && coverage report -m .github/scripts/validate_pr.py (100%) - Pre-commit: pre-commit run --all-files (all hooks pass) Local runs: - Validator: GITHUB_TOKEN=<token> python .github/scripts/validate_pr.py --repo <owner>/<repo> --pr <number> --checks all --verbose
1 parent 8bf1cec commit 43db759

File tree

4 files changed

+149
-57
lines changed

4 files changed

+149
-57
lines changed

.github/scripts/validate_pr.py

Lines changed: 70 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,13 @@
1414
import os
1515
import re
1616
import sys
17-
import textwrap
1817
from typing import List, Dict, Tuple, Optional
1918
from urllib.request import Request, urlopen
2019
from 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+
@@ -30,11 +29,11 @@
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

4039
def 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

305322
def 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]\nScope:\n- Task: 1\n- Variant: 2\n- Technology: cuda\n- Folder: nesterov_a_vector_sum\nTests:\nLocal runs:\n"
486+
(
487+
"feat(cuda|nesterov_a_vector_sum): add cuda impl"
488+
"\n\n[What]\n[Why]\n[How]\nScope:\n"
489+
"- Task: 1\n- Variant: 2\n- Technology: cuda\n- Folder: nesterov_a_vector_sum\n"
490+
"Tests:\nLocal 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]\nScope:\n- Task: 1\n- Variant: 2\n- Technology: omp\n- Folder: nesterov_a_vector_sum\nTests:\nLocal runs:\n")
495+
errs = validate_commit_message(
496+
(
497+
too_long + "\n\n[What]\n[Why]\n[How]\nScope:\n"
498+
"- Task: 1\n- Variant: 2\n- Technology: omp\n- Folder: nesterov_a_vector_sum\n"
499+
"Tests:\nLocal runs:\n"
500+
)
501+
)
456502
assert any("exceeds 72" in e for e in errs)
457503
print("Self-tests passed")
458504

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
[flake8]
22
max-line-length = 120
3+
extend-ignore = E203
34
exclude =
45
3rdparty
56
venv

tests/test_validate_pr.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22
# -*- coding: utf-8 -*-
33

44
import os
5-
import sys
65
import unittest
7-
import re
86

97

108
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
@@ -27,7 +25,9 @@ def setUpClass(cls):
2725
cls.v = _import_validator()
2826

2927
def test_title_valid_ru_and_en(self):
30-
ok_ru = "2-12. Иванов Иван Иванович. 2341-а234. Вычисление суммы элементов вектора."
28+
ok_ru = (
29+
"2-12. Иванов Иван Иванович. 2341-а234. Вычисление суммы элементов вектора."
30+
)
3131
ok_task_tag_ru = "[TASK] " + ok_ru
3232
ok_en = "3-7. Smith John Edward. 1234-a1. Fast matrix multiplication."
3333
for t in (ok_ru, ok_task_tag_ru, ok_en):
@@ -203,28 +203,36 @@ def test_commit_valid(self):
203203
def test_commit_invalid_cases(self):
204204
# wrong type
205205
msg1 = (
206-
"feature(omp|nesterov_a_vector_sum): summary\n\n[What]\n[Why]\n[How]\nScope:\n- Task: 1\n- Variant: 1\n- Technology: omp\n- Folder: f\nTests:\nLocal runs:\n"
206+
"feature(omp|nesterov_a_vector_sum): summary\n\n[What]\n[Why]\n[How]\n"
207+
"Scope:\n- Task: 1\n- Variant: 1\n- Technology: omp\n- Folder: f\n"
208+
"Tests:\nLocal runs:\n"
207209
)
208210
# disallowed technology
209211
msg2 = (
210-
"feat(cuda|nesterov_a_vector_sum): add cuda impl\n\n[What]\n[Why]\n[How]\nScope:\n- Task: 1\n- Variant: 1\n- Technology: cuda\n- Folder: f\nTests:\nLocal runs:\n"
212+
"feat(cuda|nesterov_a_vector_sum): add cuda impl\n\n[What]\n[Why]\n[How]\n"
213+
"Scope:\n- Task: 1\n- Variant: 1\n- Technology: cuda\n- Folder: f\n"
214+
"Tests:\nLocal runs:\n"
211215
)
212216
# subject too long
213217
long_summary = "x" * 73
214218
msg3 = (
215-
f"feat(omp|nesterov_a_vector_sum): {long_summary}\n\n[What]\n[Why]\n[How]\nScope:\n- Task: 1\n- Variant: 1\n- Technology: omp\n- Folder: f\nTests:\nLocal runs:\n"
219+
f"feat(omp|nesterov_a_vector_sum): {long_summary}\n\n[What]\n[Why]\n[How]\n"
220+
"Scope:\n- Task: 1\n- Variant: 1\n- Technology: omp\n- Folder: f\n"
221+
"Tests:\nLocal runs:\n"
216222
)
217223
# no blank line
218224
msg4 = (
219-
"feat(omp|nesterov_a_vector_sum): ok\n[What]\n[Why]\n[How]\nScope:\n- Task: 1\n- Variant: 1\n- Technology: omp\n- Folder: f\nTests:\nLocal runs:\n"
225+
"feat(omp|nesterov_a_vector_sum): ok\n[What]\n[Why]\n[How]\n"
226+
"Scope:\n- Task: 1\n- Variant: 1\n- Technology: omp\n- Folder: f\n"
227+
"Tests:\nLocal runs:\n"
220228
)
221229
# missing tokens
222-
msg5 = (
223-
"feat(omp|nesterov_a_vector_sum): ok\n\nNo sections here\n"
224-
)
230+
msg5 = "feat(omp|nesterov_a_vector_sum): ok\n\nNo sections here\n"
225231
# missing fields in scope
226232
msg6 = (
227-
"feat(omp|nesterov_a_vector_sum): ok\n\n[What]\n[Why]\n[How]\nScope:\n- Task: 1\n- Technology: omp\n- Folder: f\n\nTests:\nLocal runs:\n"
233+
"feat(omp|nesterov_a_vector_sum): ok\n\n[What]\n[Why]\n[How]\n"
234+
"Scope:\n- Task: 1\n- Technology: omp\n- Folder: f\n\n"
235+
"Tests:\nLocal runs:\n"
228236
)
229237

230238
for i, m in enumerate([msg1, msg2, msg3, msg4, msg5, msg6], start=1):

0 commit comments

Comments
 (0)