Skip to content

Commit adaee71

Browse files
committed
enhance PR compliance validation: add new tests, improve regex checks, and refine workflows
1 parent f346046 commit adaee71

File tree

7 files changed

+227
-35
lines changed

7 files changed

+227
-35
lines changed

.github/scripts/validate_pr.py

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
(?P<firstname>[А-ЯA-ZЁ][а-яa-zё]+)\s+
2929
(?P<middlename>[А-ЯA-ZЁ][а-яa-zё]+)\.\s+
3030
(?P<group>\d{3,5}(?:-[A-Za-zА-Яа-я0-9-]+)?)\.\s+
31-
(?P<taskname>.+)
31+
(?P<taskname>\S.*)
3232
$
3333
'''
3434

@@ -68,9 +68,8 @@ def validate_title(title: str) -> List[str]:
6868
f"Example (EN): {example_en}"
6969
]
7070

71-
# Full strict match
72-
if re.match(TITLE_REGEX, title, flags=re.UNICODE | re.VERBOSE) is not None:
73-
return errors
71+
# Full strict match (record but do not early-return to allow granular checks)
72+
full_match = re.match(TITLE_REGEX, title, flags=re.UNICODE | re.VERBOSE) is not None
7473

7574
# If not matched – try to pinpoint the failing segment.
7675
# 1) Optional prefix is allowed; strip it for partial checks
@@ -118,17 +117,12 @@ def validate_title(title: str) -> List[str]:
118117

119118
rest = rest[m.end() :]
120119

121-
# 5) Task name must be non-empty and end with a period is not explicitly required
122-
# by regex (taskname can contain a trailing period as part of text). Ensure non-empty
123-
if not rest.strip():
124-
errors.append(
125-
"Task name is missing — non-empty text is required after the group.\n"
126-
f"Example (RU): {example_ru}\n"
127-
f"Example (EN): {example_en}"
128-
)
129-
return errors
120+
# 5) Task name validity is enforced by the full regex (non-whitespace start)
130121

131-
# Fallback
122+
# If no specific segment errors and full regex matched, accept
123+
if full_match:
124+
return errors
125+
# Otherwise, fallback message
132126
errors.append(
133127
"PR title does not match the required pattern.\n"
134128
f"Current title: '{title}'\n"

.github/workflows/docker.yml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@ on:
55
paths:
66
- 'docker/**'
77
- '.github/workflows/docker.yml'
8-
pull_request:
9-
paths:
10-
- 'docker/**'
11-
- '.github/workflows/docker.yml'
8+
workflow_run:
9+
workflows: ["PR Compliance"]
10+
types: [completed]
1211
workflow_dispatch:
1312

1413
permissions:
@@ -19,7 +18,9 @@ jobs:
1918
build-and-push:
2019
name: Build & Push Docker Image
2120
runs-on: ubuntu-latest
22-
if: github.repository == 'learning-process/parallel_programming_course'
21+
if: >-
22+
${{ github.repository == 'learning-process/parallel_programming_course' &&
23+
(github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') }}
2324
2425
steps:
2526
- name: Check out code

.github/workflows/pr-compliance.yml

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ on:
55
types: [opened, edited, synchronize, reopened]
66

77
jobs:
8-
validate:
8+
unit_tests:
9+
name: Unit Tests
910
runs-on: ubuntu-latest
1011
steps:
1112
- name: Checkout
@@ -21,13 +22,64 @@ jobs:
2122
python -m pip install --upgrade pip
2223
python -m pip install coverage
2324
24-
- name: Run validator unit tests
25+
- name: Run validator unit tests with coverage
2526
run: |
2627
coverage run -m unittest -v tests/test_validate_pr.py tests/test_validate_pr_main.py
2728
coverage report --fail-under=100 -m .github/scripts/validate_pr.py
2829
29-
- name: Validate PR title, body and commits
30+
pr_title:
31+
name: Validate PR Title
32+
runs-on: ubuntu-latest
33+
needs: [unit_tests]
34+
steps:
35+
- name: Checkout
36+
uses: actions/checkout@v4
37+
38+
- name: Set up Python
39+
uses: actions/setup-python@v5
40+
with:
41+
python-version: '3.11'
42+
43+
- name: Validate PR title
44+
run: |
45+
python .github/scripts/validate_pr.py --checks title --verbose
46+
env:
47+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
48+
49+
pr_body:
50+
name: Validate PR Body
51+
runs-on: ubuntu-latest
52+
needs: [unit_tests]
53+
steps:
54+
- name: Checkout
55+
uses: actions/checkout@v4
56+
57+
- name: Set up Python
58+
uses: actions/setup-python@v5
59+
with:
60+
python-version: '3.11'
61+
62+
- name: Validate PR body
63+
run: |
64+
python .github/scripts/validate_pr.py --checks body --verbose
65+
env:
66+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
67+
68+
pr_commits:
69+
name: Validate PR Commits
70+
runs-on: ubuntu-latest
71+
needs: [unit_tests]
72+
steps:
73+
- name: Checkout
74+
uses: actions/checkout@v4
75+
76+
- name: Set up Python
77+
uses: actions/setup-python@v5
78+
with:
79+
python-version: '3.11'
80+
81+
- name: Validate PR commits
3082
run: |
31-
python .github/scripts/validate_pr.py --checks all --verbose
83+
python .github/scripts/validate_pr.py --checks commits --verbose
3284
env:
3385
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/pre-commit.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ name: Pre-commit checks
22

33
on:
44
push:
5-
pull_request:
65
workflow_call:
6+
workflow_run:
7+
workflows: ["PR Compliance"]
8+
types: [completed]
79

810
jobs:
911
pre-commit:
12+
if: >-
13+
${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
1014
runs-on: ubuntu-24.04
1115
container:
1216
image: ghcr.io/learning-process/ppc-ubuntu:1.1

.github/workflows/static-analysis-pr.yml

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
11
name: Static analysis
22

33
on:
4-
pull_request:
5-
paths:
6-
- '**/*.cpp'
7-
- '**/*.hpp'
8-
- '**/*.c'
9-
- '**/*.h'
10-
- '**/CMakeLists.txt'
11-
- '**/*.cmake'
12-
- '**/.clang-tidy'
13-
- '.github/workflows/static-analysis-pr.yml'
4+
workflow_run:
5+
workflows: ["PR Compliance"]
6+
types: [completed]
147

158
concurrency:
169
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
@@ -21,6 +14,7 @@ concurrency:
2114
2215
jobs:
2316
clang-tidy:
17+
if: ${{ github.event.workflow_run.conclusion == 'success' }}
2418
runs-on: ubuntu-24.04
2519
container:
2620
image: ghcr.io/learning-process/ppc-ubuntu:1.1
@@ -117,6 +111,7 @@ jobs:
117111
echo "clang-tidy run has failed. See previous 'Run clang-tidy' stage logs"
118112
exit 1
119113
nolint-check:
114+
if: ${{ github.event.workflow_run.conclusion == 'success' }}
120115
runs-on: ubuntu-24.04
121116
steps:
122117
- uses: actions/checkout@v5

tests/test_validate_pr.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ def test_title_invalid_examples(self):
4444
with self.subTest(t=t):
4545
self.assertNotEqual(self.v.validate_title(t), [])
4646

47+
def test_title_empty_and_task_prefix_partial(self):
48+
# Empty title path
49+
errs = self.v.validate_title("")
50+
self.assertNotEqual(errs, [])
51+
# Strip [TASK] and fail early on task/variant block to cover branch
52+
errs = self.v.validate_title("[TASK] Иванов Иван Иванович — задача")
53+
self.assertTrue(any("Invalid task/variant block" in e for e in errs))
54+
4755

4856
class TestPRBody(unittest.TestCase):
4957
@classmethod
@@ -140,6 +148,40 @@ def test_body_with_html_comments(self):
140148
errs = self.v.validate_body(body)
141149
self.assertTrue(any("Found HTML comments" in e for e in errs))
142150

151+
def test_body_empty(self):
152+
errs = self.v.validate_body("")
153+
self.assertTrue(any("PR body is empty" in e for e in errs))
154+
155+
def test_body_label_without_any_value(self):
156+
body = (
157+
"## 1. Full name and group\n"
158+
"Name and group:\n\n" # no spaces after colon
159+
"## 2. Assignment / Topic / Task\n"
160+
"Assignment: A\n\n"
161+
"## 3. Technology / Platform used\n"
162+
"Technology: A\n\n"
163+
"## 4. Goals of the work\n"
164+
"Goals: A\n\n"
165+
"## 5. Solution description and structure\n"
166+
"Description: A\n\n"
167+
"## 6. System requirements and build instructions\n"
168+
"Build & Run: A\n\n"
169+
"## 7. Testing and verification\n"
170+
"Testing: A\n\n"
171+
"## 8. Results\n"
172+
"Results: A\n\n"
173+
"## 9. Performance analysis\n"
174+
"Analysis: A\n\n"
175+
"## 10. Conclusions and possible improvements\n"
176+
"Conclusions: A\n\n"
177+
"## 11. Limitations / known issues\n"
178+
"Limitations: A\n\n"
179+
"## 12. CI / static analysis / code style\n"
180+
"CI & Style: A\n"
181+
)
182+
errs = self.v.validate_body(body)
183+
self.assertTrue(any("Empty required fields" in e for e in errs))
184+
143185

144186
class TestCommitMessages(unittest.TestCase):
145187
@classmethod
@@ -190,7 +232,20 @@ def test_commit_invalid_cases(self):
190232
errs = self.v.validate_commit_message(m)
191233
self.assertNotEqual(errs, [])
192234

235+
def test_title_partial_group_and_taskname_errors_and_fallback(self):
236+
# Valid task/variant and name, invalid group
237+
t1 = "2-12. Иванов Иван Иванович. ХХХ. Что-то"
238+
errs = self.v.validate_title(t1)
239+
self.assertTrue(any("Invalid group number" in e for e in errs))
240+
# Valid group but trailing newline — parser flags group error in this path
241+
t2 = "2-12. Иванов Иван Иванович. 2341-а234. \n"
242+
errs = self.v.validate_title(t2)
243+
self.assertTrue(any("Invalid group number" in e for e in errs))
244+
# Fallback case: embed newline so full regex fails while partial checks pass
245+
t3 = "2-12. Иванов Иван Иванович. 2341-а234. Задача\nещё"
246+
errs = self.v.validate_title(t3)
247+
self.assertTrue(any("does not match the required pattern" in e for e in errs))
248+
193249

194250
if __name__ == "__main__":
195251
unittest.main(verbosity=2)
196-

tests/test_validate_pr_main.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,86 @@ def test_main_commits_error_and_internal(self):
125125
code = self.v.main()
126126
self.assertEqual(code, 1)
127127

128+
def test_main_title_and_body_missing_in_payload(self):
129+
# No payload at all
130+
with mock.patch.object(sys, 'argv', ["prog", "--checks", "title", "--verbose"]):
131+
os.environ.pop("GITHUB_EVENT_PATH", None)
132+
code = self.v.main()
133+
self.assertEqual(code, 1)
134+
# Payload present but missing title/body triggers error printing branch
135+
data = {"pull_request": {"number": 5, "title": "Иванов Иван Иванович — задача", "body": "## 1. Full name and group\nName and group: \n"}}
136+
with tempfile.NamedTemporaryFile("w+", delete=False) as tf:
137+
json.dump(data, tf)
138+
path = tf.name
139+
try:
140+
with mock.patch.object(sys, 'argv', ["prog", "--checks", "title", "--verbose"]):
141+
os.environ["GITHUB_EVENT_PATH"] = path
142+
code = self.v.main()
143+
self.assertEqual(code, 1)
144+
with mock.patch.object(sys, 'argv', ["prog", "--checks", "body", "--verbose"]):
145+
os.environ["GITHUB_EVENT_PATH"] = path
146+
code = self.v.main()
147+
self.assertEqual(code, 1)
148+
finally:
149+
os.unlink(path)
150+
128151
# Now raise an internal error
129152
with mock.patch.object(sys, 'argv', ["prog", "--checks", "commits", "--repo", "o/r", "--pr", "1"]):
130153
os.environ["GITHUB_TOKEN"] = "token"
131154
with mock.patch.object(self.v, "fetch_pr_commits", side_effect=RuntimeError("boom")):
132155
code = self.v.main()
133156
self.assertEqual(code, 2)
134157

158+
def test_main_body_none_and_ok(self):
159+
v = _import_validator()
160+
# Body None path (no payload)
161+
with mock.patch.object(sys, 'argv', ["prog", "--checks", "body", "--verbose"]):
162+
os.environ.pop("GITHUB_EVENT_PATH", None)
163+
code = v.main()
164+
self.assertEqual(code, 1)
165+
# Body OK path
166+
body = (
167+
"## 1. Full name and group\nName and group: A\n\n"
168+
"## 2. Assignment / Topic / Task\nAssignment: A\n\n"
169+
"## 3. Technology / Platform used\nTechnology: omp\n\n"
170+
"## 4. Goals of the work\nGoals: A\n\n"
171+
"## 5. Solution description and structure\nDescription: A\n\n"
172+
"## 6. System requirements and build instructions\nBuild & Run: A\n\n"
173+
"## 7. Testing and verification\nTesting: A\n\n"
174+
"## 8. Results\nResults: A\n\n"
175+
"## 9. Performance analysis\nAnalysis: A\n\n"
176+
"## 10. Conclusions and possible improvements\nConclusions: A\n\n"
177+
"## 11. Limitations / known issues\nLimitations: A\n\n"
178+
"## 12. CI / static analysis / code style\nCI & Style: A\n"
179+
)
180+
data = {"pull_request": {"number": 7, "title": "x", "body": body}}
181+
with tempfile.NamedTemporaryFile("w+", delete=False) as tf:
182+
json.dump(data, tf)
183+
path = tf.name
184+
try:
185+
with mock.patch.object(sys, 'argv', ["prog", "--checks", "body", "--verbose"]):
186+
os.environ["GITHUB_EVENT_PATH"] = path
187+
code = v.main()
188+
self.assertEqual(code, 0)
189+
finally:
190+
os.unlink(path)
191+
192+
def test_main_title_print_branch_else(self):
193+
v = _import_validator()
194+
data = {"pull_request": {"number": 8, "title": "bad", "body": ""}}
195+
with tempfile.NamedTemporaryFile("w+", delete=False) as tf:
196+
json.dump(data, tf)
197+
path = tf.name
198+
try:
199+
with mock.patch.object(sys, 'argv', ["prog", "--checks", "title"]):
200+
# Force errs starting with '✗' to hit print(e)
201+
with mock.patch.object(v, 'validate_title', return_value=["✗ forced-error"]):
202+
os.environ["GITHUB_EVENT_PATH"] = path
203+
code = v.main()
204+
self.assertEqual(code, 1)
205+
finally:
206+
os.unlink(path)
207+
135208

136209
class TestRunAsMain(unittest.TestCase):
137210
def test_run_module_as_main_with_verbose(self):
@@ -171,6 +244,24 @@ def test_run_module_as_main_with_verbose(self):
171244
finally:
172245
os.unlink(path)
173246

247+
def test_systemexit_branch_in_try(self):
248+
# Force validate_title to raise SystemExit inside try block to hit 'except SystemExit: raise'
249+
v = _import_validator()
250+
with mock.patch.object(sys, 'argv', ["prog", "--checks", "title"]):
251+
with mock.patch.object(v, 'validate_title', side_effect=SystemExit(5)):
252+
# Provide minimal payload with title
253+
data = {"pull_request": {"number": 1, "title": "bad", "body": ""}}
254+
with tempfile.NamedTemporaryFile("w+", delete=False) as tf:
255+
json.dump(data, tf)
256+
path = tf.name
257+
try:
258+
os.environ["GITHUB_EVENT_PATH"] = path
259+
with self.assertRaises(SystemExit) as cm:
260+
v.main()
261+
self.assertEqual(cm.exception.code, 5)
262+
finally:
263+
os.unlink(path)
264+
174265

175266
if __name__ == "__main__":
176267
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)