From 227ceec5e91dc240c403dd424f7ee766e1c3daa5 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sat, 18 Oct 2025 12:54:34 +0200 Subject: [PATCH 1/7] add PR title validation in CI --- .../task_submission_en.md | 4 + .../task_submission_ru.md | 4 + .github/pull_request_template.md | 4 + .github/scripts/validate_pr.py | 115 ++++++++++++++++++ .github/workflows/main.yml | 24 ++++ 5 files changed, 151 insertions(+) create mode 100644 .github/scripts/validate_pr.py diff --git a/.github/PULL_REQUEST_TEMPLATE/task_submission_en.md b/.github/PULL_REQUEST_TEMPLATE/task_submission_en.md index 59ecfe046..3a0206f9e 100644 --- a/.github/PULL_REQUEST_TEMPLATE/task_submission_en.md +++ b/.github/PULL_REQUEST_TEMPLATE/task_submission_en.md @@ -1,3 +1,7 @@ +PR Title (CI enforced): +- Tasks: `[TASK] -. . . .` +- Development: `[DEV] ` + Please go to the `Preview` tab and select the appropriate template: diff --git a/.github/scripts/validate_pr.py b/.github/scripts/validate_pr.py new file mode 100644 index 000000000..676fc8201 --- /dev/null +++ b/.github/scripts/validate_pr.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Minimal PR title validator for CI gate. + +Rules: +- Accept either a strict task title with required prefix '[TASK]' + Pattern: [TASK] -. . . +- Or a development title with prefix '[DEV]' followed by any non-empty text + Pattern: [DEV] +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from typing import List, Optional + + +TITLE_TASK_REGEX = r""" +^\[TASK\]\s+ +(?P\d+)-(?P\d+)\.\s+ +(?P[А-ЯA-ZЁ][а-яa-zё]+)\s+ +(?P[А-ЯA-ZЁ][а-яa-zё]+)\s+ +(?P[А-ЯA-ZЁ][а-яa-zё]+)\.\s+ +(?P.+?)\.\s+ +(?P\S.*) +$ +""" + +TITLE_DEV_REGEX = r"^\[DEV\]\s+\S.*$" + + +def _trim(s: Optional[str]) -> str: + return (s or "").strip() + + +def validate_title(title: str) -> List[str]: + """Validate PR title. Returns a list of error messages (empty if valid).""" + title = (title or "").strip() + if not title: + return [ + "Empty PR title. Use '[TASK] …' for tasks or '[DEV] …' for development.", + ] + + # Accept development titles with a simple rule + if re.match(TITLE_DEV_REGEX, title, flags=re.UNICODE): + return [] + + # Accept strict course task titles + if re.match(TITLE_TASK_REGEX, title, flags=re.UNICODE | re.VERBOSE): + return [] + + example_task_ru = ( + "[TASK] 2-12. Иванов Иван Иванович. 2341-а234. Вычисление суммы элементов вектора." + ) + example_task_en = ( + "[TASK] 2-12. Ivanov Ivan Ivanovich. 2341-a234. Vector elements sum calculation." + ) + example_dev = "[DEV] Update docs for lab 2" + return [ + "Invalid PR title.", + "Allowed formats:", + f"- Task: {example_task_ru}", + f"- Task: {example_task_en}", + f"- Dev: {example_dev}", + ] + + +def _load_event_payload(path: Optional[str]) -> Optional[dict]: + if not path or not os.path.exists(path): + return None + with open(path, "r", encoding="utf-8") as f: + try: + return json.load(f) + except Exception: + return None + + +def main() -> int: + try: + payload = _load_event_payload(os.environ.get("GITHUB_EVENT_PATH")) + + pr_title = None + if payload and payload.get("pull_request"): + pr = payload["pull_request"] + pr_title = pr.get("title") + + if pr_title is None: + # Not a PR context – do not fail the gate + print("No PR title in event payload; skipping title check (non-PR event).") + return 0 + + errs = validate_title(pr_title) + if errs: + for e in errs: + print(f"✗ {e}") + return 1 + + print("OK: PR title is valid.") + return 0 + + except SystemExit: + raise + except Exception as e: + print(f"Internal error occurred: {e}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b41003750..e02347932 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,7 +16,31 @@ concurrency: !startsWith(github.ref, 'refs/heads/gh-readonly-queue') }} jobs: + pr_title: + name: PR Title Gate + runs-on: ubuntu-latest + steps: + - name: Non-PR event — skip title validation + if: ${{ github.event_name != 'pull_request' }} + run: echo "Not a PR event; skipping title check" + + - name: Checkout + if: ${{ github.event_name == 'pull_request' }} + uses: actions/checkout@v4 + + - name: Set up Python + if: ${{ github.event_name == 'pull_request' }} + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Validate PR title + if: ${{ github.event_name == 'pull_request' }} + run: python .github/scripts/validate_pr.py + pre-commit: + needs: + - pr_title uses: ./.github/workflows/pre-commit.yml ubuntu: needs: From 21b367632038c83ce20b545e8802ecc21670f2df Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sat, 18 Oct 2025 13:01:19 +0200 Subject: [PATCH 2/7] update GitHub Actions workflow: enhance conditional triggers and add branch specification --- .github/workflows/main.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e02347932..eaca105bc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,7 +2,10 @@ name: Build application on: push: + branches: + - master pull_request: + types: [opened, edited, synchronize, reopened] merge_group: schedule: - cron: '0 0 * * *' @@ -39,22 +42,27 @@ jobs: run: python .github/scripts/validate_pr.py pre-commit: + if: ${{ github.event_name != 'pull_request' || github.event.action != 'edited' || github.event.changes.title }} needs: - pr_title uses: ./.github/workflows/pre-commit.yml ubuntu: + if: ${{ github.event_name != 'pull_request' || github.event.action != 'edited' || github.event.changes.title }} needs: - pre-commit uses: ./.github/workflows/ubuntu.yml mac: + if: ${{ github.event_name != 'pull_request' || github.event.action != 'edited' || github.event.changes.title }} needs: - pre-commit uses: ./.github/workflows/mac.yml windows: + if: ${{ github.event_name != 'pull_request' || github.event.action != 'edited' || github.event.changes.title }} needs: - pre-commit uses: ./.github/workflows/windows.yml perf: + if: ${{ github.event_name != 'pull_request' || github.event.action != 'edited' || github.event.changes.title }} needs: - ubuntu - mac @@ -62,6 +70,7 @@ jobs: uses: ./.github/workflows/perf.yml pages: + if: ${{ github.event_name != 'pull_request' || github.event.action != 'edited' || github.event.changes.title }} needs: - perf uses: ./.github/workflows/pages.yml From 2db618da30becf2d2f0280e08238e6afa53c17e2 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sat, 18 Oct 2025 13:07:14 +0200 Subject: [PATCH 3/7] update GitHub Actions workflow: adjust upstream repository configuration and simplify PR title validation --- .github/scripts/validate_pr.py | 9 ++------- .github/workflows/pre-commit.yml | 6 +++--- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/.github/scripts/validate_pr.py b/.github/scripts/validate_pr.py index 676fc8201..39c988bbf 100644 --- a/.github/scripts/validate_pr.py +++ b/.github/scripts/validate_pr.py @@ -13,7 +13,6 @@ from __future__ import annotations -import argparse import json import os import re @@ -55,12 +54,8 @@ def validate_title(title: str) -> List[str]: if re.match(TITLE_TASK_REGEX, title, flags=re.UNICODE | re.VERBOSE): return [] - example_task_ru = ( - "[TASK] 2-12. Иванов Иван Иванович. 2341-а234. Вычисление суммы элементов вектора." - ) - example_task_en = ( - "[TASK] 2-12. Ivanov Ivan Ivanovich. 2341-a234. Vector elements sum calculation." - ) + example_task_ru = "[TASK] 2-12. Иванов Иван Иванович. 2341-а234. Вычисление суммы элементов вектора." + example_task_en = "[TASK] 2-12. Ivanov Ivan Ivanovich. 2341-a234. Vector elements sum calculation." example_dev = "[DEV] Update docs for lab 2" return [ "Invalid PR title.", diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 6fa2ed6b3..34b25c039 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -26,6 +26,6 @@ jobs: git config --global --add safe.directory '*' - name: Run pre-commit checks run: | - FROM_REF="${{ github.base_ref || 'HEAD~1' }}" - git fetch origin $FROM_REF:$FROM_REF || true - pre-commit run --from-ref $FROM_REF --to-ref HEAD + git remote add upstream https://github.com/learning-process/parallel_programming_course.git || true + git fetch --no-tags upstream master:upstream/master + pre-commit run --from-ref upstream/master --to-ref HEAD From e715ac9c1cd9fa68ff09a3876b6584053bdd8f1c Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sat, 18 Oct 2025 13:19:45 +0200 Subject: [PATCH 4/7] refactor: split PR title validation into separate reusable workflow and enhance policy configuration --- .github/policy/pr_title.json | 10 +++++ .github/scripts/validate_pr.py | 72 +++++++++++++++++++++++----------- .github/workflows/main.yml | 21 +--------- .github/workflows/pr-title.yml | 28 +++++++++++++ 4 files changed, 88 insertions(+), 43 deletions(-) create mode 100644 .github/policy/pr_title.json create mode 100644 .github/workflows/pr-title.yml diff --git a/.github/policy/pr_title.json b/.github/policy/pr_title.json new file mode 100644 index 000000000..d9728923f --- /dev/null +++ b/.github/policy/pr_title.json @@ -0,0 +1,10 @@ +{ + "allow_dev": true, + "task_regex": "^\\[TASK\\]\\s+(?P\\d+)-(?P\\d+)\\.\\s+(?P[А-ЯA-ZЁ][а-яa-zё]+)\\s+(?P[А-ЯA-ZЁ][а-яa-zё]+)\\s+(?P[А-ЯA-ZЁ][а-яa-zё]+)\\.\\s+(?P.+?)\\.\\s+(?P\\S.*)$", + "dev_regex": "^\\[DEV\\]\\s+\\S.*$", + "examples": { + "task_ru": "[TASK] 2-12. Иванов Иван Иванович. 2341-а234. Вычисление суммы элементов вектора.", + "task_en": "[TASK] 2-12. Ivanov Ivan Ivanovich. 2341-a234. Vector elements sum calculation.", + "dev": "[DEV] Update docs for lab 2" + } +} diff --git a/.github/scripts/validate_pr.py b/.github/scripts/validate_pr.py index 39c988bbf..a1ddfdd5a 100644 --- a/.github/scripts/validate_pr.py +++ b/.github/scripts/validate_pr.py @@ -17,27 +17,29 @@ import os import re import sys -from typing import List, Optional - - -TITLE_TASK_REGEX = r""" -^\[TASK\]\s+ -(?P\d+)-(?P\d+)\.\s+ -(?P[А-ЯA-ZЁ][а-яa-zё]+)\s+ -(?P[А-ЯA-ZЁ][а-яa-zё]+)\s+ -(?P[А-ЯA-ZЁ][а-яa-zё]+)\.\s+ -(?P.+?)\.\s+ -(?P\S.*) -$ -""" +from typing import List, Optional, Tuple + -TITLE_DEV_REGEX = r"^\[DEV\]\s+\S.*$" +DEFAULT_TITLE_TASK_REGEX = None # No built-in defaults — must come from file +DEFAULT_TITLE_DEV_REGEX = None # No built-in defaults — must come from file def _trim(s: Optional[str]) -> str: return (s or "").strip() +def _load_title_config() -> Tuple[Optional[dict], List[str]]: + policy_path = os.path.join(".github", "policy", "pr_title.json") + if os.path.exists(policy_path): + try: + with open(policy_path, "r", encoding="utf-8") as f: + return json.load(f), [policy_path] + except Exception: + # Invalid JSON — treat as error (no defaults) + return None, [policy_path] + return None, [policy_path] + + def validate_title(title: str) -> List[str]: """Validate PR title. Returns a list of error messages (empty if valid).""" title = (title or "").strip() @@ -46,23 +48,47 @@ def validate_title(title: str) -> List[str]: "Empty PR title. Use '[TASK] …' for tasks or '[DEV] …' for development.", ] + # Load policy config (required) + cfg, candidates = _load_title_config() + if not cfg: + return [ + "PR title policy config not found or invalid.", + f"Expected one of: {', '.join(candidates)}", + ] + + # Validate required keys (no built-in defaults) + errors: List[str] = [] + task_regex = cfg.get("task_regex") + dev_regex = cfg.get("dev_regex") + allow_dev = cfg.get("allow_dev") + examples = cfg.get("examples") if isinstance(cfg.get("examples"), dict) else {} + + if not isinstance(task_regex, str) or not task_regex.strip(): + errors.append("Missing or empty 'task_regex' in policy config.") + if not isinstance(dev_regex, str) or not dev_regex.strip(): + errors.append("Missing or empty 'dev_regex' in policy config.") + if not isinstance(allow_dev, bool): + errors.append("Missing or non-boolean 'allow_dev' in policy config.") + if errors: + return errors + # Accept development titles with a simple rule - if re.match(TITLE_DEV_REGEX, title, flags=re.UNICODE): + if allow_dev and re.match(dev_regex, title, flags=re.UNICODE | re.VERBOSE): return [] # Accept strict course task titles - if re.match(TITLE_TASK_REGEX, title, flags=re.UNICODE | re.VERBOSE): + if re.match(task_regex, title, flags=re.UNICODE | re.VERBOSE): return [] - example_task_ru = "[TASK] 2-12. Иванов Иван Иванович. 2341-а234. Вычисление суммы элементов вектора." - example_task_en = "[TASK] 2-12. Ivanov Ivan Ivanovich. 2341-a234. Vector elements sum calculation." - example_dev = "[DEV] Update docs for lab 2" + example_task_ru = examples.get("task_ru") + example_task_en = examples.get("task_en") + example_dev = examples.get("dev") return [ "Invalid PR title.", - "Allowed formats:", - f"- Task: {example_task_ru}", - f"- Task: {example_task_en}", - f"- Dev: {example_dev}", + "Allowed formats (see policy config):", + *( [f"- Task (RU): {example_task_ru}"] if example_task_ru else [] ), + *( [f"- Task (EN): {example_task_en}"] if example_task_en else [] ), + *( [f"- Dev: {example_dev}"] if example_dev else [] ), ] diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index eaca105bc..c0016e9b1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,26 +20,7 @@ concurrency: jobs: pr_title: - name: PR Title Gate - runs-on: ubuntu-latest - steps: - - name: Non-PR event — skip title validation - if: ${{ github.event_name != 'pull_request' }} - run: echo "Not a PR event; skipping title check" - - - name: Checkout - if: ${{ github.event_name == 'pull_request' }} - uses: actions/checkout@v4 - - - name: Set up Python - if: ${{ github.event_name == 'pull_request' }} - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Validate PR title - if: ${{ github.event_name == 'pull_request' }} - run: python .github/scripts/validate_pr.py + uses: ./.github/workflows/pr-title.yml pre-commit: if: ${{ github.event_name != 'pull_request' || github.event.action != 'edited' || github.event.changes.title }} diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 000000000..3bc02cdc3 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,28 @@ +name: PR Title Gate + +on: + workflow_call: + +jobs: + pr_title: + name: Validate PR Title + runs-on: ubuntu-latest + steps: + - name: Skip on non-PR events + if: ${{ github.event_name != 'pull_request' }} + run: echo "Not a PR event; skipping title check" + + - name: Checkout + if: ${{ github.event_name == 'pull_request' }} + uses: actions/checkout@v4 + + - name: Set up Python + if: ${{ github.event_name == 'pull_request' }} + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Validate PR title + if: ${{ github.event_name == 'pull_request' }} + run: python .github/scripts/validate_pr.py + From e8e2757a16208c05dc4aec44f4f2cefe58b6f35e Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sat, 18 Oct 2025 13:26:39 +0200 Subject: [PATCH 5/7] style: fix formatting in PR title validation script and workflow files --- .github/scripts/validate_pr.py | 8 ++++---- .github/workflows/pr-title.yml | 6 ------ 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/.github/scripts/validate_pr.py b/.github/scripts/validate_pr.py index a1ddfdd5a..c895aa46d 100644 --- a/.github/scripts/validate_pr.py +++ b/.github/scripts/validate_pr.py @@ -21,7 +21,7 @@ DEFAULT_TITLE_TASK_REGEX = None # No built-in defaults — must come from file -DEFAULT_TITLE_DEV_REGEX = None # No built-in defaults — must come from file +DEFAULT_TITLE_DEV_REGEX = None # No built-in defaults — must come from file def _trim(s: Optional[str]) -> str: @@ -86,9 +86,9 @@ def validate_title(title: str) -> List[str]: return [ "Invalid PR title.", "Allowed formats (see policy config):", - *( [f"- Task (RU): {example_task_ru}"] if example_task_ru else [] ), - *( [f"- Task (EN): {example_task_en}"] if example_task_en else [] ), - *( [f"- Dev: {example_dev}"] if example_dev else [] ), + *([f"- Task (RU): {example_task_ru}"] if example_task_ru else []), + *([f"- Task (EN): {example_task_en}"] if example_task_en else []), + *([f"- Dev: {example_dev}"] if example_dev else []), ] diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index 3bc02cdc3..3fc91f237 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -1,8 +1,6 @@ name: PR Title Gate - on: workflow_call: - jobs: pr_title: name: Validate PR Title @@ -11,18 +9,14 @@ jobs: - name: Skip on non-PR events if: ${{ github.event_name != 'pull_request' }} run: echo "Not a PR event; skipping title check" - - name: Checkout if: ${{ github.event_name == 'pull_request' }} uses: actions/checkout@v4 - - name: Set up Python if: ${{ github.event_name == 'pull_request' }} uses: actions/setup-python@v5 with: python-version: '3.11' - - name: Validate PR title if: ${{ github.event_name == 'pull_request' }} run: python .github/scripts/validate_pr.py - From f30f9a5c0a45f784b69a6f1902520bb693bdd9f7 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sat, 18 Oct 2025 13:40:14 +0200 Subject: [PATCH 6/7] add tests for PR title validation and enhance CI workflow integration --- .../task_submission_en.md | 2 +- .../task_submission_ru.md | 2 +- .github/policy/pr_title.json | 6 +- .github/pull_request_template.md | 2 +- .github/workflows/main.yml | 7 ++ .github/workflows/pr-title-tests.yml | 15 ++++ .../tests/pr_title/test_main_integration.py | 51 +++++++++++ scripts/tests/pr_title/test_validate_title.py | 89 +++++++++++++++++++ 8 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/pr-title-tests.yml create mode 100644 scripts/tests/pr_title/test_main_integration.py create mode 100644 scripts/tests/pr_title/test_validate_title.py diff --git a/.github/PULL_REQUEST_TEMPLATE/task_submission_en.md b/.github/PULL_REQUEST_TEMPLATE/task_submission_en.md index 3a0206f9e..f839a74a1 100644 --- a/.github/PULL_REQUEST_TEMPLATE/task_submission_en.md +++ b/.github/PULL_REQUEST_TEMPLATE/task_submission_en.md @@ -1,5 +1,5 @@ PR Title (CI enforced): -- Tasks: `[TASK] -. . . .` +- Tasks: `[TASK] -. . . . .` - Development: `[DEV] ` diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c0016e9b1..f41995f01 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,10 +22,17 @@ jobs: pr_title: uses: ./.github/workflows/pr-title.yml + pr_title_tests: + needs: + - pr_title + if: ${{ github.event_name != 'pull_request' || github.event.action != 'edited' || github.event.changes.title }} + uses: ./.github/workflows/pr-title-tests.yml + pre-commit: if: ${{ github.event_name != 'pull_request' || github.event.action != 'edited' || github.event.changes.title }} needs: - pr_title + - pr_title_tests uses: ./.github/workflows/pre-commit.yml ubuntu: if: ${{ github.event_name != 'pull_request' || github.event.action != 'edited' || github.event.changes.title }} diff --git a/.github/workflows/pr-title-tests.yml b/.github/workflows/pr-title-tests.yml new file mode 100644 index 000000000..ad85a8999 --- /dev/null +++ b/.github/workflows/pr-title-tests.yml @@ -0,0 +1,15 @@ +name: PR Title Tests +on: + workflow_call: +jobs: + unit: + name: Validator Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Run unit tests + run: | + python -m unittest -v scripts/tests/pr_title/test_validate_title.py scripts/tests/pr_title/test_main_integration.py diff --git a/scripts/tests/pr_title/test_main_integration.py b/scripts/tests/pr_title/test_main_integration.py new file mode 100644 index 000000000..40787e208 --- /dev/null +++ b/scripts/tests/pr_title/test_main_integration.py @@ -0,0 +1,51 @@ +import json +import os +import tempfile +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +from unittest import TestCase + + +REPO_ROOT = Path.cwd() +VALIDATOR_PATH = REPO_ROOT / ".github/scripts/validate_pr.py" + + +def load_validator(): + spec = spec_from_file_location("validate_pr", str(VALIDATOR_PATH)) + assert spec and spec.loader + mod = module_from_spec(spec) + spec.loader.exec_module(mod) # type: ignore[attr-defined] + return mod + + +class TestMainIntegration(TestCase): + def setUp(self) -> None: + self.validator = load_validator() + self._old_event_path = os.environ.get("GITHUB_EVENT_PATH") + + def tearDown(self) -> None: + if self._old_event_path is None: + os.environ.pop("GITHUB_EVENT_PATH", None) + else: + os.environ["GITHUB_EVENT_PATH"] = self._old_event_path + + def _with_event(self, title: str) -> str: + payload = {"pull_request": {"title": title}} + fd, path = tempfile.mkstemp(prefix="gh-event-", suffix=".json") + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(payload, f) + os.environ["GITHUB_EVENT_PATH"] = path + return path + + def test_main_ok(self) -> None: + self._with_event( + "[TASK] 2-12. Иванов Иван Иванович. 2341-а234. OMP. Вычисление суммы элементов вектора." + ) + rc = self.validator.main() + self.assertEqual(rc, 0) + + def test_main_fail(self) -> None: + self._with_event("Bad title format") + rc = self.validator.main() + self.assertEqual(rc, 1) + diff --git a/scripts/tests/pr_title/test_validate_title.py b/scripts/tests/pr_title/test_validate_title.py new file mode 100644 index 000000000..e55c43084 --- /dev/null +++ b/scripts/tests/pr_title/test_validate_title.py @@ -0,0 +1,89 @@ +import json +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +from typing import Tuple +from unittest import TestCase, mock + + +REPO_ROOT = Path.cwd() +VALIDATOR_PATH = REPO_ROOT / ".github/scripts/validate_pr.py" +POLICY_PATH = REPO_ROOT / ".github/policy/pr_title.json" + + +def load_validator(): + spec = spec_from_file_location("validate_pr", str(VALIDATOR_PATH)) + assert spec and spec.loader + mod = module_from_spec(spec) + spec.loader.exec_module(mod) # type: ignore[attr-defined] + return mod + + +class TestValidateTitle(TestCase): + @classmethod + def setUpClass(cls) -> None: + assert VALIDATOR_PATH.exists(), "Validator script not found" + assert POLICY_PATH.exists(), "Policy file not found" + cls.validator = load_validator() + with open(POLICY_PATH, "r", encoding="utf-8") as f: + cls.policy = json.load(f) + + def test_valid_task_ru(self) -> None: + title = ( + "[TASK] 2-12. Иванов Иван Иванович. 2341-а234. OMP. Вычисление суммы элементов вектора." + ) + errs = self.validator.validate_title(title) + self.assertEqual(errs, []) + + def test_valid_task_en(self) -> None: + title = ( + "[TASK] 3-4. Ivanov Ivan Ivanovich. 2341-a234. MPI. Vector elements sum calculation." + ) + errs = self.validator.validate_title(title) + self.assertEqual(errs, []) + + def test_invalid_task_number_out_of_range(self) -> None: + title = ( + "[TASK] 6-1. Иванов Иван Иванович. 2341-а234. SEQ. Вычисление суммы элементов вектора." + ) + errs = self.validator.validate_title(title) + self.assertTrue(errs, "Expected errors for out-of-range task number") + + def test_valid_dev_when_allowed(self) -> None: + title = "[DEV] Update docs for lab 2" + errs = self.validator.validate_title(title) + self.assertEqual(errs, []) + + def test_dev_disallowed_by_policy(self) -> None: + cfg = dict(self.policy) + cfg["allow_dev"] = False + + def fake_load_title_config() -> Tuple[dict, list]: + return cfg, [str(POLICY_PATH)] + + with mock.patch.object(self.validator, "_load_title_config", fake_load_title_config): + errs = self.validator.validate_title("[DEV] Working WIP") + self.assertTrue(errs, "Expected errors when allow_dev is False") + + def test_missing_policy_file(self) -> None: + def fake_load_title_config_missing(): + return None, [str(POLICY_PATH)] + + with mock.patch.object(self.validator, "_load_title_config", fake_load_title_config_missing): + errs = self.validator.validate_title("[TASK] 2-1. X Y Z. G. OMP. Title.") + self.assertTrue(errs, "Expected error for missing policy config") + self.assertIn("policy config not found", " ".join(errs).lower()) + + def test_missing_technology_block(self) -> None: + title = ( + "[TASK] 2-12. Иванов Иван Иванович. 2341-а234. Вычисление суммы элементов вектора." + ) + errs = self.validator.validate_title(title) + self.assertTrue(errs, "Expected error when technology block is missing") + + def test_invalid_technology_token(self) -> None: + title = ( + "[TASK] 2-12. Иванов Иван Иванович. 2341-а234. CUDA. Вычисление суммы элементов вектора." + ) + errs = self.validator.validate_title(title) + self.assertTrue(errs, "Expected error for unsupported technology token") + From 048837d7cd9cc8961f728e09984e16f389b4dd6f Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sat, 18 Oct 2025 13:50:23 +0200 Subject: [PATCH 7/7] CI: fix scoreboard perf artifact unzip; yamllint line length; ruff-format tests --- .github/workflows/pr-title-tests.yml | 4 ++- .../tests/pr_title/test_main_integration.py | 1 - scripts/tests/pr_title/test_validate_title.py | 29 +++++++------------ 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/.github/workflows/pr-title-tests.yml b/.github/workflows/pr-title-tests.yml index ad85a8999..98d75ea93 100644 --- a/.github/workflows/pr-title-tests.yml +++ b/.github/workflows/pr-title-tests.yml @@ -12,4 +12,6 @@ jobs: python-version: '3.11' - name: Run unit tests run: | - python -m unittest -v scripts/tests/pr_title/test_validate_title.py scripts/tests/pr_title/test_main_integration.py + python -m unittest -v \ + scripts/tests/pr_title/test_validate_title.py \ + scripts/tests/pr_title/test_main_integration.py diff --git a/scripts/tests/pr_title/test_main_integration.py b/scripts/tests/pr_title/test_main_integration.py index 40787e208..5f41374d8 100644 --- a/scripts/tests/pr_title/test_main_integration.py +++ b/scripts/tests/pr_title/test_main_integration.py @@ -48,4 +48,3 @@ def test_main_fail(self) -> None: self._with_event("Bad title format") rc = self.validator.main() self.assertEqual(rc, 1) - diff --git a/scripts/tests/pr_title/test_validate_title.py b/scripts/tests/pr_title/test_validate_title.py index e55c43084..4d3271e4a 100644 --- a/scripts/tests/pr_title/test_validate_title.py +++ b/scripts/tests/pr_title/test_validate_title.py @@ -28,23 +28,17 @@ def setUpClass(cls) -> None: cls.policy = json.load(f) def test_valid_task_ru(self) -> None: - title = ( - "[TASK] 2-12. Иванов Иван Иванович. 2341-а234. OMP. Вычисление суммы элементов вектора." - ) + title = "[TASK] 2-12. Иванов Иван Иванович. 2341-а234. OMP. Вычисление суммы элементов вектора." errs = self.validator.validate_title(title) self.assertEqual(errs, []) def test_valid_task_en(self) -> None: - title = ( - "[TASK] 3-4. Ivanov Ivan Ivanovich. 2341-a234. MPI. Vector elements sum calculation." - ) + title = "[TASK] 3-4. Ivanov Ivan Ivanovich. 2341-a234. MPI. Vector elements sum calculation." errs = self.validator.validate_title(title) self.assertEqual(errs, []) def test_invalid_task_number_out_of_range(self) -> None: - title = ( - "[TASK] 6-1. Иванов Иван Иванович. 2341-а234. SEQ. Вычисление суммы элементов вектора." - ) + title = "[TASK] 6-1. Иванов Иван Иванович. 2341-а234. SEQ. Вычисление суммы элементов вектора." errs = self.validator.validate_title(title) self.assertTrue(errs, "Expected errors for out-of-range task number") @@ -60,7 +54,9 @@ def test_dev_disallowed_by_policy(self) -> None: def fake_load_title_config() -> Tuple[dict, list]: return cfg, [str(POLICY_PATH)] - with mock.patch.object(self.validator, "_load_title_config", fake_load_title_config): + with mock.patch.object( + self.validator, "_load_title_config", fake_load_title_config + ): errs = self.validator.validate_title("[DEV] Working WIP") self.assertTrue(errs, "Expected errors when allow_dev is False") @@ -68,22 +64,19 @@ def test_missing_policy_file(self) -> None: def fake_load_title_config_missing(): return None, [str(POLICY_PATH)] - with mock.patch.object(self.validator, "_load_title_config", fake_load_title_config_missing): + with mock.patch.object( + self.validator, "_load_title_config", fake_load_title_config_missing + ): errs = self.validator.validate_title("[TASK] 2-1. X Y Z. G. OMP. Title.") self.assertTrue(errs, "Expected error for missing policy config") self.assertIn("policy config not found", " ".join(errs).lower()) def test_missing_technology_block(self) -> None: - title = ( - "[TASK] 2-12. Иванов Иван Иванович. 2341-а234. Вычисление суммы элементов вектора." - ) + title = "[TASK] 2-12. Иванов Иван Иванович. 2341-а234. Вычисление суммы элементов вектора." errs = self.validator.validate_title(title) self.assertTrue(errs, "Expected error when technology block is missing") def test_invalid_technology_token(self) -> None: - title = ( - "[TASK] 2-12. Иванов Иван Иванович. 2341-а234. CUDA. Вычисление суммы элементов вектора." - ) + title = "[TASK] 2-12. Иванов Иван Иванович. 2341-а234. CUDA. Вычисление суммы элементов вектора." errs = self.validator.validate_title(title) self.assertTrue(errs, "Expected error for unsupported technology token") -