diff --git a/.github/PULL_REQUEST_TEMPLATE/task_submission_en.md b/.github/PULL_REQUEST_TEMPLATE/task_submission_en.md index 59ecfe046..f839a74a1 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..c895aa46d --- /dev/null +++ b/.github/scripts/validate_pr.py @@ -0,0 +1,136 @@ +#!/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 json +import os +import re +import sys +from typing import List, Optional, Tuple + + +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() + if not title: + return [ + "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 allow_dev and re.match(dev_regex, title, flags=re.UNICODE | re.VERBOSE): + return [] + + # Accept strict course task titles + if re.match(task_regex, title, flags=re.UNICODE | re.VERBOSE): + return [] + + 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 (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 []), + ] + + +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..f41995f01 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 * * *' @@ -16,21 +19,38 @@ concurrency: !startsWith(github.ref, 'refs/heads/gh-readonly-queue') }} 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 }} 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 @@ -38,6 +58,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 diff --git a/.github/workflows/pr-title-tests.yml b/.github/workflows/pr-title-tests.yml new file mode 100644 index 000000000..98d75ea93 --- /dev/null +++ b/.github/workflows/pr-title-tests.yml @@ -0,0 +1,17 @@ +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/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 000000000..3fc91f237 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,22 @@ +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 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 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..5f41374d8 --- /dev/null +++ b/scripts/tests/pr_title/test_main_integration.py @@ -0,0 +1,50 @@ +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..4d3271e4a --- /dev/null +++ b/scripts/tests/pr_title/test_validate_title.py @@ -0,0 +1,82 @@ +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")