Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .git-management.json
Original file line number Diff line number Diff line change
@@ -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"
}
19 changes: 19 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!-- multica
task_id: <必填任务标识>
run_id: <必填运行标识>
change_id: <可选变更标识>
-->

<!-- PR 标题必须包含与上方 task_id 完全相同的完整任务号。 -->

## 目标

## 变更范围

## 验收标准

- [ ]

## 测试结果

## 风险和回滚
83 changes: 83 additions & 0 deletions .github/scripts/test_validate_pr.py
Original file line number Diff line number Diff line change
@@ -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": "<!-- multica\ntask_id: MT-1\nrun_id: RUN-1\n-->",
"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": "<!-- multica\ntask_id: TML-741\nrun_id: RUN-1\n-->",
"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": "<!-- multica\ntask_id: TML-741\nrun_id: RUN-1\n-->",
"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": "<!-- multica\ntask_id: TML-741\nrun_id: RUN-1\n-->",
"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()
82 changes: 82 additions & 0 deletions .github/scripts/validate_pr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import argparse
import json
import re
from pathlib import Path


def parse_metadata(body):
block = re.search(r"<!--\s*multica(.*?)-->", 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 '<empty>'}"
)

if not re.fullmatch(policy["branch_pattern"], head_ref):
failures.append(f"branch name does not match policy: {head_ref or '<empty>'}")

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"(?<![A-Za-z0-9]){re.escape(task_id)}(?![A-Za-z0-9])"
if not re.search(exact_task_id, title):
failures.append(f"PR title must contain task_id: {task_id}")

return failures


def main():
parser = argparse.ArgumentParser()
parser.add_argument("--event", required=True)
parser.add_argument("--policy", required=True)
args = parser.parse_args()

event = json.loads(Path(args.event).read_text(encoding="utf-8"))
policy = json.loads(Path(args.policy).read_text(encoding="utf-8"))
failures = validate(event, policy)

if failures:
for failure in failures:
print(f"FAILED: {failure}")
raise SystemExit(1)

print("Git governance policy passed")


if __name__ == "__main__":
main()
69 changes: 69 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: ci

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
ci:
name: ci
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Go
if: hashFiles('go.mod') != ''
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Setup Node
if: hashFiles('package.json') != '' && hashFiles('bun.lockb') == '' && hashFiles('bun.lock') == ''
uses: actions/setup-node@v4
with:
node-version: lts/*

- name: Setup Bun
if: hashFiles('bun.lockb') != '' || hashFiles('bun.lock') != ''
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Run project checks
shell: bash
run: |
set -euo pipefail

if [[ -f Makefile ]] && grep -qE '^ci:' Makefile; then
make ci
exit 0
fi

if [[ -f go.mod ]]; then
go test ./...
exit 0
fi

if [[ -f package.json ]]; then
if [[ -f bun.lockb || -f bun.lock ]]; then
bun install --frozen-lockfile
if node -e 'const p=require("./package.json"); process.exit(p.scripts && p.scripts.build ? 0 : 1)'; then
bun run build
fi
else
npm ci
if node -e 'const p=require("./package.json"); process.exit(p.scripts && p.scripts.build ? 0 : 1)'; then
npm run build
fi
fi
exit 0
fi

echo "No standard project command detected. Add a Makefile ci target or update this workflow."
30 changes: 30 additions & 0 deletions .github/workflows/pr-policy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: git-governance

on:
pull_request:
types: [opened, edited, synchronize, reopened]

permissions:
contents: read
pull-requests: read

jobs:
governance:
name: git-governance
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main

- name: Test policy validator
run: python -m unittest discover -s .github/scripts -p "test_*.py"

- name: Validate pull request
shell: bash
run: |
python .github/scripts/validate_pr.py \
--event "$GITHUB_EVENT_PATH" \
--policy .git-management.json
Loading