diff --git a/.git-management.json b/.git-management.json new file mode 100644 index 0000000..e13386a --- /dev/null +++ b/.git-management.json @@ -0,0 +1,16 @@ +{ + "policy_version": 3, + "default_branch": "main", + "branch_pattern": "^(ai|feat|fix|refactor|chore)/.+$", + "required_pr_metadata": ["task_id", "run_id"], + "task_id_pattern": "^[A-Z][A-Z0-9]*-[0-9]+$", + "require_task_id_in_pr_title": true, + "required_checks": ["ci", "git-governance"], + "review_policy": { + "enabled": false, + "required_approvals": 0, + "require_resolved_conversations": false + }, + "require_up_to_date_branch": true, + "merge_method": "squash" +} diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ee7fe2c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,19 @@ + + + + +## 目标 + +## 变更范围 + +## 验收标准 + +- [ ] + +## 测试结果 + +## 风险和回滚 diff --git a/.github/scripts/test_validate_pr.py b/.github/scripts/test_validate_pr.py new file mode 100644 index 0000000..c2a2fe3 --- /dev/null +++ b/.github/scripts/test_validate_pr.py @@ -0,0 +1,83 @@ +import importlib.util +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("validate_pr.py") +SPEC = importlib.util.spec_from_file_location("validate_pr", MODULE_PATH) +VALIDATE_PR = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(VALIDATE_PR) + +POLICY = { + "default_branch": "main", + "branch_pattern": r"^(ai|feat|fix|refactor|chore)/.+$", + "required_pr_metadata": ["task_id", "run_id"], + "task_id_pattern": r"^[A-Z][A-Z0-9]*-[0-9]+$", + "require_task_id_in_pr_title": True, +} + + +class ValidatePullRequestTest(unittest.TestCase): + def test_accepts_valid_pull_request(self): + event = { + "pull_request": { + "title": "feat(example): MT-1 add example", + "body": "", + "head": {"ref": "feat/example"}, + "base": {"ref": "main"}, + } + } + self.assertEqual([], VALIDATE_PR.validate(event, POLICY)) + + def test_rejects_missing_metadata_and_invalid_branch(self): + event = { + "pull_request": { + "body": "", + "head": {"ref": "random"}, + "base": {"ref": "main"}, + } + } + failures = VALIDATE_PR.validate(event, POLICY) + self.assertIn("branch name does not match policy: random", failures) + self.assertIn("missing PR metadata: task_id", failures) + self.assertIn("missing PR metadata: run_id", failures) + + def test_rejects_title_without_task_id(self): + event = { + "pull_request": { + "title": "feat(home): add team homepage", + "body": "", + "head": {"ref": "feat/home"}, + "base": {"ref": "main"}, + } + } + failures = VALIDATE_PR.validate(event, POLICY) + self.assertIn("PR title must contain task_id: TML-741", failures) + + def test_rejects_title_with_different_task_id(self): + event = { + "pull_request": { + "title": "feat(home): TML-742 add team homepage", + "body": "", + "head": {"ref": "feat/home"}, + "base": {"ref": "main"}, + } + } + failures = VALIDATE_PR.validate(event, POLICY) + self.assertIn("PR title must contain task_id: TML-741", failures) + + def test_rejects_task_id_prefix_match(self): + event = { + "pull_request": { + "title": "feat(home): TML-7410 add team homepage", + "body": "", + "head": {"ref": "feat/home"}, + "base": {"ref": "main"}, + } + } + failures = VALIDATE_PR.validate(event, POLICY) + self.assertIn("PR title must contain task_id: TML-741", failures) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/validate_pr.py b/.github/scripts/validate_pr.py new file mode 100644 index 0000000..9252e88 --- /dev/null +++ b/.github/scripts/validate_pr.py @@ -0,0 +1,82 @@ +import argparse +import json +import re +from pathlib import Path + + +def parse_metadata(body): + block = re.search(r"", body or "", re.S | re.I) + if not block: + raise ValueError("PR must contain a multica metadata block") + + fields = {} + for line in block.group(1).splitlines(): + if ":" in line: + key, value = line.split(":", 1) + fields[key.strip()] = value.strip() + return fields + + +def validate(event, policy): + pull_request = event.get("pull_request") or {} + title = pull_request.get("title") or "" + body = pull_request.get("body") or "" + head_ref = pull_request.get("head", {}).get("ref") or "" + base_ref = pull_request.get("base", {}).get("ref") or "" + failures = [] + + if base_ref != policy["default_branch"]: + failures.append( + f"target branch must be {policy['default_branch']}, got {base_ref or ''}" + ) + + if not re.fullmatch(policy["branch_pattern"], head_ref): + failures.append(f"branch name does not match policy: {head_ref or ''}") + + try: + metadata = parse_metadata(body) + except ValueError as exc: + failures.append(str(exc)) + metadata = {} + + for field in policy.get("required_pr_metadata", []): + value = metadata.get(field, "") + if not value or value.startswith("<"): + failures.append(f"missing PR metadata: {field}") + + task_id = metadata.get("task_id", "") + task_id_is_present = bool(task_id) and not task_id.startswith("<") + task_id_is_valid = task_id_is_present + task_id_pattern = policy.get("task_id_pattern") + if task_id_is_present and task_id_pattern and not re.fullmatch(task_id_pattern, task_id): + failures.append(f"invalid PR metadata: task_id: {task_id}") + task_id_is_valid = False + + if policy.get("require_task_id_in_pr_title") and task_id_is_valid: + exact_task_id = rf"(?