diff --git a/.gitea/workflows/auto-merge.yml b/.gitea/workflows/auto-merge.yml new file mode 100644 index 000000000..bc0db30ee --- /dev/null +++ b/.gitea/workflows/auto-merge.yml @@ -0,0 +1,145 @@ +name: Auto Merge CI PRs + +on: + pull_request: + types: [synchronize, opened, ready_for_review, review_requested] + +jobs: + auto-merge: + name: Auto Merge on CI Green + Approved + runs-on: ci-l1 + if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop' + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Auto merge when CI passes and approved + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + set -eu + + echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}" + echo + + # 只合develop分支 + if [ "$BASE_REF" != "develop" ]; then + echo "Skip: 目标分支不是develop" + exit 0 + fi + + # 判断是否纯前端改动 + FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" \ + | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]") + TOTAL=$(echo "$FILES" | grep -cv '^$' || true) + FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true) + BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT)) + echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})" + + if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then + CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)") + echo "纯前端改动,只检查Frontend Lint" + else + CONTEXTS=( + "CI/CD Pipeline / Validate Code Quality And Tests (pull_request)" + "CI/CD Pipeline / Unit Tests (pull_request)" + "CI/CD Pipeline / Frontend Lint (pull_request)" + "CI/CD Pipeline / Integration Tests (pull_request)" + ) + echo "检查全部四门禁" + fi + echo + + # 轮询等待,最多30分钟(180次x10秒) + for attempt in $(seq 1 180); do + ALL_SUCCESS=true + ANY_FAILED=false + + echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---" + + # 检查CI状态 + for ctx in "${CONTEXTS[@]}"; do + STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$GITHUB_SHA" "$ctx") + echo " CI: ${ctx##*/}: $STATE" + if [ "$STATE" != "success" ]; then + ALL_SUCCESS=false + fi + if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then + ANY_FAILED=true + fi + done + + # 检查审批状态 + APPROVAL_RESULT=$(python3 scripts/check_pr_approval.py "$MERGE_TOKEN" "$GITHUB_REPOSITORY" "$PR_NUMBER" 1) + echo " 审批: $APPROVAL_RESULT" + HAS_APPROVAL=false + if echo "$APPROVAL_RESULT" | grep -q '^approved'; then + HAS_APPROVAL=true + fi + + # 全部满足 → 合并 + if [ "$ALL_SUCCESS" = "true" ] && [ "$HAS_APPROVAL" = "true" ]; then + echo + echo "CI全绿 + 审批通过,执行自动合并" + + # 幂等检查:PR是否还是open + PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ + | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))") + + if [ "$PR_STATE" != "open" ]; then + echo "PR状态为 ${PR_STATE},无需合并" + exit 0 + fi + + # 执行squash merge + HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \ + -X POST \ + -H "Authorization: token ${MERGE_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge") + + echo "合并API HTTP状态: $HTTP_CODE" + + if [ "$HTTP_CODE" = "200" ]; then + echo "自动合并成功" + exit 0 + elif [ "$HTTP_CODE" = "405" ]; then + echo "合并失败(405),可能有冲突或门禁未通过" + curl -s -X POST \ + -H "Authorization: token ${MERGE_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"body": "Auto merge failed: PR may have conflicts or unresolved checks. Please review manually."}' \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true + exit 0 + else + echo "自动合并失败 (HTTP $HTTP_CODE)" + cat /tmp/merge_resp.json 2>/dev/null || true + curl -s -X POST \ + -H "Authorization: token ${MERGE_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true + exit 1 + fi + fi + + if [ "$ANY_FAILED" = "true" ]; then + echo + echo "CI有失败项,不自动合并" + exit 0 + fi + + sleep 10 + done + + echo + echo "等待超时(30分钟)" + exit 0 diff --git a/scripts/check_pr_approval.py b/scripts/check_pr_approval.py new file mode 100644 index 000000000..5f08a4c72 --- /dev/null +++ b/scripts/check_pr_approval.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""检查PR是否有至少N个APPROVED审批。 + +用法: python3 check_pr_approval.py +返回: 打印 "approved" 或 "pending" +""" + +import json +import sys +import urllib.request + + +def main(): + if len(sys.argv) != 5: + print("pending") + return + + token = sys.argv[1] + repo = sys.argv[2] + pr_number = sys.argv[3] + min_approval = int(sys.argv[4]) + + api_url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/pulls/{pr_number}/reviews" + req = urllib.request.Request(api_url, headers={"Authorization": f"token {token}"}) + + try: + with urllib.request.urlopen(req, timeout=30) as resp: + reviews = json.loads(resp.read().decode()) + except Exception: + print("pending") + return + + # 统计APPROVED的人数(去重,同一人多次审批只算一次) + approvers = set() + for r in reviews: + if r.get("state") == "APPROVED": + approvers.add(r.get("user", {}).get("login", "")) + + if len(approvers) >= min_approval: + print(f"approved ({len(approvers)})") + else: + print(f"pending ({len(approvers)})") + + +if __name__ == "__main__": + main()