Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE/task_submission_en.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
PR Title (CI enforced):
- Tasks: `[TASK] <Task>-<Variant>. <Last Name> <First Name> <Middle Name>. <Group>. <Technology: SEQ|MPI|ALL|OMP|STL|TBB>. <Task name>.`
- Development: `[DEV] <any descriptive title>`

<!--
Pull request title requirement:

Expand Down
4 changes: 4 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE/task_submission_ru.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
Формат заголовка PR (CI):
- Задачи: `[TASK] <Task>-<Variant>. <Фамилия> <Имя> <Отчество>. <Группа>. <Технология: SEQ|MPI|ALL|OMP|STL|TBB>. <Название задачи>.`
- Разработка: `[DEV] <произвольное осмысленное название>`

<!--
Требования к названию pull request:

Expand Down
10 changes: 10 additions & 0 deletions .github/policy/pr_title.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"allow_dev": true,
"task_regex": "^\\[TASK\\]\\s+(?P<task>[1-5])-(?P<variant>\\d+)\\.\\s+(?P<lastname>[А-ЯA-ZЁ][а-яa-zё]+)\\s+(?P<firstname>[А-ЯA-ZЁ][а-яa-zё]+)\\s+(?P<middlename>[А-ЯA-ZЁ][а-яa-zё]+)\\.\\s+(?P<group>.+?)\\.\\s+(?P<technology>SEQ|MPI|ALL|OMP|STL|TBB)\\.\\s+(?P<taskname>\\S.*)$",
"dev_regex": "^\\[DEV\\]\\s+\\S.*$",
"examples": {
"task_ru": "[TASK] 2-12. Иванов Иван Иванович. 2341-а234. OMP. Вычисление суммы элементов вектора.",
"task_en": "[TASK] 2-12. Ivanov Ivan Ivanovich. 2341-a234. OMP. Vector elements sum calculation.",
"dev": "[DEV] Update docs for lab 2"
}
}
4 changes: 4 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
PR Title (CI enforced):
- Tasks: `[TASK] <Task>-<Variant>. <Last Name> <First Name> <Middle Name>. <Group>. <Technology: SEQ|MPI|ALL|OMP|STL|TBB>. <Task name>.`
- Development: `[DEV] <any descriptive title>`

<!-- Solution for PR template choice: https://stackoverflow.com/a/75030350/24543008 -->

Please go to the `Preview` tab and select the appropriate template:
Expand Down
136 changes: 136 additions & 0 deletions .github/scripts/validate_pr.py
Original file line number Diff line number Diff line change
@@ -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] <Task>-<Variant>. <Last> <First> <Middle>. <Group>. <Task name>
- Or a development title with prefix '[DEV]' followed by any non-empty text
Pattern: [DEV] <any text>
"""

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())
21 changes: 21 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ name: Build application

on:
push:
branches:
- master
pull_request:
types: [opened, edited, synchronize, reopened]
merge_group:
schedule:
- cron: '0 0 * * *'
Expand All @@ -16,28 +19,46 @@ 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
- windows
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
17 changes: 17 additions & 0 deletions .github/workflows/pr-title-tests.yml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions .github/workflows/pr-title.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
50 changes: 50 additions & 0 deletions scripts/tests/pr_title/test_main_integration.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading