From 442d79fcb51624e9aaf62612d99c8f2a9ac54911 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:29:21 +0800 Subject: [PATCH 1/7] Retire legacy AIAudit workflow paths Co-Authored-By: Codex --- .github/workflows/auto_merge_codex_pr.yml | 194 -------- .github/workflows/codex_pr_feedback.yml | 542 ---------------------- 2 files changed, 736 deletions(-) delete mode 100644 .github/workflows/auto_merge_codex_pr.yml delete mode 100644 .github/workflows/codex_pr_feedback.yml diff --git a/.github/workflows/auto_merge_codex_pr.yml b/.github/workflows/auto_merge_codex_pr.yml deleted file mode 100644 index 8feb378..0000000 --- a/.github/workflows/auto_merge_codex_pr.yml +++ /dev/null @@ -1,194 +0,0 @@ -name: Auto Merge Codex Remediation PR - -"on": - workflow_run: - workflows: ["CI"] - types: [completed] - -jobs: - auto-merge: - if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_repository.full_name == github.repository && startsWith(github.event.workflow_run.head_branch, 'codex/monthly-review-issue-') && contains(fromJSON('["true","True","TRUE"]'), vars.CODEX_AUDIT_AUTO_MERGE) - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: write - issues: write - pull-requests: write - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ github.event.repository.default_branch }} - - - name: Resolve Codex remediation PR - id: pr - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - mkdir -p data/output/codex_auto_merge - branch="${{ github.event.workflow_run.head_branch }}" - pr_payload=$(gh pr list --repo "${{ github.repository }}" --state open --head "${branch}" --json number,headRefOid,headRepository,isCrossRepository \ - --jq '[.[] | select(.isCrossRepository == false and .headRepository.nameWithOwner == "${{ github.repository }}")][0] // {}') - pr_number=$(python3 -c 'import json,sys; print(json.load(sys.stdin).get("number", ""))' <<<"${pr_payload}") - if [ -z "${pr_number}" ]; then - echo "No same-repository open PR found for ${branch}." >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - pr_head_sha=$(python3 -c 'import json,sys; print(json.load(sys.stdin).get("headRefOid", ""))' <<<"${pr_payload}") - if [ "${pr_head_sha}" != "${{ github.event.workflow_run.head_sha }}" ]; then - echo "Skipping auto-merge for stale CI success on ${branch}; workflow_run head SHA does not match the current PR head." >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT" - - - name: Evaluate merge guard - id: merge_guard - if: steps.pr.outputs.pr_number != '' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - gh pr view "${{ steps.pr.outputs.pr_number }}" --repo "${{ github.repository }}" \ - --json number,isDraft,body,url,files,changedFiles,additions,deletions,reviewDecision,labels,baseRefName,headRefName,headRepositoryOwner,headRepository,isCrossRepository > data/output/codex_auto_merge/pr.json - gh api --paginate --slurp \ - "/repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.pr_number }}/files?per_page=100" \ - > data/output/codex_auto_merge/pr_files_pages.json - python3 - <<'PY' - import json - from pathlib import Path - - output_dir = Path("data/output/codex_auto_merge") - pr_path = output_dir / "pr.json" - pr = json.loads(pr_path.read_text(encoding="utf-8")) - pages = json.loads((output_dir / "pr_files_pages.json").read_text(encoding="utf-8")) - files = [] - for page in pages: - if not isinstance(page, list): - continue - for item in page: - if not isinstance(item, dict): - continue - files.append( - { - "path": item.get("filename", ""), - "status": item.get("status", ""), - "additions": item.get("additions"), - "deletions": item.get("deletions"), - "previous_filename": item.get("previous_filename", ""), - } - ) - pr["files"] = files - pr_path.write_text(json.dumps(pr, indent=2, sort_keys=True) + "\n", encoding="utf-8") - PY - python3 scripts/evaluate_codex_pr_merge.py \ - --pr-json data/output/codex_auto_merge/pr.json \ - --summary-file data/output/codex_auto_merge/summary.md \ - --decision-file data/output/codex_auto_merge/decision.json \ - --expected-base-ref "${{ github.event.repository.default_branch }}" \ - --expected-head-ref "${{ github.event.workflow_run.head_branch }}" \ - --expected-head-owner "${{ github.repository_owner }}" \ - --expected-head-repository "${{ github.repository }}" \ - --require-same-repository - python3 - <<'PY' >> "$GITHUB_OUTPUT" - import json - from pathlib import Path - - decision = json.loads(Path("data/output/codex_auto_merge/decision.json").read_text(encoding="utf-8")) - print(f"should_merge={str(bool(decision['should_merge'])).lower()}") - print(f"reason={decision['reason']}") - print(f"risk_level={decision['risk_level']}") - PY - - - name: Append merge guard summary - if: steps.pr.outputs.pr_number != '' - run: cat data/output/codex_auto_merge/summary.md >> "$GITHUB_STEP_SUMMARY" - - - name: Comment auto-merge guard decision - if: steps.pr.outputs.pr_number != '' && steps.merge_guard.outputs.should_merge != 'true' - continue-on-error: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - python3 scripts/post_codex_auto_merge_decision_comment.py \ - --repo "${{ github.repository }}" \ - --pr-json data/output/codex_auto_merge/pr.json \ - --decision-json data/output/codex_auto_merge/decision.json \ - --output-file data/output/codex_auto_merge/guard_decision_comment.md \ - --sync-labels - - - name: Check guarded auto-merge readiness before merge - id: merge_readiness - if: steps.merge_guard.outputs.should_merge == 'true' - continue-on-error: true - env: - GITHUB_TOKEN: ${{ secrets.CODEX_AUDIT_READINESS_TOKEN || secrets.GITHUB_TOKEN }} - CODEX_AUDIT_REQUIRED_STATUS_CHECKS: ${{ vars.CODEX_AUDIT_REQUIRED_STATUS_CHECKS || 'test' }} - run: | - set -euo pipefail - python3 scripts/check_codex_auto_merge_readiness.py \ - --repo "${{ github.repository }}" \ - --branch "${{ github.event.repository.default_branch }}" \ - --auto-merge true \ - --required-status-checks "${CODEX_AUDIT_REQUIRED_STATUS_CHECKS}" \ - --summary-file data/output/codex_auto_merge/readiness.md - - - name: Comment merge-time readiness failure - if: steps.merge_guard.outputs.should_merge == 'true' && steps.merge_readiness.outcome != 'success' - continue-on-error: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - python3 - <<'PY' - import json - from pathlib import Path - - decision_path = Path("data/output/codex_auto_merge/decision.json") - decision = json.loads(decision_path.read_text(encoding="utf-8")) - risk_reasons = list(decision.get("risk_reasons") or []) - risk_reasons.append("merge-time readiness check failed; see codex_auto_merge/readiness.md") - metadata_errors = list(decision.get("metadata_errors") or []) - metadata_errors.append("merge-time readiness check failed") - decision.update( - { - "should_merge": False, - "reason": "merge_readiness_failed", - "risk_level": "high", - "risk_reasons": risk_reasons, - "metadata_errors": metadata_errors, - } - ) - Path("data/output/codex_auto_merge/readiness_decision.json").write_text( - json.dumps(decision, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) - PY - python3 scripts/post_codex_auto_merge_decision_comment.py \ - --repo "${{ github.repository }}" \ - --pr-json data/output/codex_auto_merge/pr.json \ - --decision-json data/output/codex_auto_merge/readiness_decision.json \ - --output-file data/output/codex_auto_merge/readiness_guard_decision_comment.md \ - --sync-labels - - - name: Fail on merge-time readiness failure - if: steps.merge_guard.outputs.should_merge == 'true' && steps.merge_readiness.outcome != 'success' - run: | - echo "Guarded auto-merge readiness failed at merge time; see readiness.md and readiness_guard_decision_comment.md." >&2 - exit 1 - - - name: Merge Codex remediation PR - if: steps.merge_guard.outputs.should_merge == 'true' && steps.merge_readiness.outcome == 'success' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh pr merge "${{ steps.pr.outputs.pr_number }}" --repo "${{ github.repository }}" --rebase --delete-branch --match-head-commit "${{ github.event.workflow_run.head_sha }}" - - - name: Upload Codex auto-merge diagnostics - if: always() - uses: actions/upload-artifact@v7 - with: - name: codex-auto-merge-${{ github.run_id }} - path: data/output/codex_auto_merge/ - if-no-files-found: warn diff --git a/.github/workflows/codex_pr_feedback.yml b/.github/workflows/codex_pr_feedback.yml deleted file mode 100644 index 05c1e58..0000000 --- a/.github/workflows/codex_pr_feedback.yml +++ /dev/null @@ -1,542 +0,0 @@ -name: Codex PR Feedback - -"on": - workflow_run: - workflows: ["CI"] - types: [completed] - pull_request_review: - types: [submitted] - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - CODEX_AUDIT_ENABLED: ${{ vars.CODEX_AUDIT_ENABLED || 'true' }} - CODEX_AUDIT_BRIDGE_REPOSITORY: ${{ vars.CODEX_AUDIT_BRIDGE_REPOSITORY || 'QuantStrategyLab/AIAuditBridge' }} - CODEX_AUDIT_BRIDGE_REF: ${{ vars.CODEX_AUDIT_BRIDGE_REF || 'main' }} - CODEX_AUDIT_MODE: ${{ vars.CODEX_AUDIT_MODE || 'review_and_fix' }} - CODEX_AUDIT_PROVIDER: ${{ vars.CODEX_AUDIT_PROVIDER || 'auto' }} - CODEX_AUDIT_AUTO_MERGE: ${{ vars.CODEX_AUDIT_AUTO_MERGE || 'false' }} - CODEX_AUDIT_REQUIRED_STATUS_CHECKS: ${{ vars.CODEX_AUDIT_REQUIRED_STATUS_CHECKS || 'test' }} - -jobs: - ci-feedback: - if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure' && startsWith(github.event.workflow_run.head_branch, 'codex/monthly-review-issue-') && github.event.workflow_run.head_repository.full_name == github.repository - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: read - issues: write - pull-requests: read - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Post CI failure back to Codex issue - id: feedback - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BRANCH_NAME: ${{ github.event.workflow_run.head_branch }} - RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - RUN_URL: ${{ github.event.workflow_run.html_url }} - RUN_NAME: ${{ github.event.workflow_run.name }} - MAX_CODEX_FEEDBACK_ROUNDS: ${{ vars.CODEX_AUDIT_MAX_FEEDBACK_ROUNDS || '3' }} - run: | - set -euo pipefail - mkdir -p data/output/codex_feedback - gh pr list --repo "${GITHUB_REPOSITORY}" --state open --head "${BRANCH_NAME}" --json number,title,url,body,headRefOid,headRepository,isCrossRepository \ - --jq '[.[] | select(.isCrossRepository == false and .headRepository.nameWithOwner == "'"${GITHUB_REPOSITORY}"'")]' > data/output/codex_feedback/pr.json - python3 - <<'PY' - import json - import os - import re - import textwrap - from pathlib import Path - - prs = json.loads(Path("data/output/codex_feedback/pr.json").read_text(encoding="utf-8")) - if not prs: - Path("data/output/codex_feedback/skip.txt").write_text("No open Codex PR found.\n", encoding="utf-8") - raise SystemExit(0) - - pr = prs[0] - if str(pr.get("headRefOid") or "") != os.environ["RUN_HEAD_SHA"]: - Path("data/output/codex_feedback/skip.txt").write_text( - "Skipping stale CI failure because the workflow_run head SHA no longer matches the current PR head.\n", - encoding="utf-8", - ) - raise SystemExit(0) - body = pr.get("body") or "" - match = re.search(r"", body) - if not match: - Path("data/output/codex_feedback/skip.txt").write_text("No source issue marker found.\n", encoding="utf-8") - raise SystemExit(0) - - issue_number = match.group(1) - comment = textwrap.dedent( - f"""\ - - ## Codex PR CI Feedback - - CI failed for the Codex remediation PR. - - - PR: {pr['url']} - - Branch: `{os.environ['BRANCH_NAME']}` - - Workflow: `{os.environ['RUN_NAME']}` - - Run: {os.environ['RUN_URL']} - - Codex should inspect the failing GitHub Actions logs, update the same PR branch, run targeted tests, and keep the PR draft until the fix is verified. - """ - ) - Path("data/output/codex_feedback/issue_number.txt").write_text(issue_number, encoding="utf-8") - Path("data/output/codex_feedback/pr_number.txt").write_text(str(pr["number"]), encoding="utf-8") - Path("data/output/codex_feedback/comment.md").write_text(comment.strip() + "\n", encoding="utf-8") - PY - if [ -f data/output/codex_feedback/issue_number.txt ]; then - issue_number="$(cat data/output/codex_feedback/issue_number.txt)" - pr_number="$(cat data/output/codex_feedback/pr_number.txt)" - policy_labels="$(python3 - <<'PY' - import sys - - from scripts.check_codex_auto_merge_readiness import DEFAULT_POLICY_PATH, ReadinessError, load_policy_labels - - try: - labels = load_policy_labels(DEFAULT_POLICY_PATH) - except ReadinessError as exc: - print( - f"::warning::Skipping stale guarded auto-merge label cleanup because auto-merge policy labels are invalid: {exc}", - file=sys.stderr, - ) - raise SystemExit(0) - print(labels["auto_merge_label"]) - print(labels["human_review_label"]) - PY - )" - guard_label="$(printf '%s\n' "${policy_labels}" | sed -n '1p')" - human_review_label="$(printf '%s\n' "${policy_labels}" | sed -n '2p')" - if [ -n "${guard_label}" ]; then - gh issue edit "${pr_number}" --repo "${GITHUB_REPOSITORY}" --remove-label "${guard_label}" || true - echo "Removed stale guarded auto-merge label ${guard_label} from PR #${pr_number} after CI failure if it existed." - else - echo "Skipped stale guarded auto-merge label cleanup after CI failure because policy labels are invalid." - fi - gh api --paginate --slurp \ - "/repos/${GITHUB_REPOSITORY}/issues/${issue_number}/comments?per_page=100" \ - > data/output/codex_feedback/comment_pages.json - python3 - <<'PY' - import json - import os - import textwrap - from pathlib import Path - - output_dir = Path("data/output/codex_feedback") - comment_pages = json.loads((output_dir / "comment_pages.json").read_text(encoding="utf-8")) - comments = [] - for page in comment_pages: - if isinstance(page, list): - comments.extend(comment.get("body") or "" for comment in page if isinstance(comment, dict)) - previous_rounds = sum(body.startswith(" - ## Codex PR Retry Limit Reached - - Automatic Codex feedback reached the retry limit. - - - Previous feedback rounds: `{previous_rounds}` - - Maximum automatic rounds: `{max_rounds}` - - The workflow removed `codex-bridge` from this issue and will try to mark the PR with the configured human-review label. Please inspect the PR and re-apply `codex-bridge` only if another automated Codex pass is still appropriate. - """ - ) - comment_path.write_text(comment.strip() + "\n", encoding="utf-8") - (output_dir / "limit_reached").write_text("true\n", encoding="utf-8") - else: - attempt = previous_rounds + 1 - comment = comment_path.read_text(encoding="utf-8").rstrip() - comment_path.write_text( - f"{comment}\n\n- Feedback round: `{attempt}` of `{max_rounds}`\n", - encoding="utf-8", - ) - PY - if [ -f data/output/codex_feedback/limit_reached ]; then - gh issue edit "${issue_number}" --repo "${GITHUB_REPOSITORY}" --remove-label codex-bridge || true - if [ -n "${human_review_label}" ]; then - gh label create "${human_review_label}" --repo "${GITHUB_REPOSITORY}" --color d93f0b --description "Codex remediation PR requires human review before merge." || true - gh issue edit "${pr_number}" --repo "${GITHUB_REPOSITORY}" --add-label "${human_review_label}" || true - echo "Marked PR #${pr_number} with human-review label ${human_review_label} after retry limit was reached." - else - echo "Skipped adding human-review label after retry limit because policy labels are invalid." - fi - echo "dispatch_feedback=false" >> "$GITHUB_OUTPUT" - else - echo "dispatch_feedback=true" >> "$GITHUB_OUTPUT" - fi - echo "issue_number=${issue_number}" >> "$GITHUB_OUTPUT" - gh issue comment "${issue_number}" --repo "${GITHUB_REPOSITORY}" --body-file data/output/codex_feedback/comment.md - else - echo "dispatch_feedback=false" >> "$GITHUB_OUTPUT" - cat data/output/codex_feedback/skip.txt >> "$GITHUB_STEP_SUMMARY" - fi - - - name: Detect Codex Audit GitHub App Credentials - id: codex_review_app_credentials - if: steps.feedback.outputs.dispatch_feedback == 'true' && contains(fromJSON('["true","True","TRUE"]'), env.CODEX_AUDIT_ENABLED) - env: - APP_ID: ${{ vars.CROSS_REPO_GITHUB_APP_ID }} - APP_PRIVATE_KEY: ${{ secrets.CROSS_REPO_GITHUB_APP_PRIVATE_KEY }} - run: | - set -euo pipefail - if [ -n "${APP_ID:-}" ] && [ -n "${APP_PRIVATE_KEY:-}" ]; then - echo "available=true" >> "$GITHUB_OUTPUT" - else - echo "available=false" >> "$GITHUB_OUTPUT" - fi - - - name: Create GitHub App Token For Codex Audit - id: codex_review_app_token - if: steps.codex_review_app_credentials.outputs.available == 'true' - continue-on-error: true - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ vars.CROSS_REPO_GITHUB_APP_ID }} - private-key: ${{ secrets.CROSS_REPO_GITHUB_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: | - AIAuditBridge - permission-actions: write - - - name: Check guarded auto-merge readiness - id: auto_merge_readiness - if: steps.feedback.outputs.dispatch_feedback == 'true' && contains(fromJSON('["true","True","TRUE"]'), env.CODEX_AUDIT_ENABLED) - continue-on-error: true - env: - GITHUB_TOKEN: ${{ secrets.CODEX_AUDIT_READINESS_TOKEN || secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - python scripts/check_codex_auto_merge_readiness.py \ - --repo "${GITHUB_REPOSITORY}" \ - --branch "${{ github.event.repository.default_branch || github.ref_name }}" \ - --auto-merge "${CODEX_AUDIT_AUTO_MERGE}" \ - --required-status-checks "${CODEX_AUDIT_REQUIRED_STATUS_CHECKS}" \ - --summary-file data/output/codex_feedback/codex_auto_merge_readiness.md - - - name: Dispatch Codex feedback retry - if: steps.feedback.outputs.dispatch_feedback == 'true' && contains(fromJSON('["true","True","TRUE"]'), env.CODEX_AUDIT_ENABLED) - env: - GH_TOKEN: ${{ steps.codex_review_app_token.outputs.token || secrets.CODEX_AUDIT_DISPATCH_TOKEN }} - ISSUE_NUMBER: ${{ steps.feedback.outputs.issue_number }} - SOURCE_REF: ${{ github.event.repository.default_branch || github.ref_name }} - TARGET_REPOSITORY: ${{ env.CODEX_AUDIT_BRIDGE_REPOSITORY }} - REVIEW_MODE: ${{ env.CODEX_AUDIT_MODE }} - REVIEW_PROVIDER: ${{ env.CODEX_AUDIT_PROVIDER }} - AUTO_MERGE_REQUESTED: ${{ env.CODEX_AUDIT_AUTO_MERGE }} - AUTO_MERGE_READINESS_OUTCOME: ${{ steps.auto_merge_readiness.outcome || 'skipped' }} - run: | - set -euo pipefail - if [ -z "${GH_TOKEN:-}" ]; then - echo "Codex audit feedback dispatch requires either a GitHub App token or CODEX_AUDIT_DISPATCH_TOKEN" >&2 - exit 1 - fi - if [[ ! "${TARGET_REPOSITORY}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then - echo "Invalid Codex audit repository: ${TARGET_REPOSITORY}" >&2 - exit 1 - fi - case "${REVIEW_MODE}" in - review_only|review_and_fix) ;; - *) echo "Unsupported Codex audit mode: ${REVIEW_MODE}" >&2; exit 1 ;; - esac - case "${REVIEW_PROVIDER}" in - auto|api|anthropic|codex|openai) ;; - *) echo "Unsupported Codex audit provider: ${REVIEW_PROVIDER}" >&2; exit 1 ;; - esac - auto_merge_requested="false" - if [ "${AUTO_MERGE_REQUESTED}" = "true" ] || [ "${AUTO_MERGE_REQUESTED}" = "True" ] || [ "${AUTO_MERGE_REQUESTED}" = "TRUE" ]; then - auto_merge_requested="true" - fi - auto_merge="false" - if [ "${auto_merge_requested}" = "true" ] && [ "${AUTO_MERGE_READINESS_OUTCOME}" = "success" ]; then - auto_merge="true" - fi - if [ "${auto_merge_requested}" = "true" ] && [ "${AUTO_MERGE_READINESS_OUTCOME}" != "success" ]; then - echo "Guarded auto-merge was requested, but readiness did not pass; dispatching Codex feedback retry with auto_merge=false." - fi - gh workflow run codex_audit.yml \ - --repo "${TARGET_REPOSITORY}" \ - --ref "${CODEX_AUDIT_BRIDGE_REF}" \ - --field source_repo="${GITHUB_REPOSITORY}" \ - --field source_ref="${SOURCE_REF}" \ - --field issue_number="${ISSUE_NUMBER}" \ - --field mode="${REVIEW_MODE}" \ - --field provider="${REVIEW_PROVIDER}" \ - --field auto_merge="${auto_merge}" \ - --field task="monthly_snapshot_audit" - echo "Dispatched AIAuditBridge feedback retry for issue #${ISSUE_NUMBER} to ${TARGET_REPOSITORY}" - - - name: Upload Codex feedback diagnostics - if: always() - uses: actions/upload-artifact@v7 - with: - name: codex-pr-feedback-ci-${{ github.run_id }} - path: data/output/codex_feedback/ - if-no-files-found: warn - - review-feedback: - if: github.event_name == 'pull_request_review' && github.event.review.state == 'changes_requested' && startsWith(github.event.pull_request.head.ref, 'codex/monthly-review-issue-') && github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: read - issues: write - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Post review feedback back to Codex issue - id: feedback - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_URL: ${{ github.event.pull_request.html_url }} - PR_BODY: ${{ github.event.pull_request.body }} - REVIEW_URL: ${{ github.event.review.html_url }} - REVIEW_AUTHOR: ${{ github.event.review.user.login }} - REVIEW_BODY: ${{ github.event.review.body }} - MAX_CODEX_FEEDBACK_ROUNDS: ${{ vars.CODEX_AUDIT_MAX_FEEDBACK_ROUNDS || '3' }} - run: | - set -euo pipefail - mkdir -p data/output/codex_feedback - python3 - <<'PY' - import os - import re - import textwrap - from pathlib import Path - - body = os.environ.get("PR_BODY") or "" - match = re.search(r"", body) - if not match: - Path("data/output/codex_feedback/skip.txt").write_text("No source issue marker found.\n", encoding="utf-8") - raise SystemExit(0) - - review_body = (os.environ.get("REVIEW_BODY") or "_No review body supplied._").strip() - comment = textwrap.dedent( - f"""\ - - ## Codex PR Review Feedback - - A review requested changes on the Codex remediation PR. - - - PR: {os.environ['PR_URL']} - - Reviewer: @{os.environ['REVIEW_AUTHOR']} - - Review: {os.environ['REVIEW_URL']} - - ### Review Body - - {review_body} - - Codex should update the same PR branch, address the requested changes, run targeted tests, and leave the PR draft until the fix is verified. - """ - ) - Path("data/output/codex_feedback/issue_number.txt").write_text(match.group(1), encoding="utf-8") - Path("data/output/codex_feedback/comment.md").write_text(comment.strip() + "\n", encoding="utf-8") - PY - if [ -f data/output/codex_feedback/issue_number.txt ]; then - issue_number="$(cat data/output/codex_feedback/issue_number.txt)" - policy_labels="$(python3 - <<'PY' - import sys - - from scripts.check_codex_auto_merge_readiness import DEFAULT_POLICY_PATH, ReadinessError, load_policy_labels - - try: - labels = load_policy_labels(DEFAULT_POLICY_PATH) - except ReadinessError as exc: - print( - f"::warning::Skipping stale guarded auto-merge label cleanup because auto-merge policy labels are invalid: {exc}", - file=sys.stderr, - ) - raise SystemExit(0) - print(labels["auto_merge_label"]) - print(labels["human_review_label"]) - PY - )" - guard_label="$(printf '%s\n' "${policy_labels}" | sed -n '1p')" - human_review_label="$(printf '%s\n' "${policy_labels}" | sed -n '2p')" - if [ -n "${guard_label}" ]; then - gh issue edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --remove-label "${guard_label}" || true - echo "Removed stale guarded auto-merge label ${guard_label} from PR #${PR_NUMBER} after requested changes if it existed." - else - echo "Skipped stale guarded auto-merge label cleanup after requested changes because policy labels are invalid." - fi - gh api --paginate --slurp \ - "/repos/${GITHUB_REPOSITORY}/issues/${issue_number}/comments?per_page=100" \ - > data/output/codex_feedback/comment_pages.json - python3 - <<'PY' - import json - import os - import textwrap - from pathlib import Path - - output_dir = Path("data/output/codex_feedback") - comment_pages = json.loads((output_dir / "comment_pages.json").read_text(encoding="utf-8")) - comments = [] - for page in comment_pages: - if isinstance(page, list): - comments.extend(comment.get("body") or "" for comment in page if isinstance(comment, dict)) - previous_rounds = sum(body.startswith(" - ## Codex PR Retry Limit Reached - - Automatic Codex feedback reached the retry limit. - - - Previous feedback rounds: `{previous_rounds}` - - Maximum automatic rounds: `{max_rounds}` - - The workflow removed `codex-bridge` from this issue and will try to mark the PR with the configured human-review label. Please inspect the PR and re-apply `codex-bridge` only if another automated Codex pass is still appropriate. - """ - ) - comment_path.write_text(comment.strip() + "\n", encoding="utf-8") - (output_dir / "limit_reached").write_text("true\n", encoding="utf-8") - else: - attempt = previous_rounds + 1 - comment = comment_path.read_text(encoding="utf-8").rstrip() - comment_path.write_text( - f"{comment}\n\n- Feedback round: `{attempt}` of `{max_rounds}`\n", - encoding="utf-8", - ) - PY - if [ -f data/output/codex_feedback/limit_reached ]; then - gh issue edit "${issue_number}" --repo "${GITHUB_REPOSITORY}" --remove-label codex-bridge || true - if [ -n "${human_review_label}" ]; then - gh label create "${human_review_label}" --repo "${GITHUB_REPOSITORY}" --color d93f0b --description "Codex remediation PR requires human review before merge." || true - gh issue edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --add-label "${human_review_label}" || true - echo "Marked PR #${PR_NUMBER} with human-review label ${human_review_label} after retry limit was reached." - else - echo "Skipped adding human-review label after retry limit because policy labels are invalid." - fi - echo "dispatch_feedback=false" >> "$GITHUB_OUTPUT" - else - echo "dispatch_feedback=true" >> "$GITHUB_OUTPUT" - fi - echo "issue_number=${issue_number}" >> "$GITHUB_OUTPUT" - gh issue comment "${issue_number}" --repo "${GITHUB_REPOSITORY}" --body-file data/output/codex_feedback/comment.md - else - echo "dispatch_feedback=false" >> "$GITHUB_OUTPUT" - cat data/output/codex_feedback/skip.txt >> "$GITHUB_STEP_SUMMARY" - fi - - - name: Detect Codex Audit GitHub App Credentials - id: codex_review_app_credentials - if: steps.feedback.outputs.dispatch_feedback == 'true' && contains(fromJSON('["true","True","TRUE"]'), env.CODEX_AUDIT_ENABLED) - env: - APP_ID: ${{ vars.CROSS_REPO_GITHUB_APP_ID }} - APP_PRIVATE_KEY: ${{ secrets.CROSS_REPO_GITHUB_APP_PRIVATE_KEY }} - run: | - set -euo pipefail - if [ -n "${APP_ID:-}" ] && [ -n "${APP_PRIVATE_KEY:-}" ]; then - echo "available=true" >> "$GITHUB_OUTPUT" - else - echo "available=false" >> "$GITHUB_OUTPUT" - fi - - - name: Create GitHub App Token For Codex Audit - id: codex_review_app_token - if: steps.codex_review_app_credentials.outputs.available == 'true' - continue-on-error: true - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ vars.CROSS_REPO_GITHUB_APP_ID }} - private-key: ${{ secrets.CROSS_REPO_GITHUB_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: | - AIAuditBridge - permission-actions: write - - - name: Check guarded auto-merge readiness - id: auto_merge_readiness - if: steps.feedback.outputs.dispatch_feedback == 'true' && contains(fromJSON('["true","True","TRUE"]'), env.CODEX_AUDIT_ENABLED) - continue-on-error: true - env: - GITHUB_TOKEN: ${{ secrets.CODEX_AUDIT_READINESS_TOKEN || secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - python scripts/check_codex_auto_merge_readiness.py \ - --repo "${GITHUB_REPOSITORY}" \ - --branch "${{ github.event.repository.default_branch || github.ref_name }}" \ - --auto-merge "${CODEX_AUDIT_AUTO_MERGE}" \ - --required-status-checks "${CODEX_AUDIT_REQUIRED_STATUS_CHECKS}" \ - --summary-file data/output/codex_feedback/codex_auto_merge_readiness.md - - - name: Dispatch Codex feedback retry - if: steps.feedback.outputs.dispatch_feedback == 'true' && contains(fromJSON('["true","True","TRUE"]'), env.CODEX_AUDIT_ENABLED) - env: - GH_TOKEN: ${{ steps.codex_review_app_token.outputs.token || secrets.CODEX_AUDIT_DISPATCH_TOKEN }} - ISSUE_NUMBER: ${{ steps.feedback.outputs.issue_number }} - SOURCE_REF: ${{ github.event.repository.default_branch || github.ref_name }} - TARGET_REPOSITORY: ${{ env.CODEX_AUDIT_BRIDGE_REPOSITORY }} - REVIEW_MODE: ${{ env.CODEX_AUDIT_MODE }} - REVIEW_PROVIDER: ${{ env.CODEX_AUDIT_PROVIDER }} - AUTO_MERGE_REQUESTED: ${{ env.CODEX_AUDIT_AUTO_MERGE }} - AUTO_MERGE_READINESS_OUTCOME: ${{ steps.auto_merge_readiness.outcome || 'skipped' }} - run: | - set -euo pipefail - if [ -z "${GH_TOKEN:-}" ]; then - echo "Codex audit feedback dispatch requires either a GitHub App token or CODEX_AUDIT_DISPATCH_TOKEN" >&2 - exit 1 - fi - if [[ ! "${TARGET_REPOSITORY}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then - echo "Invalid Codex audit repository: ${TARGET_REPOSITORY}" >&2 - exit 1 - fi - case "${REVIEW_MODE}" in - review_only|review_and_fix) ;; - *) echo "Unsupported Codex audit mode: ${REVIEW_MODE}" >&2; exit 1 ;; - esac - case "${REVIEW_PROVIDER}" in - auto|api|anthropic|codex|openai) ;; - *) echo "Unsupported Codex audit provider: ${REVIEW_PROVIDER}" >&2; exit 1 ;; - esac - auto_merge_requested="false" - if [ "${AUTO_MERGE_REQUESTED}" = "true" ] || [ "${AUTO_MERGE_REQUESTED}" = "True" ] || [ "${AUTO_MERGE_REQUESTED}" = "TRUE" ]; then - auto_merge_requested="true" - fi - auto_merge="false" - if [ "${auto_merge_requested}" = "true" ] && [ "${AUTO_MERGE_READINESS_OUTCOME}" = "success" ]; then - auto_merge="true" - fi - if [ "${auto_merge_requested}" = "true" ] && [ "${AUTO_MERGE_READINESS_OUTCOME}" != "success" ]; then - echo "Guarded auto-merge was requested, but readiness did not pass; dispatching Codex feedback retry with auto_merge=false." - fi - gh workflow run codex_audit.yml \ - --repo "${TARGET_REPOSITORY}" \ - --ref "${CODEX_AUDIT_BRIDGE_REF}" \ - --field source_repo="${GITHUB_REPOSITORY}" \ - --field source_ref="${SOURCE_REF}" \ - --field issue_number="${ISSUE_NUMBER}" \ - --field mode="${REVIEW_MODE}" \ - --field provider="${REVIEW_PROVIDER}" \ - --field auto_merge="${auto_merge}" \ - --field task="monthly_snapshot_audit" - echo "Dispatched AIAuditBridge feedback retry for issue #${ISSUE_NUMBER} to ${TARGET_REPOSITORY}" - - - name: Upload Codex feedback diagnostics - if: always() - uses: actions/upload-artifact@v7 - with: - name: codex-pr-feedback-review-${{ github.run_id }} - path: data/output/codex_feedback/ - if-no-files-found: warn From d48ba0a1fd26b37bbd3b2a5cb6b1113dab28e7df Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:53:24 +0800 Subject: [PATCH 2/7] Align readiness with retired audit workflows Co-Authored-By: Codex --- .github/workflows/monthly_review.yml | 188 ---- docs/operator_runbook.md | 38 +- docs/operator_runbook.zh-CN.md | 32 +- docs/snapshot-ai-audit-automation.md | 187 +--- scripts/check_codex_auto_merge_readiness.py | 181 ---- scripts/plan_codex_auto_merge_enablement.py | 103 +-- .../test_check_codex_auto_merge_readiness.py | 861 +----------------- tests/test_monthly_review_workflow_config.py | 247 +---- .../test_plan_codex_auto_merge_enablement.py | 320 +------ 9 files changed, 137 insertions(+), 2020 deletions(-) diff --git a/.github/workflows/monthly_review.yml b/.github/workflows/monthly_review.yml index c519d62..6733ae5 100644 --- a/.github/workflows/monthly_review.yml +++ b/.github/workflows/monthly_review.yml @@ -28,20 +28,12 @@ jobs: group: monthly-snapshot-review-${{ github.ref_name }} cancel-in-progress: false permissions: - actions: write contents: read issues: write env: REVIEW_LABEL: monthly-review FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - CODEX_AUDIT_ENABLED: ${{ vars.CODEX_AUDIT_ENABLED || 'true' }} - CODEX_AUDIT_BRIDGE_REPOSITORY: ${{ vars.CODEX_AUDIT_BRIDGE_REPOSITORY || 'QuantStrategyLab/AIAuditBridge' }} - CODEX_AUDIT_BRIDGE_REF: ${{ vars.CODEX_AUDIT_BRIDGE_REF || 'main' }} - CODEX_AUDIT_MODE: ${{ vars.CODEX_AUDIT_MODE || 'review_and_fix' }} - CODEX_AUDIT_PROVIDER: ${{ vars.CODEX_AUDIT_PROVIDER || 'auto' }} - CODEX_AUDIT_AUTO_MERGE: ${{ vars.CODEX_AUDIT_AUTO_MERGE || 'false' }} - CODEX_AUDIT_REQUIRED_STATUS_CHECKS: ${{ vars.CODEX_AUDIT_REQUIRED_STATUS_CHECKS || 'test' }} steps: - name: Checkout @@ -200,186 +192,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Ensure guarded auto-merge labels - if: success() && contains(fromJSON('["true","True","TRUE"]'), env.CODEX_AUDIT_ENABLED) && contains(fromJSON('["true","True","TRUE"]'), env.CODEX_AUDIT_AUTO_MERGE) - continue-on-error: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - python scripts/sync_codex_auto_merge_labels.py \ - --repo "${GITHUB_REPOSITORY}" \ - --auto-merge "${CODEX_AUDIT_AUTO_MERGE}" \ - --summary-file data/output/monthly_report_bundle/codex_auto_merge_label_sync.md - - - name: Check guarded auto-merge readiness - id: auto_merge_readiness - if: success() && contains(fromJSON('["true","True","TRUE"]'), env.CODEX_AUDIT_ENABLED) - continue-on-error: true - env: - GITHUB_TOKEN: ${{ secrets.CODEX_AUDIT_READINESS_TOKEN || secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - python scripts/check_codex_auto_merge_readiness.py \ - --repo "${GITHUB_REPOSITORY}" \ - --branch "${GITHUB_REF_NAME}" \ - --auto-merge "${CODEX_AUDIT_AUTO_MERGE}" \ - --required-status-checks "${CODEX_AUDIT_REQUIRED_STATUS_CHECKS}" \ - --summary-file data/output/monthly_report_bundle/codex_auto_merge_readiness.md - - - name: Build guarded auto-merge enablement plan - if: success() && contains(fromJSON('["true","True","TRUE"]'), env.CODEX_AUDIT_ENABLED) - continue-on-error: true - env: - GITHUB_TOKEN: ${{ secrets.CODEX_AUDIT_READINESS_TOKEN || secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - python scripts/plan_codex_auto_merge_enablement.py \ - --repo "${GITHUB_REPOSITORY}" \ - --branch "${GITHUB_REF_NAME}" \ - --required-status-checks "${CODEX_AUDIT_REQUIRED_STATUS_CHECKS}" \ - --output-file data/output/monthly_report_bundle/codex_auto_merge_enablement_plan.md - - - name: Comment guarded auto-merge preflight summary - if: success() && contains(fromJSON('["true","True","TRUE"]'), env.CODEX_AUDIT_ENABLED) - continue-on-error: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - python scripts/post_codex_auto_merge_preflight_comment.py \ - --repo "${GITHUB_REPOSITORY}" \ - --issue-number "${{ steps.review_issue.outputs.issue_number }}" \ - --report-month "${{ steps.bundle.outputs.report_month }}" \ - --readiness-file data/output/monthly_report_bundle/codex_auto_merge_readiness.md \ - --label-sync-file data/output/monthly_report_bundle/codex_auto_merge_label_sync.md \ - --enablement-plan-file data/output/monthly_report_bundle/codex_auto_merge_enablement_plan.md \ - --output-file data/output/monthly_report_bundle/codex_auto_merge_preflight_comment.md - - - name: Detect Codex Audit GitHub App Credentials - id: codex_review_app_credentials - if: success() && contains(fromJSON('["true","True","TRUE"]'), env.CODEX_AUDIT_ENABLED) - env: - APP_ID: ${{ vars.CROSS_REPO_GITHUB_APP_ID }} - APP_PRIVATE_KEY: ${{ secrets.CROSS_REPO_GITHUB_APP_PRIVATE_KEY }} - run: | - set -euo pipefail - if [ -n "${APP_ID:-}" ] && [ -n "${APP_PRIVATE_KEY:-}" ]; then - echo "available=true" >> "$GITHUB_OUTPUT" - else - echo "available=false" >> "$GITHUB_OUTPUT" - fi - - - name: Create GitHub App Token For Codex Audit - id: codex_review_app_token - if: steps.codex_review_app_credentials.outputs.available == 'true' - continue-on-error: true - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ vars.CROSS_REPO_GITHUB_APP_ID }} - private-key: ${{ secrets.CROSS_REPO_GITHUB_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: | - AIAuditBridge - permission-actions: write - - - name: Trigger Monthly Review Automation - if: success() && contains(fromJSON('["true","True","TRUE"]'), env.CODEX_AUDIT_ENABLED) - env: - APP_TOKEN: ${{ steps.codex_review_app_token.outputs.token }} - CODEX_AUDIT_DISPATCH_TOKEN: ${{ secrets.CODEX_AUDIT_DISPATCH_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_REPOSITORY: ${{ github.repository }} - GITHUB_REF_NAME: ${{ github.ref_name }} - ISSUE_NUMBER: ${{ steps.review_issue.outputs.issue_number }} - TARGET_REPOSITORY: ${{ env.CODEX_AUDIT_BRIDGE_REPOSITORY }} - REVIEW_MODE: ${{ env.CODEX_AUDIT_MODE }} - REVIEW_PROVIDER: ${{ env.CODEX_AUDIT_PROVIDER }} - AUTO_MERGE_REQUESTED: ${{ env.CODEX_AUDIT_AUTO_MERGE }} - AUTO_MERGE_READINESS_OUTCOME: ${{ steps.auto_merge_readiness.outcome || 'skipped' }} - CODEX_AUDIT_ENABLED_RUNTIME: ${{ env.CODEX_AUDIT_ENABLED }} - run: | - set -euo pipefail - python3 - <<'PY' - import json - import os - import re - import urllib.error - import urllib.request - - def enabled(name: str) -> bool: - return os.environ.get(name, "").strip().lower() == "true" - - def dispatch(token: str, url: str, payload: dict) -> int: - request = urllib.request.Request( - url, - data=json.dumps(payload).encode("utf-8"), - method="POST", - headers={ - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github+json", - "Content-Type": "application/json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "us-equity-snapshot-monthly-review", - }, - ) - with urllib.request.urlopen(request, timeout=30) as response: - return response.status - - def dispatch_codex() -> None: - token = os.environ.get("APP_TOKEN", "").strip() or os.environ.get("CODEX_AUDIT_DISPATCH_TOKEN", "").strip() - if not token: - raise RuntimeError( - "Codex audit workflow dispatch requires either a GitHub App token or CODEX_AUDIT_DISPATCH_TOKEN" - ) - target_repository = os.environ["TARGET_REPOSITORY"].strip() - if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", target_repository): - raise RuntimeError(f"Invalid Codex audit repository: {target_repository!r}") - mode = os.environ["REVIEW_MODE"].strip() or "review_and_fix" - if mode not in {"review_only", "review_and_fix"}: - raise RuntimeError(f"Unsupported Codex audit mode: {mode}") - provider = os.environ["REVIEW_PROVIDER"].strip() or "codex" - if provider not in {"api", "anthropic", "codex", "openai", "auto"}: - raise RuntimeError(f"Unsupported Codex audit provider: {provider}") - auto_merge_requested = enabled("AUTO_MERGE_REQUESTED") - auto_merge_ready = os.environ.get("AUTO_MERGE_READINESS_OUTCOME", "").strip() == "success" - auto_merge = auto_merge_requested and auto_merge_ready - if auto_merge_requested and not auto_merge_ready: - print( - "Guarded auto-merge was requested, but readiness did not pass; " - "dispatching Codex audit with auto_merge=false." - ) - payload = { - "ref": os.environ["CODEX_AUDIT_BRIDGE_REF"], - "inputs": { - "source_repo": os.environ["GITHUB_REPOSITORY"], - "source_ref": os.environ["GITHUB_REF_NAME"], - "issue_number": os.environ["ISSUE_NUMBER"], - "mode": mode, - "provider": provider, - "auto_merge": str(auto_merge).lower(), - "task": "monthly_snapshot_audit", - }, - } - status = dispatch( - token, - f"https://api.github.com/repos/{target_repository}/actions/workflows/codex_audit.yml/dispatches", - payload, - ) - if status not in (201, 204): - raise RuntimeError(f"Unexpected Codex dispatch status: {status}") - print( - f"Dispatched AIAuditBridge review for issue #{os.environ['ISSUE_NUMBER']} " - f"to {target_repository}" - ) - - if enabled("CODEX_AUDIT_ENABLED_RUNTIME"): - dispatch_codex() - raise SystemExit(0) - - print("Codex audit bridge dispatch is disabled by CODEX_AUDIT_ENABLED=false") - PY - - name: Upload monthly review bundle if: always() uses: actions/upload-artifact@v7 diff --git a/docs/operator_runbook.md b/docs/operator_runbook.md index 8a07582..cc580ad 100644 --- a/docs/operator_runbook.md +++ b/docs/operator_runbook.md @@ -201,28 +201,16 @@ The publish workflow keeps a defensive month-end trading-day guard: if the resol ## Monthly AI Review -After a successful scheduled `Publish Snapshot Artifacts` run, `Monthly Snapshot Review` downloads that run's artifacts and assembles: - -```text -data/output/monthly_report_bundle/monthly_report_bundle.json -data/output/monthly_report_bundle/ai_review_input.md -data/output/monthly_report_bundle/job_summary.md -``` - -The workflow creates or updates a GitHub issue labeled `monthly-review`. By default it dispatches `QuantStrategyLab/AIAuditBridge`, which calls the Quant HTTPS/443 service-backed Codex path and posts the audit result back to the issue. The review focuses on: - -- artifact completeness for the expected snapshot profile -- contract version, snapshot date, row count, and ranking-preview health -- missing or stale evidence that should block downstream confidence -- downstream impact for broker/runtime repositories - -The monthly review workflow dispatches `AIAuditBridge`; the bridge owns provider selection through `CODEX_AUDIT_PROVIDER`. `auto` is the default and calls the service-backed Codex path first, falls back to the configured API reviewers when Codex service execution fails, and fails loudly when no API fallback key is configured. Set `CODEX_AUDIT_BRIDGE_REF` to pin the bridge workflow ref; the default is `main`. `codex` uses only the service-backed Codex path and disables API fallback; `api` posts a combined API review; `openai` and `anthropic` post a single-provider API review only. - -Direct bridge PRs are not auto-merged by default. Set `CODEX_AUDIT_AUTO_MERGE=true` only after branch protection or repository rulesets, CI gates, the source `Auto Merge Codex Remediation PR` guard, and the `Codex PR Feedback` retry workflow are confirmed. When guarded auto-merge is requested, `Monthly Snapshot Review` first runs `scripts/sync_codex_auto_merge_labels.py` to create the configured `auto-merge-ok` / `human-review-required` labels if they are missing. Before flipping the variable, run `scripts/plan_codex_auto_merge_enablement.py` and complete its enablement preflight checklist, including review of any existing branch protection or ruleset settings. The source auto-merge workflow is hard-gated by `CODEX_AUDIT_AUTO_MERGE`, so it skips when the variable is unset or false even if a PR already carries `auto-merge-ok`. Required CI checks default to `test`; set repository variable `CODEX_AUDIT_REQUIRED_STATUS_CHECKS` to a comma- or newline-separated list before enablement if branch protection uses different check names. `Monthly Snapshot Review` still dispatches AIAuditBridge when readiness is incomplete, but downgrades that dispatch to `auto_merge=false` so audit and PR creation continue without requesting guarded auto-merge. If the default workflow token cannot read branch protection, rulesets, or label settings in the deployed repository, configure `CODEX_AUDIT_READINESS_TOKEN` as a source-repository secret for readiness checks only. The intended unattended path is: Codex opens a small remediation PR, CI passes, the PR carries the monthly remediation marker and `auto-merge-ok`, then the source merge guard confirms there is no active requested-changes review decision, classifies the changed-file surface, and enforces `max_changed_files` (`20` by default) and `max_changed_lines` (`1200` by default) before merging. The auto-merge workflow skips stale successful CI runs when the run head SHA no longer matches the current PR head, and the final merge is also pinned to the CI `workflow_run.head_sha`; a newer push to the same PR branch requires a fresh successful CI run. High-risk changes such as strategy logic, live profile contracts, dependencies, secrets, broker/runtime settings, live allocation, data artifacts, or strategy deletion/disablement must stay human-reviewed; AIAuditBridge labels those generated PRs `human-review-required` and records the risk files in the source issue. -Each monthly review bundle artifact also includes `codex_auto_merge_label_sync.md`, `codex_auto_merge_readiness.md`, `codex_auto_merge_enablement_plan.md`, and the rendered `codex_auto_merge_preflight_comment.md` when the audit path is enabled. The workflow also upserts that short guarded auto-merge preflight comment on the monthly review issue, including the label-sync, readiness, and enablement checklist snapshots. Operators can use those records to audit the preflight state that was used before any guarded auto-merge dispatch decision. - -If CI fails on a same-repository Codex remediation PR, or a reviewer requests changes on that PR, `Codex PR Feedback` first skips stale CI-failure runs whose head SHA no longer matches the current PR head. For current failures or requested changes, it removes any stale configured guarded auto-merge label from that PR on a best-effort basis after validating that the policy labels are readable and distinct; if the policy is invalid or the auto-merge and human-review labels collide, it skips label mutation instead of guessing. It then comments the failure or review summary back to the source `codex-bridge` issue and dispatches `AIAuditBridge` again for the same issue. Feedback retries run the same guarded auto-merge readiness check; when readiness is incomplete, the retry still runs but passes `auto_merge=false`. The bridge resolves the latest feedback marker and updates the existing PR branch when it is still open, same-repository, and tied to the same monthly issue. The workflow permits `CODEX_AUDIT_MAX_FEEDBACK_ROUNDS` automatic feedback rounds, defaulting to `3`; invalid values fall back to `3`, and values are clamped to `1` through `10`. Once that limit is reached, it removes `codex-bridge`, marks the PR with the configured human-review label on a best-effort basis, creating that label first if it is missing and permissions allow it, and leaves the issue for human review. The feedback workflow uploads `codex-pr-feedback-ci-` or `codex-pr-feedback-review-` diagnostics even when later steps fail; inspect those artifacts for `comment.md`, `comment_pages.json`, `pr.json`, and `codex_auto_merge_readiness.md` before rerunning manually. - -When guarded auto-merge is enabled, `Auto Merge Codex Remediation PR` uploads `codex-auto-merge-` diagnostics on every run. Use `decision.json`, `summary.md`, `pr.json`, `guard_decision_comment.md`, and `readiness.md` from that artifact to explain why a PR merged, skipped, failed readiness, or was blocked by the merge guard. If the merge guard skips a PR, or if the final merge-time readiness check fails after the merge guard was otherwise ready, the workflow also upserts a short decision comment on the monthly review issue so high-risk/manual-review cases are visible without opening artifacts first; it also tries to remove stale guarded auto-merge labels from the skipped PR and add the human-review label for high-risk decisions. The decision comment is written first; label hygiene action results are then appended to the same comment and artifact on a best-effort basis. If the follow-up comment update fails, the artifact records that failure, so a permission issue does not hide the reason unattended merge stopped. - -See [`snapshot-ai-audit-automation.md`](snapshot-ai-audit-automation.md) for the risk tiers and unattended-maintenance policy. +`Monthly Snapshot Review` is a report-only workflow: it builds the existing health, +promotion-readiness, and review evidence bundle, uploads that bundle, and creates +or updates a `monthly-review` issue. + +AIAuditBridge review, retry, and merge dispatch paths are retired. GitHub Codex +App is the sole AI reviewer for pull requests. This workflow does not dispatch an +AI reviewer, create remediation pull requests, retry feedback, or request or +perform automatic merges. The evidence bundle is advisory only and is not an +approval or merge signal. + +Repository settings, legacy variables, and secrets are outside this workflow's +contract. Do not infer their runtime state from this document; any settings change +requires its own authenticated, explicitly authorized procedure. diff --git a/docs/operator_runbook.zh-CN.md b/docs/operator_runbook.zh-CN.md index 6162ab8..420208b 100644 --- a/docs/operator_runbook.zh-CN.md +++ b/docs/operator_runbook.zh-CN.md @@ -95,28 +95,14 @@ workflow 仍保留月末交易日 guard:如果解析得到的 `snapshot_as_of` ## 月度 AI Review -定时 `Publish Snapshot Artifacts` 成功后,`Monthly Snapshot Review` 会下载该 run 的 artifacts,并组装: +`Monthly Snapshot Review` 是只生成报告的 workflow:它构建既有 health、 +promotion-readiness 与 review 证据包,上传该 bundle,并创建或更新 +`monthly-review` issue。 -```text -data/output/monthly_report_bundle/monthly_report_bundle.json -data/output/monthly_report_bundle/ai_review_input.md -data/output/monthly_report_bundle/job_summary.md -``` - -workflow 会创建或更新一个带 `monthly-review` label 的 GitHub issue。默认会 dispatch `QuantStrategyLab/AIAuditBridge`,由该桥接 workflow 调用 Quant HTTPS/443 服务端 Codex 路径,并把 audit 结果回写到 issue。review 重点包括: - -- 预期 snapshot profile 的 artifact 完整性; -- contract version、snapshot date、row count 和 ranking-preview 健康度; -- 会降低下游信心的缺失或陈旧证据; -- 对券商/runtime 下游仓库的影响。 - -`Monthly Snapshot Review` 通过 `CODEX_AUDIT_PROVIDER` 控制 reviewer provider。默认 `auto` 会优先调用 service-backed Codex,失败时回退到已配置的 API reviewer;没有 API fallback key 时会显式失败。可通过 `CODEX_AUDIT_BRIDGE_REF` pin 桥接 workflow ref,默认是 `main`。 - -桥接 PR 默认不自动合并。只有确认 branch protection 或 repository rulesets、CI、源仓库 `Auto Merge Codex Remediation PR` guard 以及 `Codex PR Feedback` 重试 workflow 后,才应设置 `CODEX_AUDIT_AUTO_MERGE=true`。当请求受控自动合并时,`Monthly Snapshot Review` 会先运行 `scripts/sync_codex_auto_merge_labels.py`,在缺失时创建配置的 `auto-merge-ok` / `human-review-required` label。真正打开变量前,应先运行 `scripts/plan_codex_auto_merge_enablement.py`,并逐项完成其中的 enablement preflight checklist,包括复核现有 branch protection 或 ruleset 设置,避免覆盖已有保护规则。源仓库 auto-merge workflow 自身也受 `CODEX_AUDIT_AUTO_MERGE` 硬开关控制;变量未设置或为 false 时,即使 PR 已带 `auto-merge-ok` 也会跳过。必需 CI check 默认是 `test`;如果 branch protection 使用不同 check 名称,应在启用前把仓库变量 `CODEX_AUDIT_REQUIRED_STATUS_CHECKS` 设置为逗号或换行分隔的列表。启用前可先运行 `scripts/plan_codex_auto_merge_enablement.py` 生成只读启用计划和 branch protection 命令;该脚本不会修改远端设置。`Monthly Snapshot Review` 会在 dispatch AIAuditBridge 前运行 `scripts/check_codex_auto_merge_readiness.py`;如果 auto-merge 已开启但本地 guard、反馈重试 workflow、远端 label、protected branch/ruleset 或必需 CI status check 任一条件缺失,本次 dispatch 会自动降级为 `auto_merge=false`,继续执行 AI 审计和 PR 创建,但不会请求受控自动合并。如果部署仓库中的默认 workflow token 不能读取 branch protection、rulesets 或 label 设置,可配置源仓库 secret `CODEX_AUDIT_READINESS_TOKEN`,仅供 readiness 检查使用。`auto-merge-ok` label 默认由 AIAuditBridge 在低/中风险 PR 打标前按需创建;如果 bridge token 权限不足,需要先手动创建该 label。无人值守路径应为:Codex 创建小范围修复 PR,CI 通过,PR 带 monthly remediation marker 和 `auto-merge-ok`,然后源仓库 merge guard 会确认当前没有 requested-changes review decision,再根据变更文件面、风险等级和 `max_changed_files`(默认 `20`)和 `max_changed_lines`(默认 `1200`)决定是否合并。auto-merge workflow 会跳过 run head SHA 已经不是当前 PR head 的过期成功 CI,最终 merge 也会绑定 CI 的 `workflow_run.head_sha`;同一 PR 分支如果之后又有新提交,必须重新通过 CI 才能合并。策略逻辑、live profile contract、依赖、密钥、broker/runtime 设置、live allocation、数据 artifact、策略删除或禁用等高风险变更必须保留人工复核;AIAuditBridge 会给这类生成 PR 添加 `human-review-required`,并在源 issue 中记录风险文件。 -月度 review bundle artifact 在 audit path 启用时也会包含 `codex_auto_merge_label_sync.md`、`codex_auto_merge_readiness.md`、`codex_auto_merge_enablement_plan.md` 以及渲染后的 `codex_auto_merge_preflight_comment.md`,workflow 还会在月度 review issue 上 upsert 这条简短的 guarded auto-merge preflight 评论,并包含 label sync、readiness 与 enablement checklist 摘要,用于留存本次受控自动合并 dispatch 决策前的 preflight 状态。 - -如果同仓 Codex remediation PR 的 CI 失败,或 reviewer 要求修改,`Codex PR Feedback` 会先跳过 run head SHA 已经不是当前 PR head 的过期 CI 失败事件。对当前失败或 requested changes,它会复用 readiness 的策略 label 校验,确认 policy 可读且 auto-merge / human-review label 不冲突后,再尽力移除该 PR 上旧的受控自动合并 label;如果 policy 无效或两个 label 冲突,则跳过 label mutation,避免误删人工复核 label。随后它会把失败或 review 摘要评论回源 `codex-bridge` issue,并再次 dispatch `AIAuditBridge` 处理同一个 issue。反馈重试会运行同一套受控 auto-merge readiness 检查;如果 readiness 不完整,重试仍会执行,但传入 `auto_merge=false`。Bridge 会读取最新 feedback marker;只要原 PR 仍然 open、同仓、且属于同一个 monthly issue,就更新现有 PR 分支。workflow 允许 `CODEX_AUDIT_MAX_FEEDBACK_ROUNDS` 轮自动反馈,默认 `3` 轮;无效值会回退到 `3`,并被限制在 `1` 到 `10` 之间。达到上限后会移除 `codex-bridge`,并尽力先创建缺失的人工复核 label、再给 PR 加上该 label,然后交给人工处理。反馈 workflow 即使后续步骤失败,也会上传 `codex-pr-feedback-ci-` 或 `codex-pr-feedback-review-` 诊断 artifact;手动重跑前应先检查其中的 `comment.md`、`comment_pages.json`、`pr.json` 和 `codex_auto_merge_readiness.md`。 - -启用受控自动合并后,`Auto Merge Codex Remediation PR` 每次运行都会上传 `codex-auto-merge-` 诊断 artifact。可用其中的 `decision.json`、`summary.md`、`pr.json`、`guard_decision_comment.md` 和 `readiness.md` 判断 PR 为什么被合并、跳过、readiness 失败或被 merge guard 阻止。如果 merge guard 跳过 PR,或 merge guard 已就绪但最终 merge-time readiness 检查失败,workflow 还会在月度 review issue 上 upsert 一条简短决策评论,让高风险或需人工复核的情况不必先打开 artifact 也能看到;同时会尽力从被跳过的 PR 上移除陈旧的受控自动合并 label,并在高风险决策时补上人工复核 label。决策评论会优先写入;label hygiene action 结果随后会 best-effort 追加到同一条评论和 artifact 中。如果后续评论更新失败,artifact 会记录该失败,避免权限问题掩盖无人值守合并停止的真实原因。 +AIAuditBridge 的 review、retry 和 merge dispatch 路径已经退役。GitHub Codex +App 是 PR 的唯一 AI reviewer。该 workflow 不会 dispatch AI reviewer、创建 +remediation PR、重试反馈,或请求/执行自动合并。证据包仅作参考,不构成审批或 +合并信号。 -风险分层和无人值守维护边界见 [`snapshot-ai-audit-automation.md`](snapshot-ai-audit-automation.md)。 +仓库 settings、旧变量和 secret 不属于此 workflow 的契约范围。不要从本文推断 +其 runtime 状态;任何 settings 修改都需要独立的、已认证且明确授权的流程。 diff --git a/docs/snapshot-ai-audit-automation.md b/docs/snapshot-ai-audit-automation.md index d71b312..641ef4a 100644 --- a/docs/snapshot-ai-audit-automation.md +++ b/docs/snapshot-ai-audit-automation.md @@ -1,173 +1,28 @@ -# Snapshot AI audit automation policy +# Snapshot AI audit automation -This policy defines how the monthly snapshot AI audit can run mostly unattended -while keeping high-risk operations behind human review. +## Current contract -## Target flow +GitHub Codex App is the sole AI reviewer for pull requests. AIAuditBridge +review, feedback-retry, and automatic-merge workflow paths are retired. -1. `Publish Snapshot Artifacts` succeeds on a scheduled source-input refresh. -2. `Monthly Snapshot Review` downloads artifacts, builds live strategy health - evidence, creates/updates the monthly review issue, and dispatches - `AIAuditBridge`. -3. `AIAuditBridge` reviews the issue and may open a remediation PR for safe, - focused fixes. -4. Source-repository CI must pass. -5. `Auto Merge Codex Remediation PR` evaluates the PR marker, labels, file - surface, and risk level before merging. +`Monthly Snapshot Review` is report-only: it produces the existing monthly +snapshot evidence bundle and a `monthly-review` issue. It does not dispatch an +AI reviewer, create remediation pull requests, retry feedback, or request or +perform automatic merges. Evidence is advisory only; it never authorizes a +merge, production change, or live action. -The merge gate is fail-closed: any file outside the monthly-review allowlist is -treated as high-risk and skipped. -The PR marker must also match the issue number encoded in the Codex branch name -(`codex/monthly-review-issue--...`); a generic or mismatched monthly -marker is not enough to merge. -The workflow resolves only same-repository PRs for the Codex branch before -running the merge guard, then the guard independently verifies the PR head owner -and repository again. -The auto-merge workflow also requires the successful CI `workflow_run` head -repository to match the source repository before the job starts. -It resolves the current same-repository PR head and skips stale successful CI -runs whose `workflow_run.head_sha` no longer matches that current PR head. -The merge command is pinned with `--match-head-commit` to the CI -`workflow_run.head_sha`, so a PR branch pushed after the successful CI run is not -merged by that older run. -The configured human-review label is a hard veto: a PR carrying -`human-review-required` will not be auto-merged even if it also carries -`auto-merge-ok`. -When the merge guard skips a PR, the auto-merge workflow upserts a concise -decision comment back to the monthly review issue so operators can see the -reason and risk level without opening workflow artifacts first. -The same skip path upserts the guard-decision comment first, then removes -any stale configured auto-merge label from that PR and adds the configured -human-review label when the guard classified the risk as high. Label hygiene is -best-effort after the comment is written; the action results are appended back -to the same guard-decision comment and written to the diagnostic artifact when -possible. If the follow-up comment update fails, the diagnostic artifact records -that failure, and a permission or transient API failure does not hide the guard -decision itself. If the merge guard is ready but the final merge-time readiness check fails, -the workflow converts that failure into the same visible guard-decision comment -and label-hygiene path before failing the job, so operators do not need to open -artifacts first to see why the unattended merge stopped. +## Review and merge boundaries -The changed-file allowlist is stored in -`.github/codex_auto_merge_policy.json`. The source merge guard reads this file, -and AIAuditBridge can read the same policy from its temporary source -checkout before adding `auto-merge-ok`. AIAuditBridge reads the baseline -policy before Codex edits run, so a remediation PR cannot grant itself a broader -auto-merge surface by editing the policy file. Keep this file in sync with any -new monthly-review automation surface. `blocked_path_patterns` are evaluated -before low-risk prefixes, so secret-like paths under `docs/` or `tests/` still -require human review. Invalid policy JSON or invalid blocked-path regular -expressions fail closed and require human review. -The same policy also defines `human_review_label` (`human-review-required` by -default), which is used for high-risk generated PRs. -`auto_merge_label` and `human_review_label` must be distinct; matching labels -make the policy invalid and fail closed. -Only `version: 1` is currently supported; missing or future policy versions are -also fail-closed until the guard code is intentionally updated. +Deterministic CI checks, strict repository rules, and human review requirements +remain authoritative. A clean Codex review is evidence for the applicable pull +request head only; it is not a bypass for required checks, unresolved findings, +or repository protections. Merge behavior and repository settings are governed +by separately authorized control-plane procedures. -## Risk tiers +## Legacy references -| Tier | Examples | Automation policy | -| --- | --- | --- | -| Low | `docs/`, `tests/`, `README.md`, `README.zh-CN.md` | May be remediated and auto-merged when the PR has the Codex monthly marker, `auto-merge-ok`, is not draft, CI passed, no PR review is currently requesting changes, and the changed-file count plus total additions/deletions stay within `max_changed_files` (`20` by default) and `max_changed_lines` (`1200` by default). | -| Medium | Monthly-review evidence/reporting helpers such as `scripts/build_monthly_live_strategy_health_reports.py`, `scripts/run_monthly_report_bundle.py`, `scripts/post_monthly_ai_review_issue.py`, and the read-only enablement planner | May be remediated when narrowly scoped. Auto-merge is still gated by `auto-merge-ok`, CI, no active requested-changes review decision, the source merge guard summary, and the same changed-file / changed-line caps. | -| High | `src/` strategy logic, profile contracts, `pyproject.toml`, dependencies, secrets, runtime/broker settings, live allocation, publish permissions, data artifacts, workflow files, auto-merge policy files, auto-merge/readiness/merge-guard code, file removals/renames/copies, or strategy deletion/disablement | Never auto-merge. Keep the generated PR for review, label it `human-review-required`, and include evidence plus recommended next checks in the source issue. | - -## Live strategy health evidence - -The live strategy health report is advisory evidence only. It tracks artifact and content health for review workflows, not AiGateway online service health, and it must not be treated as an automatic trading, auto-merge, or auto-approval basis. A -`review_for_retirement` state can justify a human review task, but it must not -automatically remove, disable, or reallocate a strategy. Strategy retirement -still requires a separate review of out-of-sample evidence, costs, runtime -impact, and downstream broker behavior. - -## Operational switches - -- Keep `CODEX_AUDIT_ENABLED=true` to run the monthly audit automatically. -- Set `CODEX_AUDIT_AUTO_MERGE=true` only after branch protection or repository - rulesets, CI, and the source merge guard are active. -- The source `Auto Merge Codex Remediation PR` workflow is also hard-gated by - `CODEX_AUDIT_AUTO_MERGE`; when the variable is unset or not exactly `true`, - `True`, or `TRUE`, the workflow skips even if a PR already has - `auto-merge-ok`. -- `Monthly Snapshot Review` runs `scripts/check_codex_auto_merge_readiness.py` - before dispatching AIAuditBridge. When `CODEX_AUDIT_AUTO_MERGE=true`, the - readiness gate requires the local auto-merge workflow/policy guard surface, - the Codex feedback retry workflow, the configured source labels (`auto-merge-ok` and `human-review-required` by - default), a protected source branch or active ruleset, and the required CI - status check context. The expected checks default to `test`; set repository - variable `CODEX_AUDIT_REQUIRED_STATUS_CHECKS` to a comma- or newline-separated - list if branch protection uses different check names. - If any check is missing or unreadable, the workflow fails closed for guarded - auto-merge by dispatching AIAuditBridge with `auto_merge=false`; the AI - audit and PR creation path still runs. -- When guarded auto-merge is requested, `Monthly Snapshot Review` first runs - `scripts/sync_codex_auto_merge_labels.py` to create the configured source - labels if they are missing. This step is soft-fail: a token or permission - problem is recorded in the monthly artifact bundle and the preflight issue - comment, and readiness still downgrades `auto_merge=false`. -- If the default workflow token cannot read the readiness inputs in a deployed - repository, configure `CODEX_AUDIT_READINESS_TOKEN` as a source-repository - secret with read access to labels, branch protection, and status-check - configuration. The token is used only by readiness checks; issue creation and - bridge dispatch keep using their existing tokens. -- Use `scripts/plan_codex_auto_merge_enablement.py` to generate a read-only - enablement plan and copyable branch-protection commands. The planner never - mutates GitHub settings; apply the generated commands only after human review. - It uses the same `CODEX_AUDIT_REQUIRED_STATUS_CHECKS` value as readiness when - rendering branch-protection and verification commands. -- Ensure the bridge token can create/apply the configured auto-merge label - (`auto-merge-ok` by default). AIAuditBridge creates the label on demand - before applying it; if token permissions block label creation, create the - label manually before enabling `CODEX_AUDIT_AUTO_MERGE=true`. -- Ensure the bridge token can also create/apply the high-risk review label - (`human-review-required`). If this label cannot be applied, the source issue - comment still records the failure and the PR remains without `auto-merge-ok`. -- `Codex PR Feedback` may dispatch a bounded retry only for same-repository - Codex remediation PRs with a valid monthly remediation marker. The retry - ignores stale CI-failure `workflow_run` events whose head SHA no longer - matches the current PR head. For current failures or requested changes, it - first removes any stale configured guarded auto-merge label from the PR on a - best-effort basis, but only after validating the policy labels with the same - distinct-label guard used by readiness. If the policy is invalid or the - auto-merge and human-review labels collide, feedback skips label mutation - rather than guessing. The retry then updates the existing PR branch when it is - still open and tied to the same monthly issue; after the feedback-round limit, - the workflow removes `codex-bridge`, marks the PR with the configured - human-review label on a best-effort basis, creating that label first if it is - missing and permissions allow it, and leaves the issue for manual review. - Feedback retries run - the same readiness gate as the monthly review; if guarded auto-merge is not - ready, the retry still dispatches AIAuditBridge with `auto_merge=false`. -- Automatic feedback retries default to `3` rounds. Set repository variable - `CODEX_AUDIT_MAX_FEEDBACK_ROUNDS` to adjust this without editing the workflow; - invalid values fall back to `3`, and values are clamped to the safe range - `1` to `10`. -- Treat workflow artifacts as the durable audit trail for unattended runs: - `monthly-snapshot-review-` keeps the review bundle, label-sync report, - readiness report, enablement plan, and rendered preflight comment; - `codex-pr-feedback-ci-` and `codex-pr-feedback-review-` keep retry - inputs and rendered comments; `codex-auto-merge-` keeps `pr.json`, - `decision.json`, `summary.md`, `guard_decision_comment.md`, and merge-time - `readiness.md`. Inspect these artifacts before manually rerunning a failed or - skipped unattended step. -- Keep high-risk follow-ups manual even when the monthly issue was generated by - an unattended run. - -## Guarded auto-merge canary - -- 2026-06-20: Ran a controlled same-repository canary through issue #117. - The canary changes this documentation file only and uses the normal - `codex/monthly-review-issue-*` branch, monthly remediation marker, - `auto-merge-ok` label, CI, and source merge guard path. - - -## Required guardrails - -- Do not expose or persist secrets in audit prompts, comments, logs, or PRs. -- Do not edit generated `data/` artifacts. -- Do not change live strategy behavior from a single monthly report. -- Do not remove, rename, or copy files through unattended remediation; create a review PR instead. -- Keep remediation PRs small and tied to concrete monthly-review evidence. -- If CI or review feedback repeats, stop after the configured retry limit and - leave the issue for human review. +Some retained scripts, policy files, variables, and secrets may use historical +AIAudit names. They are not active workflow dispatch, retry, or merge paths. +This document does not claim their runtime configuration or permissions. Changes +to settings, rulesets, webhooks, secrets, variables, or permissions require a +separate authenticated authorization. diff --git a/scripts/check_codex_auto_merge_readiness.py b/scripts/check_codex_auto_merge_readiness.py index 3e2c0de..4ce2dbf 100644 --- a/scripts/check_codex_auto_merge_readiness.py +++ b/scripts/check_codex_auto_merge_readiness.py @@ -14,9 +14,6 @@ DEFAULT_API_URL = "https://api.github.com" DEFAULT_POLICY_PATH = Path(".github/codex_auto_merge_policy.json") -DEFAULT_AUTO_MERGE_WORKFLOW = Path(".github/workflows/auto_merge_codex_pr.yml") -DEFAULT_CODEX_FEEDBACK_WORKFLOW = Path(".github/workflows/codex_pr_feedback.yml") -DEFAULT_MONTHLY_REVIEW_WORKFLOW = Path(".github/workflows/monthly_review.yml") DEFAULT_TIMEOUT_SECONDS = 30 DEFAULT_REQUIRED_STATUS_CHECKS = ("test",) DEFAULT_HUMAN_REVIEW_LABEL = "human-review-required" @@ -213,158 +210,6 @@ def load_policy_labels(policy_path: Path) -> dict[str, str]: return {"auto_merge_label": label, "human_review_label": human_review_label} -def validate_auto_merge_workflow(workflow_path: Path) -> list[str]: - errors: list[str] = [] - try: - workflow = workflow_path.read_text(encoding="utf-8") - except OSError: - return [f"auto-merge workflow is missing: {workflow_path}"] - required_snippets = { - "CI workflow_run trigger": 'workflows: ["CI"]', - "CI success guard": "github.event.workflow_run.conclusion == 'success'", - "Codex branch guard": "codex/monthly-review-issue-", - "repository variable hard switch": "vars.CODEX_AUDIT_AUTO_MERGE", - "strict true allowlist": '["true","True","TRUE"]', - "workflow-run same-repository guard": "github.event.workflow_run.head_repository.full_name == github.repository", - "configurable required status checks": "CODEX_AUDIT_REQUIRED_STATUS_CHECKS", - "required status checks argument": "--required-status-checks", - "explicit gh repository binding": '--repo "${{ github.repository }}"', - "same-repository PR resolution": "headRepository.nameWithOwner", - "PR additions for changed-line guard": "additions", - "PR deletions for changed-line guard": "deletions", - "PR review decision guard": "reviewDecision", - "paginated PR file metadata fetch": "pulls/${{ steps.pr.outputs.pr_number }}/files?per_page=100", - "PR file status guard": '"status": item.get("status", "")', - "PR previous filename capture": '"previous_filename": item.get("previous_filename", "")', - "source merge guard script": "scripts/evaluate_codex_pr_merge.py", - "same-repository guard": "--require-same-repository", - "merge-time readiness check": "Check guarded auto-merge readiness before merge", - "merge-time readiness step id": "id: merge_readiness", - "merge-time readiness soft-fail": "continue-on-error: true", - "merge-time readiness script": "scripts/check_codex_auto_merge_readiness.py", - "merge-time readiness hard true": "--auto-merge true", - "merge-time readiness failure comment": "Comment merge-time readiness failure", - "merge-time readiness failure decision": "readiness_decision.json", - "merge-time readiness failure comment artifact": "readiness_guard_decision_comment.md", - "merge-time readiness failure reason": "merge_readiness_failed", - "merge-time readiness failure hard stop": "Fail on merge-time readiness failure", - "merge gated by readiness outcome": "steps.merge_readiness.outcome == 'success'", - "optional readiness token fallback": "CODEX_AUDIT_READINESS_TOKEN || secrets.GITHUB_TOKEN", - "content write permission": "contents: write", - "issue write permission": "issues: write", - "pull request write permission": "pull-requests: write", - "auto-merge guard decision comment": "Comment auto-merge guard decision", - "auto-merge guard decision comment script": "scripts/post_codex_auto_merge_decision_comment.py", - "auto-merge guard decision comment artifact": "guard_decision_comment.md", - "auto-merge guard decision label hygiene": "--sync-labels", - "merge command": "gh pr merge", - "head SHA race guard": "--match-head-commit", - "workflow run head SHA": "github.event.workflow_run.head_sha", - "auto-merge diagnostic artifact upload": "Upload Codex auto-merge diagnostics", - "auto-merge diagnostic artifact always uploads": "if: always()", - "auto-merge diagnostic artifact action": "actions/upload-artifact@v7", - "auto-merge diagnostic artifact name": "codex-auto-merge-", - "auto-merge diagnostic artifact path": "data/output/codex_auto_merge/", - "auto-merge diagnostic artifact missing-file warning": "if-no-files-found: warn", - } - for label, snippet in required_snippets.items(): - if snippet not in workflow: - errors.append(f"auto-merge workflow missing {label}") - return errors - - -def validate_codex_feedback_workflow(workflow_path: Path) -> list[str]: - errors: list[str] = [] - try: - workflow = workflow_path.read_text(encoding="utf-8") - except OSError: - return [f"Codex feedback workflow is missing: {workflow_path}"] - required_snippets = { - "CI failure workflow_run trigger": "workflow_run:", - "review feedback trigger": "pull_request_review:", - "CI same-repository guard": "github.event.workflow_run.head_repository.full_name == github.repository", - "review same-repository guard": "github.event.pull_request.head.repo.full_name == github.repository", - "Codex branch guard": "codex/monthly-review-issue-", - "explicit PR repository binding": 'gh pr list --repo "${GITHUB_REPOSITORY}"', - "same-repository PR filter": ".headRepository.nameWithOwner", - "explicit issue repository binding": 'gh issue comment "${issue_number}" --repo "${GITHUB_REPOSITORY}"', - "feedback retry limit": "CODEX_AUDIT_MAX_FEEDBACK_ROUNDS", - "feedback retry limit default": "vars.CODEX_AUDIT_MAX_FEEDBACK_ROUNDS || '3'", - "feedback retry limit fallback": "configured_max_rounds = 3", - "feedback retry limit clamp": "max_rounds = min(max(configured_max_rounds, 1), 10)", - "feedback stale auto-merge label lookup": "auto_merge_label", - "feedback stale auto-merge policy label validation": "load_policy_labels(DEFAULT_POLICY_PATH)", - "feedback stale auto-merge label cleanup skip": "Skipping stale guarded auto-merge label cleanup", - "feedback stale auto-merge label cleanup conditional": 'if [ -n "${guard_label}" ]; then', - "feedback stale auto-merge label cleanup": '--remove-label "${guard_label}"', - "feedback stale auto-merge label cleanup log": "Removed stale guarded auto-merge label", - "paginated feedback comment fetch": "gh api --paginate --slurp", - "feedback comment pages artifact": "comment_pages.json", - "feedback comments page size": "/comments?per_page=100", - "retry limit handoff": "--remove-label codex-bridge", - "dispatch hard switch": "env.CODEX_AUDIT_ENABLED", - "Bridge dispatch command": "gh workflow run codex_audit.yml", - "Bridge repository input": "--repo \"${TARGET_REPOSITORY}\"", - "source issue input": "--field issue_number=\"${ISSUE_NUMBER}\"", - "monthly task input": '--field task="monthly_snapshot_audit"', - "guarded auto-merge input": "--field auto_merge=\"${auto_merge}\"", - "feedback auto-merge readiness check": "scripts/check_codex_auto_merge_readiness.py", - "feedback optional readiness token fallback": "CODEX_AUDIT_READINESS_TOKEN || secrets.GITHUB_TOKEN", - "feedback configurable required status checks": "CODEX_AUDIT_REQUIRED_STATUS_CHECKS", - "feedback required status checks argument": "--required-status-checks", - "feedback auto-merge readiness soft-fail": "continue-on-error: true", - "feedback auto-merge readiness outcome gate": "AUTO_MERGE_READINESS_OUTCOME", - "feedback auto-merge downgrade": "dispatching Codex feedback retry with auto_merge=false", - "GitHub App token fallback": "CODEX_AUDIT_DISPATCH_TOKEN", - "GitHub App action permission": "permission-actions: write", - "dispatch output gate": "steps.feedback.outputs.dispatch_feedback == 'true'", - "feedback diagnostic artifact upload": "Upload Codex feedback diagnostics", - "feedback diagnostic artifact always uploads": "if: always()", - "feedback diagnostic artifact action": "actions/upload-artifact@v7", - "CI feedback diagnostic artifact": "codex-pr-feedback-ci-", - "review feedback diagnostic artifact": "codex-pr-feedback-review-", - "feedback diagnostic artifact path": "data/output/codex_feedback/", - "feedback diagnostic artifact missing-file warning": "if-no-files-found: warn", - } - for label, snippet in required_snippets.items(): - if snippet not in workflow: - errors.append(f"Codex feedback workflow missing {label}") - return errors - - -def validate_monthly_review_workflow(workflow_path: Path) -> list[str]: - errors: list[str] = [] - try: - workflow = workflow_path.read_text(encoding="utf-8") - except OSError: - return [f"Monthly review workflow is missing: {workflow_path}"] - required_snippets = { - "Bridge dispatch workflow": "actions/workflows/codex_audit.yml/dispatches", - "guarded label sync step": "Ensure guarded auto-merge labels", - "guarded label sync script": "scripts/sync_codex_auto_merge_labels.py", - "guarded label sync artifact": "codex_auto_merge_label_sync.md", - "preflight label sync input": "--label-sync-file data/output/monthly_report_bundle/codex_auto_merge_label_sync.md", - "readiness check": "scripts/check_codex_auto_merge_readiness.py", - "optional readiness token fallback": "CODEX_AUDIT_READINESS_TOKEN || secrets.GITHUB_TOKEN", - "configurable required status checks": "CODEX_AUDIT_REQUIRED_STATUS_CHECKS", - "required status checks argument": "--required-status-checks", - "readiness step id": "id: auto_merge_readiness", - "readiness soft-fail": "continue-on-error: true", - "auto-merge requested input": "AUTO_MERGE_REQUESTED", - "readiness outcome gate": "AUTO_MERGE_READINESS_OUTCOME", - "auto-merge downgrade": "dispatching Codex audit with auto_merge=false", - "guarded auto-merge dispatch field": '"auto_merge": str(auto_merge).lower()', - "monthly task input": '"task": "monthly_snapshot_audit"', - "diagnostic bundle always uploads": "if: always()", - "diagnostic bundle month fallback": "steps.bundle.outputs.report_month || 'unknown'", - "diagnostic bundle missing-file warning": "if-no-files-found: warn", - } - for label, snippet in required_snippets.items(): - if snippet not in workflow: - errors.append(f"Monthly review workflow missing {label}") - return errors - - def _protection_status_check_contexts(protection: dict[str, Any]) -> set[str]: required_status_checks = protection.get("required_status_checks") if not isinstance(required_status_checks, dict): @@ -607,9 +452,6 @@ def evaluate_readiness( branch: str, token: str, policy_path: Path = DEFAULT_POLICY_PATH, - workflow_path: Path = DEFAULT_AUTO_MERGE_WORKFLOW, - feedback_workflow_path: Path = DEFAULT_CODEX_FEEDBACK_WORKFLOW, - monthly_workflow_path: Path = DEFAULT_MONTHLY_REVIEW_WORKFLOW, api_url: str = DEFAULT_API_URL, required_status_checks: tuple[str, ...] = DEFAULT_REQUIRED_STATUS_CHECKS, ) -> dict[str, Any]: @@ -647,23 +489,6 @@ def evaluate_readiness( checks.append(f"Loaded auto-merge policy label `{label}`.") checks.append(f"Loaded high-risk human-review label `{human_review_label}`.") - workflow_errors = validate_auto_merge_workflow(workflow_path) - if workflow_errors: - errors.extend(workflow_errors) - else: - checks.append("Auto-merge workflow contains required CI, branch, guard, and merge checks.") - - feedback_workflow_errors = validate_codex_feedback_workflow(feedback_workflow_path) - if feedback_workflow_errors: - errors.extend(feedback_workflow_errors) - else: - checks.append("Codex feedback workflow contains same-repository retry, limit, and Bridge dispatch checks.") - - monthly_workflow_errors = validate_monthly_review_workflow(monthly_workflow_path) - if monthly_workflow_errors: - errors.extend(monthly_workflow_errors) - else: - checks.append("Monthly review workflow contains readiness-gated Bridge dispatch checks.") if not token.strip(): errors.append("GITHUB_TOKEN is required when CODEX_AUDIT_AUTO_MERGE=true") @@ -720,9 +545,6 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--branch", required=True) parser.add_argument("--auto-merge", default=os.environ.get("CODEX_AUDIT_AUTO_MERGE", "false")) parser.add_argument("--policy-file", type=Path, default=DEFAULT_POLICY_PATH) - parser.add_argument("--workflow-file", type=Path, default=DEFAULT_AUTO_MERGE_WORKFLOW) - parser.add_argument("--feedback-workflow-file", type=Path, default=DEFAULT_CODEX_FEEDBACK_WORKFLOW) - parser.add_argument("--monthly-workflow-file", type=Path, default=DEFAULT_MONTHLY_REVIEW_WORKFLOW) parser.add_argument("--api-url", default=DEFAULT_API_URL) parser.add_argument("--required-status-check", action="append") parser.add_argument( @@ -742,9 +564,6 @@ def main() -> int: branch=args.branch, token=os.environ.get("GITHUB_TOKEN", ""), policy_path=args.policy_file, - workflow_path=args.workflow_file, - feedback_workflow_path=args.feedback_workflow_file, - monthly_workflow_path=args.monthly_workflow_file, api_url=args.api_url, required_status_checks=parse_required_status_check_args( args.required_status_check, diff --git a/scripts/plan_codex_auto_merge_enablement.py b/scripts/plan_codex_auto_merge_enablement.py index 8efcdab..9dd764b 100644 --- a/scripts/plan_codex_auto_merge_enablement.py +++ b/scripts/plan_codex_auto_merge_enablement.py @@ -14,7 +14,6 @@ from scripts.check_codex_auto_merge_readiness import ( DEFAULT_API_URL, - DEFAULT_AUTO_MERGE_WORKFLOW, DEFAULT_POLICY_PATH, GitHubApiError, ReadinessError, @@ -37,8 +36,6 @@ "Codex remediation PR requires human review before merge", ), ) -AUTO_MERGE_VARIABLE = "CODEX_AUDIT_AUTO_MERGE" - def branch_protection_payload(required_status_checks: tuple[str, ...]) -> dict[str, Any]: required_status_checks = validate_required_status_checks(required_status_checks) @@ -232,33 +229,6 @@ def discover_branch_protection_status_checks( return discovered, warnings -def discover_repository_variable( - *, - api_url: str, - repo: str, - token: str, - name: str = AUTO_MERGE_VARIABLE, -) -> tuple[str | None, list[str]]: - if not token.strip(): - return None, [f"GITHUB_TOKEN is not set; skipped {name} repository variable discovery."] - - api_url = api_url.rstrip("/") - encoded_name = urllib.parse.quote(name, safe="") - try: - payload = github_request("GET", f"{api_url}/repos/{repo}/actions/variables/{encoded_name}", token) - except GitHubApiError as exc: - if exc.status_code == 404: - return None, [f"Repository variable {name} is not set."] - return None, [f"Could not read repository variable {name}: HTTP {exc.status_code}"] - if not isinstance(payload, dict): - return None, [f"Could not read repository variable {name}: invalid response"] - - value = payload.get("value") - if not isinstance(value, str): - return None, [f"Repository variable {name} has an invalid value."] - return value, [] - - def render_enablement_plan( *, repo: str, @@ -268,14 +238,11 @@ def render_enablement_plan( discovered_check_contexts: list[str] | None = None, discovered_branch_protection_status_checks: list[str] | None = None, discovered_ruleset_status_checks: list[str] | None = None, - auto_merge_variable_value: str | None = None, discovery_warnings: list[str] | None = None, protection_discovery_warnings: list[str] | None = None, ruleset_discovery_warnings: list[str] | None = None, - variable_discovery_warnings: list[str] | None = None, ) -> str: branch_command = render_branch_protection_command(repo, branch, required_status_checks) - label_commands = render_label_commands(repo) required_checks = ", ".join(f"`{item}`" for item in required_status_checks) discovered_check_contexts = discovered_check_contexts or [] discovered_branch_protection_status_checks = discovered_branch_protection_status_checks or [] @@ -283,21 +250,7 @@ def render_enablement_plan( discovery_warnings = discovery_warnings or [] protection_discovery_warnings = protection_discovery_warnings or [] ruleset_discovery_warnings = ruleset_discovery_warnings or [] - variable_discovery_warnings = variable_discovery_warnings or [] expected_missing = sorted(set(required_status_checks) - set(discovered_check_contexts)) if discovered_check_contexts else [] - variable_section_lines = [ - "## Current guarded auto-merge variable", - "", - ] - if auto_merge_variable_value is None: - variable_section_lines.append(f"- `{AUTO_MERGE_VARIABLE}`: unknown or not set") - else: - variable_section_lines.append(f"- `{AUTO_MERGE_VARIABLE}`: `{auto_merge_variable_value}`") - variable_section_lines.append("- The source auto-merge workflow runs only when this value is `true`, `True`, or `TRUE`.") - if variable_discovery_warnings: - variable_section_lines.extend(["", "### Variable discovery warnings"]) - variable_section_lines.extend(f"- {item}" for item in variable_discovery_warnings) - variable_section = "\n".join(variable_section_lines).strip() + "\n\n" discovered_section = "" if discovered_check_contexts or discovery_warnings: discovered_section_lines = ["## Discovered check contexts", ""] @@ -331,61 +284,32 @@ def render_enablement_plan( ruleset_section_lines.extend(f"- {item}" for item in ruleset_discovery_warnings) ruleset_section = "\n".join(ruleset_section_lines).strip() + "\n\n" preflight_section = ( - "## Enablement preflight checklist\n\n" + "## Readiness assessment checklist\n\n" "- [ ] Readiness reports `Ready: yes` after labels and branch protection or rulesets are configured.\n" - f"- [ ] `{AUTO_MERGE_VARIABLE}` is still `false` or unset before the final enablement step.\n" f"- [ ] Required status checks are configured as strict/up-to-date checks: {required_checks}.\n" "- [ ] Any existing branch protection or ruleset settings were reviewed before applying the generated `PUT` command.\n" - "- [ ] The first guarded Codex PR after enablement will be monitored, and the rollback command below is ready.\n\n" + "- [ ] Keep automatic merge disabled; this report does not authorize workflow or repository-setting changes.\n\n" ) return ( "# Codex guarded auto-merge enablement plan\n\n" "This plan is read-only. It does not modify GitHub repository settings.\n\n" "## Current readiness\n\n" f"{render_summary(readiness)}\n" - f"{variable_section}" f"{discovered_section}" f"{protection_section}" f"{ruleset_section}" f"{preflight_section}" - "## Manual enablement steps\n\n" - "1. Confirm that source CI check contexts, merge guard, and feedback retry workflow are stable, and both `auto-merge-ok` and " - "`human-review-required` labels exist. This plan expects " + "## Read-only readiness guidance\n\n" + "1. Confirm that source CI check contexts and both `auto-merge-ok` and " + "`human-review-required` labels exist. This report expects " f"{required_checks}.\n" - " Compare the expected checks with the discovered contexts, branch protection checks, and ruleset checks above before applying branch protection.\n" - "2. Create or update the labels used by the unattended and human-review paths:\n\n" - "```bash\n" - f"{label_commands}\n" - "```\n\n" - "3. Apply minimal branch protection or an equivalent active repository ruleset that requires the CI status check before merge.\n" - " The command below uses GitHub's branch protection `PUT` API. If branch protection already exists, " - "review and merge the generated payload with the existing rules before running it so review, " - "restriction, or admin-enforcement settings are not unintentionally overwritten:\n\n" + " Compare the expected checks with the discovered contexts, branch protection checks, and ruleset checks above.\n" + "2. Any future settings changes require separate authorization. The generated branch-protection payload is evidence only; " + "review existing settings first so review, restriction, and admin-enforcement settings are not overwritten.\n\n" "```bash\n" f"{branch_command}\n" "```\n\n" - "4. Verify readiness again before enabling dispatch-time auto-merge requests:\n\n" - " If GitHub Actions' default token cannot read branch protection or label settings in this repository, " - "configure `CODEX_AUDIT_READINESS_TOKEN` as a source-repository secret for readiness checks only.\n\n" - "```bash\n" - "GITHUB_TOKEN=\"$(gh auth token)\" .venv/bin/python scripts/check_codex_auto_merge_readiness.py \\\n" - f" --repo {repo} \\\n" - f" --branch {branch} \\\n" - " --auto-merge true \\\n" - + "".join(f" --required-status-check {check} \\\n" for check in required_status_checks) - + " --summary-file data/output/codex_auto_merge_readiness.md\n" - "```\n\n" - "5. Only after readiness reports `Ready: yes`, enable guarded requests from monthly review:\n\n" - "```bash\n" - "gh variable set CODEX_AUDIT_AUTO_MERGE --repo " - f"{repo} --body true\n" - "```\n\n" - "## Rollback\n\n" - "```bash\n" - "gh variable set CODEX_AUDIT_AUTO_MERGE --repo " - f"{repo} --body false\n" - "```\n\n" - "High-risk PRs remain blocked by the source merge guard even after enablement.\n" + "3. Re-run this read-only assessment before any separately authorized configuration work. Automatic merge remains disabled.\n" ) @@ -400,7 +324,6 @@ def parse_args() -> argparse.Namespace: help="Comma- or newline-separated required status check contexts. Repeated --required-status-check is still supported.", ) parser.add_argument("--policy-file", type=Path, default=DEFAULT_POLICY_PATH) - parser.add_argument("--workflow-file", type=Path, default=DEFAULT_AUTO_MERGE_WORKFLOW) parser.add_argument("--api-url", default=DEFAULT_API_URL) parser.add_argument("--output-file", type=Path) return parser.parse_args() @@ -423,7 +346,6 @@ def main() -> int: branch=args.branch, token=token, policy_path=args.policy_file, - workflow_path=args.workflow_file, api_url=args.api_url, required_status_checks=required_status_checks, ) @@ -445,11 +367,6 @@ def main() -> int: branch=args.branch, token=token, ) - auto_merge_variable_value, variable_discovery_warnings = discover_repository_variable( - api_url=args.api_url, - repo=args.repo, - token=token, - ) plan = render_enablement_plan( repo=args.repo, branch=args.branch, @@ -458,11 +375,9 @@ def main() -> int: discovered_check_contexts=discovered_contexts, discovered_branch_protection_status_checks=discovered_protection_checks, discovered_ruleset_status_checks=discovered_ruleset_checks, - auto_merge_variable_value=auto_merge_variable_value, discovery_warnings=discovery_warnings, protection_discovery_warnings=protection_discovery_warnings, ruleset_discovery_warnings=ruleset_discovery_warnings, - variable_discovery_warnings=variable_discovery_warnings, ) if args.output_file: args.output_file.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_check_codex_auto_merge_readiness.py b/tests/test_check_codex_auto_merge_readiness.py index 607e2a0..64ad32e 100644 --- a/tests/test_check_codex_auto_merge_readiness.py +++ b/tests/test_check_codex_auto_merge_readiness.py @@ -6,51 +6,25 @@ import scripts.check_codex_auto_merge_readiness as readiness from scripts.check_codex_auto_merge_readiness import ( GitHubApiError, - ReadinessError, evaluate_readiness, parse_required_status_check_args, render_summary, - validate_auto_merge_workflow, - validate_codex_feedback_workflow, - validate_monthly_review_workflow, - validate_required_status_checks, ) -def write_policy( - path: Path, - *, - label: str = "auto-merge-ok", - human_review_label: str = "human-review-required", - low_prefixes: list[str] | None = None, - low_exact: list[str] | None = None, - medium_exact: list[str] | None = None, -) -> None: +def write_policy(path: Path, *, low_prefixes: list[str] | None = None) -> None: path.write_text( json.dumps( { "version": 1, - "auto_merge_label": label, - "human_review_label": human_review_label, - "monthly_marker_prefix": "" if expected_issue_number else "" - has_marker = expected_marker in body if expected_marker else marker_prefix in body - has_merge_label = auto_merge_label in labels - has_human_review_label = human_review_label in labels - is_draft = bool(pr.get("isDraft")) - if ( - has_merge_label - and not has_human_review_label - and has_marker - and not is_draft - and file_guard["allowed"] - and not policy_errors - and not metadata_errors - and (pr.get("additions") is None or pr.get("deletions") is None) - ): - policy_errors.append("missing changed line counts require human review") - if ( - has_merge_label - and not has_human_review_label - and has_marker - and not is_draft - and file_guard["allowed"] - and not policy_errors - and not metadata_errors - ): - if "reviewDecision" not in pr: - metadata_errors.append("missing review decision requires human review") - elif review_decision in {"CHANGES_REQUESTED", "REVIEW_REQUIRED"}: - metadata_errors.append(f"review decision `{review_decision}` requires human review") - if ( - has_merge_label - and not has_human_review_label - and has_marker - and not is_draft - and file_guard["allowed"] - and not policy_errors - and not metadata_errors - and _missing_changed_file_status_metadata(pr.get("files", []) or []) - ): - metadata_errors.append("missing changed file status metadata requires human review") - should_merge = ( - has_marker - and has_merge_label - and not has_human_review_label - and not is_draft - and file_guard["allowed"] - and not policy_errors - and not metadata_errors - ) - if policy_errors: - reason = "policy_errors" - elif metadata_errors: - reason = "pr_metadata_mismatch" - elif not has_marker: - reason = "missing_marker" - elif has_human_review_label: - reason = "human_review_required" - elif not has_merge_label: - reason = "missing_auto_merge_label" - elif is_draft: - reason = "draft_pr" - elif not file_guard["allowed"]: - reason = "blocked_files" - else: - reason = "ready" - risk_level = "high" if policy_errors or metadata_errors or has_human_review_label else file_guard["risk_level"] - risk_reasons = ( - policy_errors - or metadata_errors - or ([f"human-review label `{human_review_label}` requires manual review"] if has_human_review_label else []) - or file_guard["risk_reasons"] - ) - return { - "should_merge": should_merge, - "reason": reason, - "blocked_files": file_guard["blocked_files"], - "risk_level": risk_level, - "risk_reasons": risk_reasons, - "policy_errors": policy_errors, - "metadata_errors": metadata_errors, - "base_ref": str(pr.get("baseRefName", "") or "").strip(), - "head_ref": str(pr.get("headRefName", "") or "").strip(), - "head_owner": _owner_login(pr.get("headRepositoryOwner")), - "head_repository": _repository_name_with_owner(pr.get("headRepository")), - "is_cross_repository": _cross_repository_state(pr.get("isCrossRepository")), - "reported_changed_file_count": _reported_changed_file_count(pr.get("changedFiles")), - "reported_additions": _reported_changed_line_count(pr.get("additions")), - "reported_deletions": _reported_changed_line_count(pr.get("deletions")), - "review_decision": review_decision if "reviewDecision" in pr else "n/a", - "auto_merge_label": auto_merge_label, - "human_review_label": human_review_label, - "has_human_review_label": has_human_review_label, - "monthly_marker_prefix": marker_prefix, - "medium_risk_files": file_guard["medium_risk_files"], - "changed_file_count": len([path for path in changed_files if path.strip()]), - "changed_lines": file_guard["changed_lines"], - } - - -def render_summary(pr: dict[str, Any], decision: dict[str, Any]) -> str: - lines = [ - "## Codex Auto-Merge Gate", - f"- PR: {pr.get('url', 'n/a')}", - f"- Draft: `{'yes' if pr.get('isDraft') else 'no'}`", - f"- Base ref: `{decision.get('base_ref') or 'n/a'}`", - f"- Head ref: `{decision.get('head_ref') or 'n/a'}`", - f"- Head owner: `{decision.get('head_owner') or 'n/a'}`", - f"- Head repository: `{decision.get('head_repository') or 'n/a'}`", - f"- Cross repository: `{_format_cross_repository(decision.get('is_cross_repository'))}`", - f"- Changed files: `{decision['changed_file_count']}`", - f"- Reported changed files: `{decision.get('reported_changed_file_count') if decision.get('reported_changed_file_count') is not None else 'n/a'}`", - f"- Additions: `{decision.get('reported_additions') if decision.get('reported_additions') is not None else 'n/a'}`", - f"- Deletions: `{decision.get('reported_deletions') if decision.get('reported_deletions') is not None else 'n/a'}`", - f"- Changed lines: `{decision.get('changed_lines') if decision.get('changed_lines') is not None else 'n/a'}`", - f"- Review decision: `{decision.get('review_decision') or 'none'}`", - f"- Risk level: `{decision['risk_level']}`", - f"- Blocked files: `{len(decision['blocked_files'])}`", - f"- Human-review label present: `{'yes' if decision.get('has_human_review_label') else 'no'}`", - f"- Decision: `{'merge' if decision['should_merge'] else 'skip'}`", - f"- Reason: `{decision['reason']}`", - ] - if decision.get("risk_reasons"): - lines.extend(["", "### Risk reasons"]) - lines.extend(f"- {reason}" for reason in decision["risk_reasons"]) - if decision.get("medium_risk_files"): - lines.extend(["", "### Medium-risk files"]) - lines.extend(f"- `{path}`" for path in decision["medium_risk_files"]) - if decision["blocked_files"]: - lines.extend(["", "### Blocked files"]) - lines.extend(f"- `{path}`" for path in decision["blocked_files"]) - return "\n".join(lines).strip() + "\n" - - -def _format_cross_repository(value: Any) -> str: - if value is True: - return "yes" - if value is False: - return "no" - return "unknown" - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Evaluate whether a Codex remediation PR may be auto-merged.") - parser.add_argument("--pr-json", required=True, type=Path) - parser.add_argument("--summary-file", required=True, type=Path) - parser.add_argument("--decision-file", required=True, type=Path) - parser.add_argument("--expected-base-ref", default="") - parser.add_argument("--expected-head-ref", default="") - parser.add_argument("--expected-head-owner", default="") - parser.add_argument("--expected-head-repository", default="") - parser.add_argument("--require-same-repository", action="store_true") - return parser.parse_args() - - -def main() -> int: - args = parse_args() - pr = json.loads(args.pr_json.read_text(encoding="utf-8")) - decision = evaluate_pr( - pr, - expected_base_ref=args.expected_base_ref, - expected_head_ref=args.expected_head_ref, - expected_head_owner=args.expected_head_owner, - expected_head_repository=args.expected_head_repository, - require_same_repository=args.require_same_repository, - ) - args.summary_file.parent.mkdir(parents=True, exist_ok=True) - args.summary_file.write_text(render_summary(pr, decision), encoding="utf-8") - args.decision_file.write_text(json.dumps(decision, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - print(f"should_merge={'true' if decision['should_merge'] else 'false'}") - print(f"reason={decision['reason']}") - print(f"risk_level={decision['risk_level']}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/gate_codex_app_review.py b/scripts/gate_codex_app_review.py deleted file mode 100644 index f379971..0000000 --- a/scripts/gate_codex_app_review.py +++ /dev/null @@ -1,284 +0,0 @@ -#!/usr/bin/env python3 -"""PR merge gate: static scan + Codex App review → job exit code = check status. - -Two phases, zero API keys needed: - 1. STATIC — scan diff for secrets, blocked files, metadata issues (<30s). - Fail job immediately on hard violations. - 2. WAIT — poll for Codex GitHub App review up to N min. - Fail job on CHANGES_REQUESTED, pass on APPROVED/timeout. - 3. REACT — on Codex bot review submitted: update instantly. - -The workflow job IS the check — exit 0 = pass, exit 1 = fail. -""" - -from __future__ import annotations - -import json -import os -import re -import sys -import time -import urllib.error -import urllib.request -from pathlib import Path -from typing import Any - -API_BASE = "https://api.github.com" -BOT_LOGIN = "chatgpt-codex-connector[bot]" -POLICY_PATH = Path(".github/codex_auto_merge_policy.json") - - -def env(name: str, default: str = "") -> str: - return os.environ.get(name, default).strip() - - -def env_int(name: str, default: int) -> int: - try: return int(env(name, str(default))) - except ValueError: return default - - -def github_request(token: str, method: str, path: str, - payload: dict[str, Any] | None = None) -> Any: - url = f"{API_BASE}{path}" if not path.startswith("https://") else path - data = json.dumps(payload).encode() if payload else None - headers = { - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "codex-review-gate", - } - if payload: headers["Content-Type"] = "application/json" - req = urllib.request.Request(url, data=data, method=method, headers=headers) - try: - with urllib.request.urlopen(req, timeout=30) as resp: - body = resp.read().decode("utf-8") - except urllib.error.HTTPError as exc: - detail = exc.read().decode("utf-8", errors="replace") - raise RuntimeError(f"GitHub API {method} {url}: {exc.code} {detail[:500]}") from exc - return json.loads(body) if body else {} - - -def step_summary(text: str) -> None: - p = os.environ.get("GITHUB_STEP_SUMMARY", "") - if p: - with open(p, "a", encoding="utf-8") as f: - f.write(text + "\n") - - -# ─── policy ────────────────────────────────────────────────────────────────── - -def load_policy() -> dict[str, Any]: - if POLICY_PATH.exists(): - try: return json.loads(POLICY_PATH.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): pass - return { - "version": 1, - "blocked_path_patterns": [ - r"(^|/)(\.env|.*secret.*|.*credential.*|.*token.*|.*private.*|.*\.pem|.*\.key)$", - ], - "max_changed_files": 50, - "max_changed_lines": 5000, - } - - -def compile_patterns(policy: dict[str, Any]) -> list[re.Pattern[str]]: - pp: list[re.Pattern[str]] = [] - for p in policy.get("blocked_path_patterns", []): - if isinstance(p, str) and p.strip(): - try: pp.append(re.compile(p, re.IGNORECASE)) - except re.error: pass - return pp - - -# ─── static guard ──────────────────────────────────────────────────────────── - -_SENSITIVE = re.compile( - r'(?:api[_\s]?key|secret|password|token|credential|private[_\s]?key)\s*[:=]\s*["\']' - r'(?!\$\{\{|{{|example|placeholder|test|your[-_\s]|xxx|TODO|CHANGEME)[^"\']{12,}["\']', - re.IGNORECASE, -) - - -def scan_diff(diff_text: str, path_patterns: list[re.Pattern[str]]) -> list[str]: - violations: list[str] = [] - current = "" - for line in diff_text.splitlines(): - if line.startswith("diff --git "): - parts = line.split(" ") - current = parts[3][2:] if len(parts) >= 4 and parts[3].startswith("b/") else "" - for pat in path_patterns: - if current and pat.search(current): - violations.append(f"**Blocked file**: `{current}` matches `{pat.pattern}`") - break - continue - if line.startswith("+++ b/"): current = line[6:]; continue - if not line.startswith("+") or line.startswith("+++"): continue - m = _SENSITIVE.search(line[1:]) - if m: - violations.append(f"**Hardcoded secret** in `{current}`: `{m.group(0)[:100]}`") - return list(dict.fromkeys(violations)) - - -def check_metadata(files: list[dict[str, Any]], policy: dict[str, Any]) -> list[str]: - issues: list[str] = [] - mx_f = policy.get("max_changed_files", 50) - mx_l = policy.get("max_changed_lines", 5000) - ta = sum(f.get("additions", 0) or 0 for f in files) - td = sum(f.get("deletions", 0) or 0 for f in files) - for f in files: - fn = f.get("filename", "?") - st = (f.get("status") or "").lower().strip() - if st == "removed": issues.append(f"**File deleted**: `{fn}` — verify intentional") - elif st == "renamed": issues.append(f"**File renamed**: `{f.get('previous_filename', '?')}` → `{fn}`") - if len(files) > mx_f: - issues.append(f"**Too many files**: {len(files)} changed (limit {mx_f})") - if ta + td > mx_l: - issues.append(f"**Too many lines**: {ta + td} changed (limit {mx_l})") - return issues - - -def run_static_guard(token: str, repo: str, pr_number: int) -> int: - """Return 0 if clean, 1 if blocked.""" - policy = load_policy() - files: list[dict[str, Any]] = [] - page = 1 - while True: - try: - batch = github_request(token, "GET", - f"/repos/{repo}/pulls/{pr_number}/files?per_page=100&page={page}") - except RuntimeError: break - if not isinstance(batch, list) or not batch: break - files.extend(batch) - if len(batch) < 100: break - page += 1 - - diff_text = "" - try: - req = urllib.request.Request( - f"{API_BASE}/repos/{repo}/pulls/{pr_number}", - headers={ - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github.v3.diff", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "codex-review-gate", - }, - ) - with urllib.request.urlopen(req, timeout=30) as resp: - diff_text = resp.read().decode("utf-8", errors="replace") - except Exception: pass - - issues = check_metadata(files, policy) + scan_diff(diff_text, compile_patterns(policy)) - if not issues: return 0 - - print(f"STATIC → BLOCKED: {len(issues)} issue(s)") - for i in issues: print(f" • {i}") - step_summary(f"## Merge blocked: {len(issues)} static issue(s)\n\n" + - "\n".join(f"- {i}" for i in issues)) - return 1 - - -# ─── app review ────────────────────────────────────────────────────────────── - -def get_codex_review(token: str, repo: str, pr_number: int) -> dict[str, Any] | None: - reviews = github_request(token, "GET", f"/repos/{repo}/pulls/{pr_number}/reviews?per_page=100") - if not isinstance(reviews, list): return None - for r in reversed(reviews): - if isinstance(r, dict) and (r.get("user") or {}).get("login") == BOT_LOGIN: - return r - return None - - -def app_decision(review: dict[str, Any] | None) -> tuple[int, str, str]: - """(exit_code, title, summary)""" - if review is None: - return (0, "Codex: no review — passed through", - "Codex did not respond in time. Merge allowed to avoid blocking development.") - state = (review.get("state") or "").strip().upper() - url = review.get("html_url", "") - body = (review.get("body") or "").strip() - at = review.get("submitted_at", "") - - if state == "CHANGES_REQUESTED": - snippet = (body[:500] + "...") if len(body) > 500 else body - return (1, "Codex: changes requested — MERGE BLOCKED", - f"Codex **requested changes** at {at}.\n\n{snippet}\n\n[View review]({url})") - if state == "APPROVED": - return (0, "Codex: approved", f"Codex approved at {at}. [View review]({url})") - return (0, f"Codex: reviewed ({state.lower()})", - f"Codex state `{state}` at {at}. Not blocking. [View review]({url})") - - -# ─── main ──────────────────────────────────────────────────────────────────── - -def main() -> int: - token = env("GH_TOKEN") or env("GITHUB_TOKEN") - repo = env("GITHUB_REPOSITORY") - if not token or not repo: - print("::error::GH_TOKEN + GITHUB_REPOSITORY required", file=sys.stderr) - return 1 - - event_path = Path(os.environ.get("GITHUB_EVENT_PATH", "")) - if not event_path.exists(): - print("::error::GITHUB_EVENT_PATH missing", file=sys.stderr) - return 1 - - event = json.loads(event_path.read_text(encoding="utf-8")) - event_name = env("GITHUB_EVENT_NAME", "") - pr = event.get("pull_request") or {} - pr_number = pr.get("number") - head_sha = (pr.get("head") or {}).get("sha") - if not pr_number or not head_sha: - print("::warning::Cannot resolve PR context"); return 0 - - print(f"PR #{pr_number} sha={head_sha[:12]} event={event_name}") - - # ── Phase 1: Static guard (skip on review-only events) ──────────── - if event_name != "pull_request_review": - try: rc = run_static_guard(token, repo, pr_number) - except RuntimeError as exc: - print(f"::warning::Static guard error: {exc}"); rc = 0 - if rc != 0: return 1 - print("STATIC → clean") - - # ── Phase 2: App review ─────────────────────────────────────────── - # REACT: Codex just submitted a review - review_event = event.get("review") or {} - if event_name == "pull_request_review" and (review_event.get("user") or {}).get("login") == BOT_LOGIN: - rc, title, summary = app_decision(review_event) - print(f"REACT → exit={rc}: {title}") - step_summary(f"## {title}\n\n{summary}") - return rc - - # WAIT: poll for existing or upcoming review - try: existing = get_codex_review(token, repo, pr_number) - except RuntimeError: existing = None - - if existing is not None: - rc, title, summary = app_decision(existing) - print(f"EXISTING → exit={rc}: {title}") - step_summary(f"## {title}\n\n{summary}") - return rc - - poll_s = env_int("CODEX_GATE_POLL_SECONDS", 30) - max_w = env_int("CODEX_GATE_MAX_WAIT_MINUTES", 5) - deadline = time.time() + max_w * 60 - print(f"WAIT → polling every {poll_s}s for up to {max_w}min") - - while time.time() < deadline: - time.sleep(poll_s) - try: review = get_codex_review(token, repo, pr_number) - except RuntimeError: continue - if review is not None: - rc, title, summary = app_decision(review) - print(f"WAIT → found review → exit={rc}: {title}") - step_summary(f"## {title}\n\n{summary}") - return rc - - # Timeout - print(f"TIMEOUT → Codex did not respond in {max_w}min; passing through") - step_summary(f"## Codex: timeout after {max_w}min\n\nPassed through to avoid blocking development.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/post_codex_auto_merge_decision_comment.py b/scripts/post_codex_auto_merge_decision_comment.py deleted file mode 100644 index 4f3a187..0000000 --- a/scripts/post_codex_auto_merge_decision_comment.py +++ /dev/null @@ -1,452 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import os -from pathlib import Path -import re -import sys -from typing import Any, Callable -import urllib.error -import urllib.parse -import urllib.request - - -DEFAULT_API_URL = "https://api.github.com" -DEFAULT_TIMEOUT_SECONDS = 30 -MARKER_PREFIX = "" - - -def extract_monthly_issue_number(pr: dict[str, Any], decision: dict[str, Any]) -> int | None: - body = str(pr.get("body") or "") - marker_prefix = str(decision.get("monthly_marker_prefix") or "", body) - if not match: - return None - return int(match.group(1)) - - -def _string_items(value: Any) -> list[str]: - if not isinstance(value, list): - return [] - return [str(item) for item in value if str(item).strip()] - - -def _changed_file_paths(pr: dict[str, Any]) -> list[str]: - files = pr.get("files") - if not isinstance(files, list): - return [] - paths: list[str] = [] - for item in files: - if isinstance(item, dict): - path = str(item.get("path") or "").strip() - if path: - paths.append(path) - return paths - - -def _decision_label(decision: dict[str, Any], field_name: str, fallback: str) -> str: - value = decision.get(field_name) - if isinstance(value, str) and value.strip() and "\n" not in value and "\r" not in value: - return value.strip() - return fallback - - -def _request_error_message(exc: urllib.error.HTTPError | urllib.error.URLError) -> str: - if isinstance(exc, urllib.error.HTTPError): - return f"HTTP {exc.code}" - return str(exc.reason) - - -def _bullet_list(items: list[str], *, max_items: int = MAX_LIST_ITEMS, code: bool = False) -> str: - if not items: - return "- None" - shown = items[:max_items] - lines = [f"- `{item}`" if code else f"- {item}" for item in shown] - if len(items) > max_items: - lines.append(f"- _{len(items) - max_items} more item(s) omitted; see the workflow artifact for the full details._") - return "\n".join(lines) - - -def build_guard_decision_comment(*, pr: dict[str, Any], decision: dict[str, Any]) -> str: - pr_number = positive_int(str(pr.get("number") or 0)) - marker = guard_marker(pr_number) - pr_url = str(pr.get("url") or "") or f"PR #{pr_number}" - reason = str(decision.get("reason") or "unknown") - risk_level = str(decision.get("risk_level") or "unknown") - review_decision = str(decision.get("review_decision") or "n/a") - changed_files = _changed_file_paths(pr) - blocked_files = _string_items(decision.get("blocked_files")) - medium_risk_files = _string_items(decision.get("medium_risk_files")) - risk_reasons = _string_items(decision.get("risk_reasons")) - next_action = ( - "Treat this as a human-review item before merge." - if risk_level == "high" or reason in {"human_review_required", "policy_errors", "pr_metadata_mismatch", "blocked_files"} - else "Review the PR labels and guard output before manually rerunning auto-merge." - ) - return ( - f"{marker}\n" - "## Codex Auto-Merge Guard Decision\n\n" - "The source auto-merge workflow evaluated this Codex remediation PR and did **not** merge it. " - "This comment is updated automatically so blocked unattended maintenance does not require opening workflow artifacts first.\n\n" - f"- PR: {pr_url}\n" - f"- Decision: `skip`\n" - f"- Reason: `{reason}`\n" - f"- Risk level: `{risk_level}`\n" - f"- Review decision: `{review_decision}`\n" - f"- Changed files: `{len(changed_files)}`\n" - f"- Changed lines: `{decision.get('changed_lines') if decision.get('changed_lines') is not None else 'n/a'}`\n" - f"- Next action: {next_action}\n\n" - "### Risk reasons\n\n" - f"{_bullet_list(risk_reasons)}\n\n" - "### Blocked files\n\n" - f"{_bullet_list(blocked_files, code=True)}\n\n" - "### Medium-risk files\n\n" - f"{_bullet_list(medium_risk_files, code=True)}\n" - ) - - -def append_label_actions_to_comment(body: str, label_actions: list[str]) -> str: - if not label_actions: - return body - return f"{body.rstrip()}\n\n### Label hygiene\n\n{_bullet_list(label_actions)}\n" - - -def write_output_file(path: Path, body: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(body, encoding="utf-8") - - -def append_github_output(values: dict[str, str]) -> None: - output = os.environ.get("GITHUB_OUTPUT") - if not output: - return - with open(output, "a", encoding="utf-8") as handle: - for key, value in values.items(): - print(f"{key}={value}", file=handle) - - -def find_existing_guard_comment(comments: list[Any], marker: str) -> int | None: - for comment in comments: - if not isinstance(comment, dict): - continue - body = comment.get("body") - comment_id = comment.get("id") - if isinstance(body, str) and body.startswith(marker) and isinstance(comment_id, int): - return comment_id - return None - - -def list_issue_comments( - *, - api_url: str, - repo: str, - token: str, - issue_number: int, - request_fn: RequestFn = github_request, -) -> list[Any]: - comments: list[Any] = [] - page = 1 - while True: - payload = request_fn( - "GET", - f"{api_url}/repos/{repo}/issues/{issue_number}/comments?per_page=100&page={page}", - token, - None, - ) - if not isinstance(payload, list): - raise GuardDecisionCommentError(f"issue comments response is invalid for issue #{issue_number}") - comments.extend(payload) - if len(payload) < 100: - break - page += 1 - return comments - - -def upsert_guard_decision_comment( - *, - api_url: str, - repo: str, - token: str, - issue_number: int, - body: str, - marker: str, - request_fn: RequestFn = github_request, -) -> tuple[str, str]: - comments = list_issue_comments( - api_url=api_url, - repo=repo, - token=token, - issue_number=issue_number, - request_fn=request_fn, - ) - existing_id = find_existing_guard_comment(comments, marker) - if existing_id is not None: - updated = request_fn( - "PATCH", - f"{api_url}/repos/{repo}/issues/comments/{existing_id}", - token, - {"body": body}, - ) - return "updated", str(updated.get("html_url", "")) if isinstance(updated, dict) else "" - created = request_fn( - "POST", - f"{api_url}/repos/{repo}/issues/{issue_number}/comments", - token, - {"body": body}, - ) - return "created", str(created.get("html_url", "")) if isinstance(created, dict) else "" - - -def sync_guard_decision_labels( - *, - api_url: str, - repo: str, - token: str, - pr_number: int, - decision: dict[str, Any], - request_fn: RequestFn = github_request, -) -> list[str]: - actions: list[str] = [] - auto_merge_label = _decision_label(decision, "auto_merge_label", DEFAULT_AUTO_MERGE_LABEL) - human_review_label = _decision_label(decision, "human_review_label", DEFAULT_HUMAN_REVIEW_LABEL) - if auto_merge_label == human_review_label: - return [ - "Skipped guard decision label sync because auto-merge and human-review labels are identical.", - ] - encoded_auto_merge_label = urllib.parse.quote(auto_merge_label, safe="") - - try: - request_fn( - "DELETE", - f"{api_url}/repos/{repo}/issues/{pr_number}/labels/{encoded_auto_merge_label}", - token, - None, - ) - except urllib.error.HTTPError as exc: - if exc.code != 404: - actions.append( - f"Could not remove stale auto-merge label `{auto_merge_label}` from PR #{pr_number}: " - f"{_request_error_message(exc)}." - ) - else: - actions.append(f"Auto-merge label `{auto_merge_label}` was already absent.") - except urllib.error.URLError as exc: - actions.append( - f"Could not remove stale auto-merge label `{auto_merge_label}` from PR #{pr_number}: " - f"{_request_error_message(exc)}." - ) - else: - actions.append(f"Removed stale auto-merge label `{auto_merge_label}` from PR #{pr_number}.") - - if str(decision.get("risk_level") or "").strip().lower() == "high": - try: - request_fn( - "POST", - f"{api_url}/repos/{repo}/issues/{pr_number}/labels", - token, - {"labels": [human_review_label]}, - ) - except (urllib.error.HTTPError, urllib.error.URLError) as exc: - actions.append( - f"Could not ensure human-review label `{human_review_label}` on PR #{pr_number}: " - f"{_request_error_message(exc)}." - ) - else: - actions.append(f"Ensured human-review label `{human_review_label}` on PR #{pr_number}.") - return actions - - -def try_update_comment_with_label_actions( - *, - api_url: str, - repo: str, - token: str, - issue_number: int, - body: str, - marker: str, - request_fn: RequestFn = github_request, -) -> tuple[str, str, str]: - try: - action, comment_url = upsert_guard_decision_comment( - api_url=api_url, - repo=repo, - token=token, - issue_number=issue_number, - body=body, - marker=marker, - request_fn=request_fn, - ) - except (GuardDecisionCommentError, urllib.error.HTTPError, urllib.error.URLError) as exc: - return "", "", _request_error_message(exc) if isinstance(exc, urllib.error.URLError) else str(exc) - return action, comment_url, "" - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Comment the Codex auto-merge guard skip decision back to the monthly issue.") - parser.add_argument("--repo", required=True) - parser.add_argument("--pr-json", required=True, type=Path) - parser.add_argument("--decision-json", required=True, type=Path) - parser.add_argument("--output-file", type=Path) - parser.add_argument("--dry-run", action="store_true") - parser.add_argument("--sync-labels", action="store_true") - parser.add_argument("--api-url", default=DEFAULT_API_URL) - return parser.parse_args() - - -def main() -> int: - args = parse_args() - pr = json.loads(args.pr_json.read_text(encoding="utf-8")) - decision = json.loads(args.decision_json.read_text(encoding="utf-8")) - body = build_guard_decision_comment(pr=pr, decision=decision) - marker = guard_marker(positive_int(str(pr.get("number") or 0))) - outputs: dict[str, str] = {} - if args.output_file: - write_output_file(args.output_file, body) - outputs["guard_decision_comment_file"] = str(args.output_file) - print(f"guard_decision_comment_file={args.output_file}") - - issue_number = extract_monthly_issue_number(pr, decision) - if issue_number is None: - outputs.update({"guard_decision_comment_action": "skipped_missing_issue_marker", "guard_decision_comment_url": ""}) - append_github_output(outputs) - print("guard_decision_comment_action=skipped_missing_issue_marker") - print("guard_decision_comment_url=") - return 0 - - if args.dry_run: - outputs.update({"guard_decision_comment_action": "dry_run", "guard_decision_comment_url": ""}) - append_github_output(outputs) - print("guard_decision_comment_action=dry_run") - print("guard_decision_comment_url=") - return 0 - - token = os.environ.get("GITHUB_TOKEN", "").strip() - if not token: - if outputs: - append_github_output(outputs) - print("GITHUB_TOKEN is required", file=sys.stderr) - return 1 - try: - action, comment_url = upsert_guard_decision_comment( - api_url=args.api_url.rstrip("/"), - repo=args.repo, - token=token, - issue_number=issue_number, - body=body, - marker=marker, - request_fn=github_request, - ) - label_actions: list[str] = [] - if args.sync_labels: - label_actions = sync_guard_decision_labels( - api_url=args.api_url.rstrip("/"), - repo=args.repo, - token=token, - pr_number=positive_int(str(pr.get("number") or 0)), - decision=decision, - request_fn=github_request, - ) - if label_actions: - body = append_label_actions_to_comment(body, label_actions) - if args.output_file: - write_output_file(args.output_file, body) - label_comment_action, label_comment_url, label_comment_error = try_update_comment_with_label_actions( - api_url=args.api_url.rstrip("/"), - repo=args.repo, - token=token, - issue_number=issue_number, - body=body, - marker=marker, - request_fn=github_request, - ) - if label_comment_action: - action = label_comment_action - comment_url = label_comment_url or comment_url - label_actions.append("Recorded label hygiene actions in the guard decision comment.") - else: - label_actions.append( - f"Could not update guard decision comment with label hygiene actions: {label_comment_error}." - ) - body = append_label_actions_to_comment( - build_guard_decision_comment(pr=pr, decision=decision), - label_actions, - ) - if args.output_file: - write_output_file(args.output_file, body) - except GuardDecisionCommentError as exc: - if outputs: - append_github_output(outputs) - print(f"Guard decision comment failed: {exc}", file=sys.stderr) - return 1 - except urllib.error.HTTPError as exc: - if outputs: - append_github_output(outputs) - detail = exc.read().decode("utf-8", errors="replace") - print(f"GitHub API request failed: {exc.code} {detail}", file=sys.stderr) - return 1 - except urllib.error.URLError as exc: - if outputs: - append_github_output(outputs) - print(f"GitHub API request failed: {exc.reason}", file=sys.stderr) - return 1 - - outputs.update({"guard_decision_comment_action": action, "guard_decision_comment_url": comment_url}) - if args.sync_labels: - outputs["guard_decision_label_actions"] = "; ".join(label_actions) - append_github_output(outputs) - print(f"guard_decision_comment_action={action}") - print(f"guard_decision_comment_url={comment_url}") - if args.sync_labels: - print(f"guard_decision_label_actions={'; '.join(label_actions)}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/post_codex_auto_merge_preflight_comment.py b/scripts/post_codex_auto_merge_preflight_comment.py deleted file mode 100644 index 8f4f7c0..0000000 --- a/scripts/post_codex_auto_merge_preflight_comment.py +++ /dev/null @@ -1,295 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import os -from pathlib import Path -import re -import sys -from typing import Any, Callable -import urllib.error -import urllib.parse -import urllib.request - - -DEFAULT_API_URL = "https://api.github.com" -DEFAULT_TIMEOUT_SECONDS = 30 -MARKER_PREFIX = "" - - -def read_optional_text(path: Path, *, missing_message: str | None = None) -> str: - try: - return path.read_text(encoding="utf-8") - except OSError: - if missing_message: - return missing_message.format(path=path).rstrip() + "\n" - return ( - f"_Preflight file was not generated: `{path}`. " - "Treat guarded auto-merge as not ready until the full artifact is available._\n" - ) - - -def truncate_section(text: str, *, max_chars: int = MAX_SECTION_CHARS) -> str: - text = text.strip() - if len(text) <= max_chars: - return text - return text[:max_chars].rstrip() + "\n\n_Section truncated; see the workflow artifact for the full file._" - - -def extract_markdown_section(markdown: str, heading: str) -> str: - lines = markdown.splitlines() - start: int | None = None - for index, line in enumerate(lines): - if line.strip() == heading: - start = index - break - if start is None: - return "_Section not found; see the workflow artifact for the full file._" - collected: list[str] = [] - for index, line in enumerate(lines[start:], start=start): - if index != start and line.startswith("## "): - break - collected.append(line) - return "\n".join(collected).strip() - - -def build_preflight_comment( - *, - report_month: str, - readiness_text: str, - enablement_plan_text: str, - label_sync_text: str | None = None, -) -> str: - marker = preflight_marker(report_month) - readiness = truncate_section(readiness_text) - label_sync = truncate_section(label_sync_text) if label_sync_text is not None else "" - checklist = truncate_section(extract_markdown_section(enablement_plan_text, "## Enablement preflight checklist")) - label_sync_section = f"### Label sync snapshot\n\n{label_sync}\n\n" if label_sync else "" - return ( - f"{marker}\n" - "## Guarded Auto-Merge Preflight\n\n" - "This comment is updated by the monthly snapshot review workflow before dispatching AIAuditBridge. " - "It is informational only and does not approve or enable guarded auto-merge. " - "The complete label-sync report, readiness report, and enablement plan are attached to the monthly review bundle artifact.\n\n" - f"{label_sync_section}" - "### Readiness snapshot\n\n" - f"{readiness}\n\n" - "### Enablement checklist\n\n" - f"{checklist}\n" - ) - - -def write_output_file(path: Path, body: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(body, encoding="utf-8") - - -def append_github_output(values: dict[str, str]) -> None: - output = os.environ.get("GITHUB_OUTPUT") - if not output: - return - with open(output, "a", encoding="utf-8") as handle: - for key, value in values.items(): - print(f"{key}={value}", file=handle) - - -def find_existing_preflight_comment(comments: list[Any], marker: str) -> int | None: - for comment in comments: - if not isinstance(comment, dict): - continue - body = comment.get("body") - comment_id = comment.get("id") - if isinstance(body, str) and body.startswith(marker) and isinstance(comment_id, int): - return comment_id - return None - - -def list_issue_comments(*, api_url: str, repo: str, token: str, issue_number: int, request_fn: RequestFn = github_request) -> list[Any]: - comments: list[Any] = [] - page = 1 - while True: - payload = request_fn( - "GET", - f"{api_url}/repos/{repo}/issues/{issue_number}/comments?per_page=100&page={page}", - token, - None, - ) - if not isinstance(payload, list): - raise PreflightCommentError(f"issue comments response is invalid for issue #{issue_number}") - comments.extend(payload) - if len(payload) < 100: - break - page += 1 - return comments - - -def upsert_preflight_comment( - *, - api_url: str, - repo: str, - token: str, - issue_number: int, - body: str, - marker: str, - request_fn: RequestFn = github_request, -) -> tuple[str, str]: - comments = list_issue_comments( - api_url=api_url, - repo=repo, - token=token, - issue_number=issue_number, - request_fn=request_fn, - ) - existing_id = find_existing_preflight_comment(comments, marker) - if existing_id is not None: - updated = request_fn( - "PATCH", - f"{api_url}/repos/{repo}/issues/comments/{existing_id}", - token, - {"body": body}, - ) - return "updated", str(updated.get("html_url", "")) if isinstance(updated, dict) else "" - created = request_fn( - "POST", - f"{api_url}/repos/{repo}/issues/{issue_number}/comments", - token, - {"body": body}, - ) - return "created", str(created.get("html_url", "")) if isinstance(created, dict) else "" - - -def positive_int(value: str) -> int: - try: - parsed = int(value) - except ValueError as exc: - raise argparse.ArgumentTypeError("must be an integer") from exc - if parsed <= 0: - raise argparse.ArgumentTypeError("must be positive") - return parsed - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Create or update the Codex guarded auto-merge preflight issue comment.") - parser.add_argument("--repo", required=True) - parser.add_argument("--issue-number", required=True, type=positive_int) - parser.add_argument("--report-month", required=True) - parser.add_argument("--readiness-file", required=True, type=Path) - parser.add_argument("--label-sync-file", type=Path) - parser.add_argument("--enablement-plan-file", required=True, type=Path) - parser.add_argument("--output-file", type=Path, help="Optional local file for the rendered comment body.") - parser.add_argument("--dry-run", action="store_true", help="Render the comment body without calling GitHub.") - parser.add_argument("--api-url", default=DEFAULT_API_URL) - return parser.parse_args() - - -def main() -> int: - args = parse_args() - marker = preflight_marker(args.report_month) - body = build_preflight_comment( - report_month=args.report_month, - readiness_text=read_optional_text(args.readiness_file), - label_sync_text=( - read_optional_text( - args.label_sync_file, - missing_message=( - "_Label sync file was not generated: `{path}`. " - "This usually means guarded auto-merge was not requested, or label sync failed before writing its report._" - ), - ) - if args.label_sync_file - else None - ), - enablement_plan_text=read_optional_text(args.enablement_plan_file), - ) - outputs: dict[str, str] = {} - if args.output_file: - write_output_file(args.output_file, body) - outputs["preflight_comment_file"] = str(args.output_file) - print(f"preflight_comment_file={args.output_file}") - if args.dry_run: - outputs.update({"preflight_comment_action": "dry_run", "preflight_comment_url": ""}) - append_github_output(outputs) - print("preflight_comment_action=dry_run") - print("preflight_comment_url=") - return 0 - - token = os.environ.get("GITHUB_TOKEN", "").strip() - if not token: - if outputs: - append_github_output(outputs) - print("GITHUB_TOKEN is required", file=sys.stderr) - return 1 - try: - action, comment_url = upsert_preflight_comment( - api_url=args.api_url.rstrip("/"), - repo=args.repo, - token=token, - issue_number=args.issue_number, - body=body, - marker=marker, - ) - except PreflightCommentError as exc: - if outputs: - append_github_output(outputs) - print(f"Preflight comment failed: {exc}", file=sys.stderr) - return 1 - except urllib.error.HTTPError as exc: - if outputs: - append_github_output(outputs) - detail = exc.read().decode("utf-8", errors="replace") - print(f"GitHub API request failed: {exc.code} {detail}", file=sys.stderr) - return 1 - except urllib.error.URLError as exc: - if outputs: - append_github_output(outputs) - print(f"GitHub API request failed: {exc.reason}", file=sys.stderr) - return 1 - - outputs.update({"preflight_comment_action": action, "preflight_comment_url": comment_url}) - append_github_output(outputs) - print(f"preflight_comment_action={action}") - print(f"preflight_comment_url={comment_url}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/sync_codex_auto_merge_labels.py b/scripts/sync_codex_auto_merge_labels.py deleted file mode 100644 index 01c1920..0000000 --- a/scripts/sync_codex_auto_merge_labels.py +++ /dev/null @@ -1,212 +0,0 @@ -from __future__ import annotations - -import argparse -import os -from pathlib import Path -import sys -from typing import Any -import urllib.parse - -PROJECT_ROOT = Path(__file__).resolve().parents[1] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) - -from scripts.check_codex_auto_merge_readiness import ( # noqa: E402 - DEFAULT_API_URL, - DEFAULT_POLICY_PATH, - GitHubApiError, - ReadinessError, - github_request, - load_policy_labels, - parse_bool, - validate_repo, -) - -DEFAULT_AUTO_MERGE_COLOR = "0e8a16" -DEFAULT_HUMAN_REVIEW_COLOR = "d93f0b" -DEFAULT_AUTO_MERGE_DESCRIPTION = "Guarded Codex remediation auto-merge may proceed after source CI and merge guard pass." -DEFAULT_HUMAN_REVIEW_DESCRIPTION = "Codex remediation PR requires human review before merge." - - -class LabelSyncError(RuntimeError): - pass - - -def _single_line(value: str) -> str: - normalized = value.strip() - if not normalized or "\n" in normalized or "\r" in normalized: - raise LabelSyncError("label names must be non-empty single-line values") - return normalized - - -def ensure_label( - *, - api_url: str, - repo: str, - token: str, - name: str, - color: str, - description: str, -) -> dict[str, str]: - label_name = _single_line(name) - encoded_label = urllib.parse.quote(label_name, safe="") - labels_url = f"{api_url.rstrip('/')}/repos/{repo}/labels" - label_url = f"{labels_url}/{encoded_label}" - try: - github_request("GET", label_url, token) - except GitHubApiError as exc: - if exc.status_code != 404: - raise - try: - github_request( - "POST", - labels_url, - token, - { - "name": label_name, - "color": color, - "description": description, - }, - ) - except GitHubApiError as create_exc: - if create_exc.status_code != 422: - raise - # Another workflow run may have created the label after our GET. - github_request("GET", label_url, token) - return {"label": label_name, "status": "exists_after_race"} - return {"label": label_name, "status": "created"} - return {"label": label_name, "status": "exists"} - - -def sync_codex_auto_merge_labels( - *, - auto_merge: bool, - repo: str, - token: str, - policy_path: Path = DEFAULT_POLICY_PATH, - api_url: str = DEFAULT_API_URL, -) -> dict[str, Any]: - if not auto_merge: - return { - "ready": True, - "skipped": True, - "actions": ["CODEX_AUDIT_AUTO_MERGE is false; label sync skipped."], - "errors": [], - "auto_merge_label": None, - "human_review_label": None, - } - - actions: list[str] = [] - errors: list[str] = [] - try: - validated_repo = validate_repo(repo) - labels = load_policy_labels(policy_path) - except ReadinessError as exc: - return { - "ready": False, - "skipped": False, - "actions": actions, - "errors": [str(exc)], - "auto_merge_label": None, - "human_review_label": None, - } - - auto_merge_label = labels["auto_merge_label"] - human_review_label = labels["human_review_label"] - if not token.strip(): - return { - "ready": False, - "skipped": False, - "actions": actions, - "errors": ["GITHUB_TOKEN is required to sync guarded auto-merge labels"], - "auto_merge_label": auto_merge_label, - "human_review_label": human_review_label, - } - - label_specs = [ - (auto_merge_label, DEFAULT_AUTO_MERGE_COLOR, DEFAULT_AUTO_MERGE_DESCRIPTION), - (human_review_label, DEFAULT_HUMAN_REVIEW_COLOR, DEFAULT_HUMAN_REVIEW_DESCRIPTION), - ] - for label, color, description in label_specs: - try: - result = ensure_label( - api_url=api_url, - repo=validated_repo, - token=token.strip(), - name=label, - color=color, - description=description, - ) - except (GitHubApiError, LabelSyncError) as exc: - errors.append(f"label `{label}` sync failed: {exc}") - else: - if result["status"] == "created": - actions.append(f"Created label `{label}`.") - elif result["status"] == "exists_after_race": - actions.append(f"Label `{label}` already existed after a concurrent create.") - else: - actions.append(f"Label `{label}` already exists.") - - return { - "ready": not errors, - "skipped": False, - "actions": actions, - "errors": errors, - "auto_merge_label": auto_merge_label, - "human_review_label": human_review_label, - } - - -def render_summary(decision: dict[str, Any]) -> str: - lines = [ - "## Codex Auto-Merge Label Sync", - f"- Ready: `{'yes' if decision['ready'] else 'no'}`", - f"- Skipped: `{'yes' if decision['skipped'] else 'no'}`", - ] - if decision.get("auto_merge_label"): - lines.append(f"- Auto-merge label: `{decision['auto_merge_label']}`") - if decision.get("human_review_label"): - lines.append(f"- Human-review label: `{decision['human_review_label']}`") - if decision.get("actions"): - lines.extend(["", "### Actions"]) - lines.extend(f"- {item}" for item in decision["actions"]) - if decision.get("errors"): - lines.extend(["", "### Errors"]) - lines.extend(f"- {item}" for item in decision["errors"]) - return "\n".join(lines).strip() + "\n" - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Ensure guarded Codex auto-merge labels exist in the source repo.") - parser.add_argument("--repo", required=True) - parser.add_argument("--auto-merge", default=os.environ.get("CODEX_AUDIT_AUTO_MERGE", "false")) - parser.add_argument("--policy-file", type=Path, default=DEFAULT_POLICY_PATH) - parser.add_argument("--api-url", default=DEFAULT_API_URL) - parser.add_argument("--summary-file", type=Path) - return parser.parse_args() - - -def main() -> int: - args = parse_args() - decision = sync_codex_auto_merge_labels( - auto_merge=parse_bool(args.auto_merge), - repo=args.repo, - token=os.environ.get("GITHUB_TOKEN", ""), - policy_path=args.policy_file, - api_url=args.api_url, - ) - summary = render_summary(decision) - if args.summary_file: - args.summary_file.parent.mkdir(parents=True, exist_ok=True) - args.summary_file.write_text(summary, encoding="utf-8") - step_summary = os.environ.get("GITHUB_STEP_SUMMARY") - if step_summary: - with open(step_summary, "a", encoding="utf-8") as handle: - handle.write(summary) - print(f"ready={'true' if decision['ready'] else 'false'}") - print(f"skipped={'true' if decision['skipped'] else 'false'}") - return 0 if decision["ready"] else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_evaluate_codex_pr_merge.py b/tests/test_evaluate_codex_pr_merge.py deleted file mode 100644 index 4a6ce3f..0000000 --- a/tests/test_evaluate_codex_pr_merge.py +++ /dev/null @@ -1,965 +0,0 @@ -from __future__ import annotations - -import ast -import json -from pathlib import Path - -import pytest - -from scripts.evaluate_codex_pr_merge import ( - AUTO_MERGE_LABEL, - DEFAULT_POLICY, - HUMAN_REVIEW_LABEL, - evaluate_changed_files, - evaluate_pr, - load_policy, -) - - -def _load_bridge_default_policy(bridge_script: Path) -> dict[str, object]: - tree = ast.parse(bridge_script.read_text(encoding="utf-8")) - constants: dict[str, object] = {} - for node in tree.body: - if isinstance(node, ast.Assign): - names = [target.id for target in node.targets if isinstance(target, ast.Name)] - if any(name in names for name in { - "GUARDED_AUTO_MERGE_LABEL", - "HUMAN_REVIEW_LABEL", - "GUARDED_AUTO_MERGE_LOW_RISK_PREFIXES", - "GUARDED_AUTO_MERGE_LOW_RISK_EXACT", - "GUARDED_AUTO_MERGE_MEDIUM_RISK_EXACT", - "DEFAULT_GUARDED_AUTO_MERGE_MAX_CHANGED_FILES", - "DEFAULT_GUARDED_AUTO_MERGE_MAX_CHANGED_LINES", - }): - constants.update({name: ast.literal_eval(node.value) for name in names}) - required = { - "GUARDED_AUTO_MERGE_LABEL", - "HUMAN_REVIEW_LABEL", - "GUARDED_AUTO_MERGE_LOW_RISK_PREFIXES", - "GUARDED_AUTO_MERGE_LOW_RISK_EXACT", - "GUARDED_AUTO_MERGE_MEDIUM_RISK_EXACT", - "DEFAULT_GUARDED_AUTO_MERGE_MAX_CHANGED_FILES", - "DEFAULT_GUARDED_AUTO_MERGE_MAX_CHANGED_LINES", - } - missing = required - constants.keys() - if missing: - raise AssertionError(f"Bridge policy constants not found: {sorted(missing)}") - return { - "version": 1, - "auto_merge_label": constants["GUARDED_AUTO_MERGE_LABEL"], - "human_review_label": constants["HUMAN_REVIEW_LABEL"], - "monthly_marker_prefix": "", - "labels": [{"name": AUTO_MERGE_LABEL}], - "files": [{"path": "README.md"}], - }, - policy=policy, - ) - - assert not decision["should_merge"] - assert decision["reason"] == "policy_errors" - assert decision["policy_errors"] == ["invalid auto-merge policy requires human review"] - assert decision["risk_reasons"] == ["invalid auto-merge policy requires human review"] - - -def test_evaluate_changed_files_fails_closed_when_existing_policy_schema_is_incomplete(tmp_path: Path) -> None: - policy_path = tmp_path / "codex_auto_merge_policy.json" - policy_path.write_text("{}", encoding="utf-8") - - policy = load_policy(policy_path) - decision = evaluate_changed_files(["README.md"], policy=policy) - - assert not decision["allowed"] - assert decision["blocked_files"] == ["README.md"] - assert decision["risk_reasons"] == ["invalid auto-merge policy schema requires human review"] - - -def test_evaluate_changed_files_fails_closed_when_policy_labels_match(tmp_path: Path) -> None: - policy_path = tmp_path / "codex_auto_merge_policy.json" - payload = json.loads(json.dumps(DEFAULT_POLICY)) - payload["human_review_label"] = payload["auto_merge_label"] - policy_path.write_text(json.dumps(payload), encoding="utf-8") - - policy = load_policy(policy_path) - decision = evaluate_changed_files(["README.md"], policy=policy) - - assert not decision["allowed"] - assert decision["blocked_files"] == ["README.md"] - assert decision["risk_reasons"] == [ - "auto-merge and human-review labels must be distinct requires human review" - ] - - -def test_evaluate_changed_files_fails_closed_on_unsupported_policy_version(tmp_path: Path) -> None: - policy_path = tmp_path / "codex_auto_merge_policy.json" - policy_path.write_text( - """ -{ - "version": 2, - "auto_merge_label": "auto-merge-ok", - "human_review_label": "human-review-required", - "monthly_marker_prefix": "", - "url": "https://github.com/example/repo/pull/1", - "labels": [{"name": AUTO_MERGE_LABEL}], - "files": [{"path": "docs/operator_runbook.md", "status": "modified"}], - "changedFiles": 1, - "additions": 12, - "deletions": 3, - "reviewDecision": None, - "baseRefName": "main", - "headRefName": "codex/monthly-review-issue-12", - "headRepositoryOwner": {"login": "QuantStrategyLab"}, - "headRepository": {"nameWithOwner": "QuantStrategyLab/UsEquitySnapshotPipelines"}, - "isCrossRepository": False, - } - - decision = evaluate_pr( - pr, - expected_base_ref="main", - expected_head_ref="codex/monthly-review-issue-12", - expected_head_owner="QuantStrategyLab", - expected_head_repository="QuantStrategyLab/UsEquitySnapshotPipelines", - require_same_repository=True, - ) - - assert decision["should_merge"] - assert decision["reason"] == "ready" - assert decision["risk_level"] == "low" - assert not decision["has_human_review_label"] - assert decision["changed_lines"] == 15 - assert decision["review_decision"] == "" - - -def test_evaluate_pr_human_review_label_vetoes_auto_merge() -> None: - pr = { - "isDraft": False, - "body": "", - "url": "https://github.com/example/repo/pull/1", - "labels": [{"name": AUTO_MERGE_LABEL}, {"name": HUMAN_REVIEW_LABEL}], - "files": [{"path": "docs/operator_runbook.md"}], - "changedFiles": 1, - "baseRefName": "main", - "headRefName": "codex/monthly-review-issue-12", - "headRepositoryOwner": {"login": "QuantStrategyLab"}, - "headRepository": {"nameWithOwner": "QuantStrategyLab/UsEquitySnapshotPipelines"}, - "isCrossRepository": False, - } - - decision = evaluate_pr( - pr, - expected_base_ref="main", - expected_head_ref="codex/monthly-review-issue-12", - expected_head_owner="QuantStrategyLab", - expected_head_repository="QuantStrategyLab/UsEquitySnapshotPipelines", - require_same_repository=True, - ) - - assert not decision["should_merge"] - assert decision["reason"] == "human_review_required" - assert decision["risk_level"] == "high" - assert decision["has_human_review_label"] - assert decision["human_review_label"] == HUMAN_REVIEW_LABEL - assert decision["risk_reasons"] == [f"human-review label `{HUMAN_REVIEW_LABEL}` requires manual review"] - assert decision["base_ref"] == "main" - assert decision["head_ref"] == "codex/monthly-review-issue-12" - assert decision["head_owner"] == "QuantStrategyLab" - assert decision["head_repository"] == "QuantStrategyLab/UsEquitySnapshotPipelines" - assert decision["is_cross_repository"] is False - assert decision["reported_changed_file_count"] == 1 - - -def test_evaluate_pr_blocks_large_low_risk_diff() -> None: - decision = evaluate_pr( - { - "isDraft": False, - "body": "", - "labels": [{"name": AUTO_MERGE_LABEL}], - "files": [{"path": "docs/operator_runbook.md"}], - "changedFiles": 1, - "additions": 1201, - "deletions": 0, - "baseRefName": "main", - "headRefName": "codex/monthly-review-issue-12", - "headRepositoryOwner": {"login": "QuantStrategyLab"}, - "headRepository": {"nameWithOwner": "QuantStrategyLab/UsEquitySnapshotPipelines"}, - "isCrossRepository": False, - }, - expected_base_ref="main", - expected_head_ref="codex/monthly-review-issue-12", - expected_head_owner="QuantStrategyLab", - expected_head_repository="QuantStrategyLab/UsEquitySnapshotPipelines", - require_same_repository=True, - ) - - assert not decision["should_merge"] - assert decision["reason"] == "policy_errors" - assert decision["risk_level"] == "high" - assert decision["changed_lines"] == 1201 - assert decision["risk_reasons"] == [ - "changed line count exceeds auto-merge limit requires human review: 1201 > 1200" - ] - - -def test_evaluate_pr_requires_changed_line_counts_for_auto_merge_label() -> None: - decision = evaluate_pr( - { - "isDraft": False, - "body": "", - "labels": [{"name": AUTO_MERGE_LABEL}], - "files": [{"path": "docs/operator_runbook.md"}], - "changedFiles": 1, - "baseRefName": "main", - "headRefName": "codex/monthly-review-issue-12", - "headRepositoryOwner": {"login": "QuantStrategyLab"}, - "headRepository": {"nameWithOwner": "QuantStrategyLab/UsEquitySnapshotPipelines"}, - "isCrossRepository": False, - }, - expected_base_ref="main", - expected_head_ref="codex/monthly-review-issue-12", - expected_head_owner="QuantStrategyLab", - expected_head_repository="QuantStrategyLab/UsEquitySnapshotPipelines", - require_same_repository=True, - ) - - assert not decision["should_merge"] - assert decision["reason"] == "policy_errors" - assert decision["risk_level"] == "high" - assert decision["risk_reasons"] == ["missing changed line counts require human review"] - - -def test_evaluate_pr_requires_review_decision_for_auto_merge_label() -> None: - decision = evaluate_pr( - { - "isDraft": False, - "body": "", - "labels": [{"name": AUTO_MERGE_LABEL}], - "files": [{"path": "docs/operator_runbook.md"}], - "changedFiles": 1, - "additions": 4, - "deletions": 1, - "baseRefName": "main", - "headRefName": "codex/monthly-review-issue-12", - "headRepositoryOwner": {"login": "QuantStrategyLab"}, - "headRepository": {"nameWithOwner": "QuantStrategyLab/UsEquitySnapshotPipelines"}, - "isCrossRepository": False, - }, - expected_base_ref="main", - expected_head_ref="codex/monthly-review-issue-12", - expected_head_owner="QuantStrategyLab", - expected_head_repository="QuantStrategyLab/UsEquitySnapshotPipelines", - require_same_repository=True, - ) - - assert not decision["should_merge"] - assert decision["reason"] == "pr_metadata_mismatch" - assert decision["risk_level"] == "high" - assert decision["metadata_errors"] == ["missing review decision requires human review"] - - -@pytest.mark.parametrize("review_decision", ["CHANGES_REQUESTED", "REVIEW_REQUIRED"]) -def test_evaluate_pr_blocks_requested_changes_review_decision(review_decision: str) -> None: - decision = evaluate_pr( - { - "isDraft": False, - "body": "", - "labels": [{"name": AUTO_MERGE_LABEL}], - "files": [{"path": "docs/operator_runbook.md"}], - "changedFiles": 1, - "additions": 4, - "deletions": 1, - "reviewDecision": review_decision, - "baseRefName": "main", - "headRefName": "codex/monthly-review-issue-12", - "headRepositoryOwner": {"login": "QuantStrategyLab"}, - "headRepository": {"nameWithOwner": "QuantStrategyLab/UsEquitySnapshotPipelines"}, - "isCrossRepository": False, - }, - expected_base_ref="main", - expected_head_ref="codex/monthly-review-issue-12", - expected_head_owner="QuantStrategyLab", - expected_head_repository="QuantStrategyLab/UsEquitySnapshotPipelines", - require_same_repository=True, - ) - - assert not decision["should_merge"] - assert decision["reason"] == "pr_metadata_mismatch" - assert decision["risk_level"] == "high" - assert decision["review_decision"] == review_decision - assert decision["metadata_errors"] == [f"review decision `{review_decision}` requires human review"] - - -def test_evaluate_pr_blocks_unexpected_pr_metadata() -> None: - pr = { - "isDraft": False, - "body": "", - "labels": [{"name": AUTO_MERGE_LABEL}], - "files": [{"path": "docs/operator_runbook.md"}], - "baseRefName": "release", - "headRefName": "codex/monthly-review-issue-99", - "headRepositoryOwner": {"login": "external-user"}, - "headRepository": {"nameWithOwner": "external-user/UsEquitySnapshotPipelines"}, - "isCrossRepository": True, - } - - decision = evaluate_pr( - pr, - expected_base_ref="main", - expected_head_ref="codex/monthly-review-issue-12", - expected_head_owner="QuantStrategyLab", - expected_head_repository="QuantStrategyLab/UsEquitySnapshotPipelines", - require_same_repository=True, - ) - - assert not decision["should_merge"] - assert decision["reason"] == "pr_metadata_mismatch" - assert decision["risk_level"] == "high" - assert decision["metadata_errors"] == [ - "unexpected PR base ref requires human review", - "unexpected PR head ref requires human review", - "unexpected PR head owner requires human review", - "unexpected PR head repository requires human review", - "cross-repository PR requires human review", - ] - - - - -def test_evaluate_pr_requires_file_status_metadata_for_auto_merge_label() -> None: - decision = evaluate_pr( - { - "isDraft": False, - "body": "", - "labels": [{"name": AUTO_MERGE_LABEL}], - "files": [{"path": "docs/operator_runbook.md"}], - "changedFiles": 1, - "additions": 4, - "deletions": 1, - "reviewDecision": None, - "baseRefName": "main", - "headRefName": "codex/monthly-review-issue-12", - "headRepositoryOwner": {"login": "QuantStrategyLab"}, - "headRepository": {"nameWithOwner": "QuantStrategyLab/UsEquitySnapshotPipelines"}, - "isCrossRepository": False, - }, - expected_base_ref="main", - expected_head_ref="codex/monthly-review-issue-12", - expected_head_owner="QuantStrategyLab", - expected_head_repository="QuantStrategyLab/UsEquitySnapshotPipelines", - require_same_repository=True, - ) - - assert not decision["should_merge"] - assert decision["reason"] == "pr_metadata_mismatch" - assert decision["risk_level"] == "high" - assert decision["metadata_errors"] == ["missing changed file status metadata requires human review"] - -def test_evaluate_pr_blocks_removed_or_renamed_files_when_status_is_available() -> None: - decision = evaluate_pr( - { - "isDraft": False, - "body": "", - "labels": [{"name": AUTO_MERGE_LABEL}], - "files": [ - {"path": "docs/old.md", "status": "removed"}, - {"path": "tests/test_old.py", "status": "renamed"}, - ], - "changedFiles": 2, - "additions": 1, - "deletions": 10, - "reviewDecision": None, - "baseRefName": "main", - "headRefName": "codex/monthly-review-issue-12", - "headRepositoryOwner": {"login": "QuantStrategyLab"}, - "headRepository": {"nameWithOwner": "QuantStrategyLab/UsEquitySnapshotPipelines"}, - "isCrossRepository": False, - }, - expected_base_ref="main", - expected_head_ref="codex/monthly-review-issue-12", - expected_head_owner="QuantStrategyLab", - expected_head_repository="QuantStrategyLab/UsEquitySnapshotPipelines", - require_same_repository=True, - ) - - assert not decision["should_merge"] - assert decision["reason"] == "pr_metadata_mismatch" - assert decision["risk_level"] == "high" - assert decision["metadata_errors"] == [ - "file status `removed` requires human review", - "file status `renamed` requires human review", - ] - -def test_evaluate_pr_blocks_changed_file_list_mismatch() -> None: - decision = evaluate_pr( - { - "isDraft": False, - "body": "", - "labels": [{"name": AUTO_MERGE_LABEL}], - "files": [{"path": "docs/operator_runbook.md"}], - "changedFiles": 2, - "baseRefName": "main", - "headRefName": "codex/monthly-review-issue-12", - "headRepositoryOwner": {"login": "QuantStrategyLab"}, - "headRepository": {"nameWithOwner": "QuantStrategyLab/UsEquitySnapshotPipelines"}, - "isCrossRepository": False, - }, - expected_base_ref="main", - expected_head_ref="codex/monthly-review-issue-12", - expected_head_owner="QuantStrategyLab", - expected_head_repository="QuantStrategyLab/UsEquitySnapshotPipelines", - require_same_repository=True, - ) - - assert not decision["should_merge"] - assert decision["reason"] == "pr_metadata_mismatch" - assert decision["risk_level"] == "high" - assert decision["metadata_errors"] == ["changed file list mismatch requires human review"] - - -def test_evaluate_pr_requires_marker_issue_to_match_monthly_head_ref() -> None: - decision = evaluate_pr( - { - "isDraft": False, - "body": "", - "labels": [{"name": AUTO_MERGE_LABEL}], - "files": [{"path": "docs/operator_runbook.md"}], - "changedFiles": 1, - "baseRefName": "main", - "headRefName": "codex/monthly-review-issue-12-20260620", - "headRepositoryOwner": {"login": "QuantStrategyLab"}, - "headRepository": {"nameWithOwner": "QuantStrategyLab/UsEquitySnapshotPipelines"}, - "isCrossRepository": False, - }, - expected_base_ref="main", - expected_head_ref="codex/monthly-review-issue-12-20260620", - expected_head_owner="QuantStrategyLab", - expected_head_repository="QuantStrategyLab/UsEquitySnapshotPipelines", - require_same_repository=True, - ) - - assert not decision["should_merge"] - assert decision["reason"] == "missing_marker" - assert decision["metadata_errors"] == [] - - -def test_evaluate_pr_blocks_monthly_head_ref_without_issue_number() -> None: - decision = evaluate_pr( - { - "isDraft": False, - "body": "", - "labels": [{"name": AUTO_MERGE_LABEL}], - "files": [{"path": "docs/operator_runbook.md"}], - "changedFiles": 1, - "baseRefName": "main", - "headRefName": "codex/monthly-review-issue-latest", - "headRepositoryOwner": {"login": "QuantStrategyLab"}, - "headRepository": {"nameWithOwner": "QuantStrategyLab/UsEquitySnapshotPipelines"}, - "isCrossRepository": False, - }, - expected_base_ref="main", - expected_head_ref="codex/monthly-review-issue-latest", - expected_head_owner="QuantStrategyLab", - expected_head_repository="QuantStrategyLab/UsEquitySnapshotPipelines", - require_same_repository=True, - ) - - assert not decision["should_merge"] - assert decision["reason"] == "pr_metadata_mismatch" - assert decision["metadata_errors"] == ["monthly PR head ref issue number missing requires human review"] - - -def test_evaluate_pr_blocks_empty_changed_file_paths() -> None: - decision = evaluate_pr( - { - "isDraft": False, - "body": "", - "labels": [{"name": AUTO_MERGE_LABEL}], - "files": [{"path": ""}], - "changedFiles": 1, - } - ) - - assert not decision["should_merge"] - assert decision["reason"] == "pr_metadata_mismatch" - assert decision["metadata_errors"] == ["invalid changed file path requires human review"] - - -def test_evaluate_pr_blocks_malformed_changed_file_list() -> None: - decision = evaluate_pr( - { - "isDraft": False, - "body": "", - "labels": [{"name": AUTO_MERGE_LABEL}], - "files": "docs/operator_runbook.md", - } - ) - - assert not decision["should_merge"] - assert decision["reason"] == "pr_metadata_mismatch" - assert decision["metadata_errors"] == ["invalid changed file list requires human review"] - - -def test_evaluate_pr_treats_malformed_label_list_as_missing_label() -> None: - decision = evaluate_pr( - { - "isDraft": False, - "body": "", - "labels": "auto-merge-ok", - "files": [{"path": "docs/operator_runbook.md"}], - } - ) - - assert not decision["should_merge"] - assert decision["reason"] == "missing_auto_merge_label" - - -def test_evaluate_pr_blocks_missing_cross_repository_field_when_required() -> None: - pr = { - "isDraft": False, - "body": "", - "labels": [{"name": AUTO_MERGE_LABEL}], - "files": [{"path": "docs/operator_runbook.md"}], - "baseRefName": "main", - "headRefName": "codex/monthly-review-issue-12", - "headRepositoryOwner": {"login": "QuantStrategyLab"}, - "headRepository": {"nameWithOwner": "QuantStrategyLab/UsEquitySnapshotPipelines"}, - } - - decision = evaluate_pr( - pr, - expected_base_ref="main", - expected_head_ref="codex/monthly-review-issue-12", - expected_head_owner="QuantStrategyLab", - expected_head_repository="QuantStrategyLab/UsEquitySnapshotPipelines", - require_same_repository=True, - ) - - assert not decision["should_merge"] - assert decision["reason"] == "pr_metadata_mismatch" - assert decision["is_cross_repository"] is None - assert decision["metadata_errors"] == ["cross-repository PR requires human review"] - - -def test_evaluate_pr_uses_policy_marker_and_label() -> None: - policy = { - "auto_merge_label": "custom-auto-ok", - "human_review_label": "custom-review-required", - "monthly_marker_prefix": "", - "labels": [{"name": "custom-auto-ok"}], - "files": [{"path": "docs/operator_runbook.md", "status": "modified"}], - "additions": 3, - "deletions": 1, - "reviewDecision": "APPROVED", - } - - decision = evaluate_pr(pr, policy=policy) - - assert decision["should_merge"] - assert decision["auto_merge_label"] == "custom-auto-ok" - assert decision["human_review_label"] == "custom-review-required" - assert decision["monthly_marker_prefix"] == "", - "labels": [{"name": AUTO_MERGE_LABEL}], - "files": [{"path": "docs/operator_runbook.md"}], - } - - decision = evaluate_pr(pr, policy=policy) - - assert not decision["should_merge"] - assert decision["reason"] == "policy_errors" - assert decision["risk_level"] == "high" - assert decision["policy_errors"] == [ - "invalid auto_merge_label string requires human review", - "invalid monthly_marker_prefix string requires human review", - ] - - -def test_evaluate_pr_blocks_draft_or_sensitive_files() -> None: - draft = evaluate_pr( - { - "isDraft": True, - "body": "", - "labels": [{"name": AUTO_MERGE_LABEL}], - "files": [{"path": "docs/operator_runbook.md"}], - } - ) - sensitive = evaluate_pr( - { - "isDraft": False, - "body": "", - "labels": [{"name": AUTO_MERGE_LABEL}], - "files": [{"path": "src/us_equity_snapshot_pipelines/contracts.py"}], - } - ) - - assert not draft["should_merge"] - assert draft["reason"] == "draft_pr" - assert not sensitive["should_merge"] - assert sensitive["reason"] == "blocked_files" diff --git a/tests/test_gate_codex_app_review.py b/tests/test_gate_codex_app_review.py deleted file mode 100644 index ba96471..0000000 --- a/tests/test_gate_codex_app_review.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Tests for gate_codex_app_review.py — static guard + App review gate.""" -from __future__ import annotations - -import sys -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT)) - -from scripts.gate_codex_app_review import ( - app_decision, - compile_patterns, - get_codex_review, - load_policy, - scan_diff, - BOT_LOGIN, -) - - -# ── app_decision ───────────────────────────────────────────────────────────── - - -class TestAppDecision: - def test_changes_requested_blocks(self): - rc, title, _ = app_decision({"state": "CHANGES_REQUESTED", "submitted_at": "", "html_url": "", "body": "Bad"}) - assert rc == 1 - assert "BLOCKED" in title - - def test_approved_passes(self): - rc, _, _ = app_decision({"state": "APPROVED", "submitted_at": "", "html_url": "", "body": ""}) - assert rc == 0 - - def test_commented_passes(self): - rc, _, _ = app_decision({"state": "COMMENTED", "submitted_at": "", "html_url": "", "body": ""}) - assert rc == 0 - - def test_dismissed_passes(self): - rc, _, _ = app_decision({"state": "DISMISSED", "submitted_at": "", "html_url": "", "body": ""}) - assert rc == 0 - - def test_none_passes(self): - rc, title, _ = app_decision(None) - assert rc == 0 - assert "no review" in title.lower() - - def test_lowercase_handled(self): - rc, _, _ = app_decision({"state": "changes_requested", "submitted_at": "", "html_url": "", "body": ""}) - assert rc == 1 - - -# ── get_codex_review ───────────────────────────────────────────────────────── - - -class TestGetCodexReview: - def test_returns_latest_bot_review(self): - from unittest.mock import patch - mock = [ - {"id": 1, "user": {"login": "other"}, "state": "COMMENTED"}, - {"id": 2, "user": {"login": BOT_LOGIN}, "state": "APPROVED"}, - {"id": 3, "user": {"login": BOT_LOGIN}, "state": "CHANGES_REQUESTED"}, - ] - with patch("scripts.gate_codex_app_review.github_request", return_value=mock): - r = get_codex_review("t", "r", 1) - assert r["state"] == "CHANGES_REQUESTED" - - def test_none_when_no_bot(self): - from unittest.mock import patch - with patch("scripts.gate_codex_app_review.github_request", - return_value=[{"user": {"login": "human"}}]): - assert get_codex_review("t", "r", 1) is None - - def test_none_when_empty(self): - from unittest.mock import patch - with patch("scripts.gate_codex_app_review.github_request", return_value=[]): - assert get_codex_review("t", "r", 1) is None - - def test_none_when_malformed(self): - from unittest.mock import patch - with patch("scripts.gate_codex_app_review.github_request", return_value={"bad": True}): - assert get_codex_review("t", "r", 1) is None - - -# ── scan_diff ──────────────────────────────────────────────────────────────── - - -class TestScanDiff: - def test_detects_hardcoded_secret(self): - fake_key = "sk-" + "abcdefghijklmnopqrstuvwxyz1234567890" - diff = f'diff --git a/x.py b/x.py\n--- a/x.py\n+++ b/x.py\n@@ -1 +1,2 @@\n+api_key = "{fake_key}"' - issues = scan_diff(diff, []) - assert len(issues) == 1 - assert "Hardcoded secret" in issues[0] - - def test_detects_blocked_file(self): - from scripts.gate_codex_app_review import compile_patterns - patterns = compile_patterns(load_policy()) - diff = 'diff --git a/config/.env b/config/.env\nnew file mode 100644\n--- /dev/null\n+++ b/config/.env' - issues = scan_diff(diff, patterns) - assert len(issues) == 1 - assert "Blocked file" in issues[0] - - def test_pass_clean_diff(self): - diff = 'diff --git a/x.py b/x.py\n--- a/x.py\n+++ b/x.py\n@@ -1 +1,2 @@\n+def foo():\n+ return 42' - assert scan_diff(diff, []) == [] - - def test_skips_placeholder_secrets(self): - diff = 'diff --git a/x.py b/x.py\n--- a/x.py\n+++ b/x.py\n@@ -1 +1,2 @@\n+password = "your-password-here"' - assert scan_diff(diff, []) == [] - - def test_detects_credential_file(self): - from scripts.gate_codex_app_review import compile_patterns - patterns = compile_patterns(load_policy()) - diff = 'diff --git a/src/credentials.py b/src/credentials.py\n--- a/src/credentials.py\n+++ b/src/credentials.py' - issues = scan_diff(diff, patterns) - assert len(issues) == 1 - assert "credentials" in issues[0].lower() - - -# ── policy ─────────────────────────────────────────────────────────────────── - - -class TestPolicy: - def test_load_default(self): - p = load_policy() - assert p["version"] == 1 - assert len(p["blocked_path_patterns"]) > 0 - - def test_patterns_compile(self): - patterns = compile_patterns(load_policy()) - assert len(patterns) > 0 - for pat in patterns: - assert pat.search(".env") diff --git a/tests/test_monthly_review_workflow_config.py b/tests/test_monthly_review_workflow_config.py index ca755aa..ce6067d 100644 --- a/tests/test_monthly_review_workflow_config.py +++ b/tests/test_monthly_review_workflow_config.py @@ -34,6 +34,24 @@ def test_retired_aiaudit_workflows_are_removed() -> None: assert not Path(".github/workflows/auto_merge_codex_pr.yml").exists() assert not Path(".github/workflows/ai_review.yml").exists() +def test_retired_codex_bootstrap_paths_are_removed() -> None: + for path in ( + ".github/codex_auto_merge_policy.json", + "scripts/evaluate_codex_pr_merge.py", + "scripts/gate_codex_app_review.py", + "scripts/post_codex_auto_merge_decision_comment.py", + "scripts/post_codex_auto_merge_preflight_comment.py", + "scripts/sync_codex_auto_merge_labels.py", + "tests/test_evaluate_codex_pr_merge.py", + "tests/test_gate_codex_app_review.py", + "tests/test_post_codex_auto_merge_decision_comment.py", + "tests/test_post_codex_auto_merge_preflight_comment.py", + "tests/test_sync_codex_auto_merge_labels.py", + "pyproject.toml", + "tests/test_run_codex_pr_review.py", + ): + assert not Path(path).exists() + def test_automated_snapshot_publish_runs_after_source_input_refresh() -> None: workflow = PUBLISH_SNAPSHOT_ARTIFACTS.read_text(encoding="utf-8") diff --git a/tests/test_post_codex_auto_merge_decision_comment.py b/tests/test_post_codex_auto_merge_decision_comment.py deleted file mode 100644 index 61711cc..0000000 --- a/tests/test_post_codex_auto_merge_decision_comment.py +++ /dev/null @@ -1,487 +0,0 @@ -from __future__ import annotations - -import json -import os -from pathlib import Path -import subprocess -import sys -from typing import Any -import urllib.error - -import scripts.post_codex_auto_merge_decision_comment as guard_comment -from scripts.post_codex_auto_merge_decision_comment import ( - append_label_actions_to_comment, - build_guard_decision_comment, - extract_monthly_issue_number, - find_existing_guard_comment, - guard_marker, - list_issue_comments, - sync_guard_decision_labels, - upsert_guard_decision_comment, -) - - -def sample_pr() -> dict[str, Any]: - return { - "number": 12, - "url": "https://github.com/QuantStrategyLab/UsEquitySnapshotPipelines/pull/12", - "body": "\nGenerated by Codex.", - "files": [ - {"path": "docs/operator_runbook.md"}, - {"path": "src/us_equity_snapshot_pipelines/contracts.py"}, - ], - } - - -def sample_decision() -> dict[str, Any]: - return { - "should_merge": False, - "reason": "blocked_files", - "risk_level": "high", - "review_decision": "APPROVED", - "changed_lines": 42, - "risk_reasons": ["blocked/high-risk files require human review"], - "blocked_files": ["src/us_equity_snapshot_pipelines/contracts.py"], - "medium_risk_files": [], - "monthly_marker_prefix": "" - - assert extract_monthly_issue_number(pr, decision) == 77 - assert extract_monthly_issue_number({"body": "no marker"}, decision) is None - - -def test_build_guard_decision_comment_includes_skip_reason_and_manual_next_action() -> None: - body = build_guard_decision_comment(pr=sample_pr(), decision=sample_decision()) - - assert body.startswith("") - assert "## Codex Auto-Merge Guard Decision" in body - assert "- Reason: `blocked_files`" in body - assert "- Risk level: `high`" in body - assert "Treat this as a human-review item before merge." in body - assert "`src/us_equity_snapshot_pipelines/contracts.py`" in body - - -def test_append_label_actions_to_comment_adds_visible_label_hygiene_section() -> None: - body = append_label_actions_to_comment( - "body\n", - [ - "Removed stale auto-merge label `auto-merge-ok` from PR #12.", - "Ensured human-review label `human-review-required` on PR #12.", - ], - ) - - assert body == ( - "body\n\n" - "### Label hygiene\n\n" - "- Removed stale auto-merge label `auto-merge-ok` from PR #12.\n" - "- Ensured human-review label `human-review-required` on PR #12.\n" - ) - - -def test_find_existing_guard_comment_matches_marker_prefix() -> None: - assert ( - find_existing_guard_comment( - [ - {"id": 1, "body": "ordinary"}, - {"id": 2, "body": f"{guard_marker(12)}\nbody"}, - ], - guard_marker(12), - ) - == 2 - ) - - -def test_list_issue_comments_paginates_until_short_page() -> None: - calls: list[str] = [] - - def fake_request(method: str, url: str, token: str, payload=None): - calls.append(url) - if url.endswith("page=1"): - return [{"id": i, "body": "x"} for i in range(100)] - if url.endswith("page=2"): - return [{"id": 101, "body": "y"}] - raise AssertionError(url) - - comments = list_issue_comments( - api_url="https://api.github.local", - repo="owner/repo", - token="token", - issue_number=34, - request_fn=fake_request, - ) - - assert len(comments) == 101 - assert len(calls) == 2 - - -def test_upsert_guard_decision_comment_updates_existing() -> None: - calls: list[tuple[str, str, dict[str, str] | None]] = [] - - def fake_request(method: str, url: str, token: str, payload=None): - calls.append((method, url, payload)) - if method == "GET": - return [{"id": 99, "body": f"{guard_marker(12)}\nold"}] - if method == "PATCH": - assert url.endswith("/issues/comments/99") - assert payload == {"body": "new body"} - return {"html_url": "https://github.local/comment/99"} - raise AssertionError((method, url)) - - action, url = upsert_guard_decision_comment( - api_url="https://api.github.local", - repo="owner/repo", - token="token", - issue_number=34, - body="new body", - marker=guard_marker(12), - request_fn=fake_request, - ) - - assert action == "updated" - assert url == "https://github.local/comment/99" - assert [method for method, _, _ in calls] == ["GET", "PATCH"] - - -def test_upsert_guard_decision_comment_creates_when_missing() -> None: - calls: list[tuple[str, str, dict[str, str] | None]] = [] - - def fake_request(method: str, url: str, token: str, payload=None): - calls.append((method, url, payload)) - if method == "GET": - return [] - if method == "POST": - assert url.endswith("/issues/34/comments") - assert payload == {"body": "new body"} - return {"html_url": "https://github.local/comment/100"} - raise AssertionError((method, url)) - - action, url = upsert_guard_decision_comment( - api_url="https://api.github.local", - repo="owner/repo", - token="token", - issue_number=34, - body="new body", - marker=guard_marker(12), - request_fn=fake_request, - ) - - assert action == "created" - assert url == "https://github.local/comment/100" - assert [method for method, _, _ in calls] == ["GET", "POST"] - - -def test_sync_guard_decision_labels_removes_auto_merge_and_adds_human_review_for_high_risk() -> None: - calls: list[tuple[str, str, dict[str, list[str]] | None]] = [] - decision = sample_decision() | { - "auto_merge_label": "custom-auto-ok", - "human_review_label": "custom-human-review", - } - - def fake_request(method: str, url: str, token: str, payload=None): - calls.append((method, url, payload)) - assert token == "token" - if method == "DELETE": - assert url.endswith("/issues/12/labels/custom-auto-ok") - return None - if method == "POST": - assert url.endswith("/issues/12/labels") - assert payload == {"labels": ["custom-human-review"]} - return {"labels": [{"name": "custom-human-review"}]} - raise AssertionError((method, url)) - - actions = sync_guard_decision_labels( - api_url="https://api.github.local", - repo="owner/repo", - token="token", - pr_number=12, - decision=decision, - request_fn=fake_request, - ) - - assert actions == [ - "Removed stale auto-merge label `custom-auto-ok` from PR #12.", - "Ensured human-review label `custom-human-review` on PR #12.", - ] - assert [method for method, _, _ in calls] == ["DELETE", "POST"] - - -def test_sync_guard_decision_labels_ignores_missing_auto_merge_label_for_non_high_risk() -> None: - calls: list[tuple[str, str, dict[str, list[str]] | None]] = [] - decision = sample_decision() | {"risk_level": "medium"} - - def fake_request(method: str, url: str, token: str, payload=None): - calls.append((method, url, payload)) - if method == "DELETE": - raise urllib.error.HTTPError(url, 404, "Not Found", hdrs=None, fp=None) - raise AssertionError((method, url)) - - actions = sync_guard_decision_labels( - api_url="https://api.github.local", - repo="owner/repo", - token="token", - pr_number=12, - decision=decision, - request_fn=fake_request, - ) - - assert actions == ["Auto-merge label `auto-merge-ok` was already absent."] - assert [method for method, _, _ in calls] == ["DELETE"] - - -def test_sync_guard_decision_labels_skips_when_policy_labels_match() -> None: - def fail_if_called(method: str, url: str, token: str, payload=None): - raise AssertionError("label sync should not call GitHub when labels collide") - - actions = sync_guard_decision_labels( - api_url="https://api.github.local", - repo="owner/repo", - token="token", - pr_number=12, - decision=sample_decision() | { - "auto_merge_label": "same-label", - "human_review_label": "same-label", - }, - request_fn=fail_if_called, - ) - - assert actions == [ - "Skipped guard decision label sync because auto-merge and human-review labels are identical.", - ] - - -def test_sync_guard_decision_labels_records_delete_failure_and_still_adds_human_review() -> None: - calls: list[str] = [] - - def fake_request(method: str, url: str, token: str, payload=None): - calls.append(method) - if method == "DELETE": - raise urllib.error.HTTPError(url, 403, "Forbidden", hdrs=None, fp=None) - if method == "POST": - return {"labels": [{"name": "human-review-required"}]} - raise AssertionError((method, url)) - - actions = sync_guard_decision_labels( - api_url="https://api.github.local", - repo="owner/repo", - token="token", - pr_number=12, - decision=sample_decision(), - request_fn=fake_request, - ) - - assert calls == ["DELETE", "POST"] - assert actions == [ - "Could not remove stale auto-merge label `auto-merge-ok` from PR #12: HTTP 403.", - "Ensured human-review label `human-review-required` on PR #12.", - ] - - -def test_sync_guard_decision_labels_records_human_review_label_failure() -> None: - calls: list[str] = [] - - def fake_request(method: str, url: str, token: str, payload=None): - calls.append(method) - if method == "DELETE": - return None - if method == "POST": - raise urllib.error.HTTPError(url, 403, "Forbidden", hdrs=None, fp=None) - raise AssertionError((method, url)) - - actions = sync_guard_decision_labels( - api_url="https://api.github.local", - repo="owner/repo", - token="token", - pr_number=12, - decision=sample_decision(), - request_fn=fake_request, - ) - - assert calls == ["DELETE", "POST"] - assert actions == [ - "Removed stale auto-merge label `auto-merge-ok` from PR #12.", - "Could not ensure human-review label `human-review-required` on PR #12: HTTP 403.", - ] - - -def test_cli_updates_comment_with_label_hygiene_actions(tmp_path: Path, monkeypatch) -> None: - pr_path = tmp_path / "pr.json" - decision_path = tmp_path / "decision.json" - output = tmp_path / "comment.md" - pr_path.write_text(json.dumps(sample_pr()), encoding="utf-8") - decision_path.write_text(json.dumps(sample_decision()), encoding="utf-8") - calls: list[tuple[str, str, dict[str, Any] | None]] = [] - patched_comment_body = "" - - def fake_request(method: str, url: str, token: str, payload=None): - nonlocal patched_comment_body - calls.append((method, url, payload)) - assert token == "token" - if method == "GET" and "/comments" in url: - if len([item for item in calls if item[0] == "GET" and "/comments" in item[1]]) == 1: - return [] - return [{"id": 99, "body": f"{guard_marker(12)}\nold"}] - if method == "POST" and url.endswith("/issues/34/comments"): - return {"html_url": "https://github.local/comment/99"} - if method == "DELETE": - return None - if method == "POST" and url.endswith("/issues/12/labels"): - return {"labels": [{"name": "human-review-required"}]} - if method == "PATCH": - patched_comment_body = payload["body"] - return {"html_url": "https://github.local/comment/99"} - raise AssertionError((method, url, payload)) - - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setattr(guard_comment, "github_request", fake_request) - monkeypatch.setattr( - sys, - "argv", - [ - "post_codex_auto_merge_decision_comment.py", - "--repo", - "owner/repo", - "--pr-json", - str(pr_path), - "--decision-json", - str(decision_path), - "--output-file", - str(output), - "--sync-labels", - "--api-url", - "https://api.github.local", - ], - ) - - assert guard_comment.main() == 0 - assert "### Label hygiene" in patched_comment_body - assert "Removed stale auto-merge label `auto-merge-ok`" in patched_comment_body - assert "Ensured human-review label `human-review-required`" in patched_comment_body - assert output.read_text(encoding="utf-8") == patched_comment_body - - -def test_cli_records_label_hygiene_comment_update_failure_in_output_file(tmp_path: Path, monkeypatch) -> None: - pr_path = tmp_path / "pr.json" - decision_path = tmp_path / "decision.json" - output = tmp_path / "comment.md" - pr_path.write_text(json.dumps(sample_pr()), encoding="utf-8") - decision_path.write_text(json.dumps(sample_decision()), encoding="utf-8") - comment_reads = 0 - - def fake_request(method: str, url: str, token: str, payload=None): - nonlocal comment_reads - assert token == "token" - if method == "GET" and "/comments" in url: - comment_reads += 1 - if comment_reads == 2: - raise urllib.error.HTTPError(url, 500, "Internal Server Error", hdrs=None, fp=None) - return [] - if method == "POST" and url.endswith("/issues/34/comments"): - return {"html_url": "https://github.local/comment/99"} - if method == "DELETE": - return None - if method == "POST" and url.endswith("/issues/12/labels"): - return {"labels": [{"name": "human-review-required"}]} - raise AssertionError((method, url, payload)) - - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setattr(guard_comment, "github_request", fake_request) - monkeypatch.setattr( - sys, - "argv", - [ - "post_codex_auto_merge_decision_comment.py", - "--repo", - "owner/repo", - "--pr-json", - str(pr_path), - "--decision-json", - str(decision_path), - "--output-file", - str(output), - "--sync-labels", - "--api-url", - "https://api.github.local", - ], - ) - - assert guard_comment.main() == 0 - body = output.read_text(encoding="utf-8") - assert "### Label hygiene" in body - assert "Removed stale auto-merge label `auto-merge-ok`" in body - assert "Ensured human-review label `human-review-required`" in body - assert "Could not update guard decision comment with label hygiene actions" in body - - -def test_cli_dry_run_writes_comment_body(tmp_path: Path) -> None: - pr_path = tmp_path / "pr.json" - decision_path = tmp_path / "decision.json" - output = tmp_path / "comment.md" - pr_path.write_text(json.dumps(sample_pr()), encoding="utf-8") - decision_path.write_text(json.dumps(sample_decision()), encoding="utf-8") - env = dict(os.environ) - env.pop("GITHUB_TOKEN", None) - - result = subprocess.run( - [ - sys.executable, - "scripts/post_codex_auto_merge_decision_comment.py", - "--repo", - "QuantStrategyLab/UsEquitySnapshotPipelines", - "--pr-json", - str(pr_path), - "--decision-json", - str(decision_path), - "--output-file", - str(output), - "--dry-run", - "--sync-labels", - ], - cwd=Path(__file__).resolve().parents[1], - env=env, - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode == 0, result.stderr - assert "guard_decision_comment_action=dry_run" in result.stdout - assert output.read_text(encoding="utf-8").startswith("") - - -def test_cli_skips_remote_comment_when_issue_marker_is_missing(tmp_path: Path) -> None: - pr = sample_pr() - pr["body"] = "no issue marker" - pr_path = tmp_path / "pr.json" - decision_path = tmp_path / "decision.json" - output = tmp_path / "comment.md" - pr_path.write_text(json.dumps(pr), encoding="utf-8") - decision_path.write_text(json.dumps(sample_decision()), encoding="utf-8") - - result = subprocess.run( - [ - sys.executable, - "scripts/post_codex_auto_merge_decision_comment.py", - "--repo", - "QuantStrategyLab/UsEquitySnapshotPipelines", - "--pr-json", - str(pr_path), - "--decision-json", - str(decision_path), - "--output-file", - str(output), - ], - cwd=Path(__file__).resolve().parents[1], - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode == 0, result.stderr - assert "guard_decision_comment_action=skipped_missing_issue_marker" in result.stdout - assert output.exists() diff --git a/tests/test_post_codex_auto_merge_preflight_comment.py b/tests/test_post_codex_auto_merge_preflight_comment.py deleted file mode 100644 index 3c5bf87..0000000 --- a/tests/test_post_codex_auto_merge_preflight_comment.py +++ /dev/null @@ -1,431 +0,0 @@ -from __future__ import annotations - -import io -import urllib.error - -from scripts.post_codex_auto_merge_preflight_comment import ( - PreflightCommentError, - append_github_output, - build_preflight_comment, - find_existing_preflight_comment, - list_issue_comments, - main, - marker_component, - preflight_marker, - read_optional_text, - truncate_section, - upsert_preflight_comment, - write_output_file, -) - - -def test_build_preflight_comment_includes_marker_readiness_and_checklist() -> None: - readiness = """## Codex Auto-Merge Readiness -- Ready: `no` -- Skipped: `no` - -### Errors -- branch protection is not enabled for main -""" - plan = """# Codex guarded auto-merge enablement plan - -## Enablement preflight checklist - -- [ ] Readiness reports `Ready: yes`. -- [ ] Rollback command is ready. - -## Manual enablement steps - -1. Do not skip readiness. -""" - label_sync = """## Codex Auto-Merge Label Sync -- Ready: `yes` - -### Actions -- Label `auto-merge-ok` already exists. -""" - - comment = build_preflight_comment( - report_month="2026-06", - readiness_text=readiness, - label_sync_text=label_sync, - enablement_plan_text=plan, - ) - - assert comment.startswith("") - assert "## Guarded Auto-Merge Preflight" in comment - assert "informational only and does not approve or enable guarded auto-merge" in comment - assert "### Label sync snapshot" in comment - assert "Label `auto-merge-ok` already exists." in comment - assert "- Ready: `no`" in comment - assert "## Enablement preflight checklist" in comment - assert "- [ ] Rollback command is ready." in comment - assert "## Manual enablement steps" not in comment - - -def test_marker_component_removes_newlines_and_unsafe_characters() -> None: - assert marker_component(" 2026-06\nextra/value ") == "2026-06-extra-value" - assert preflight_marker("\n") == "" - - -def test_read_optional_text_marks_missing_preflight_as_not_ready(tmp_path) -> None: - missing = tmp_path / "missing.md" - - text = read_optional_text(missing) - - assert "Preflight file was not generated" in text - assert "Treat guarded auto-merge as not ready" in text - - -def test_read_optional_text_uses_custom_missing_message(tmp_path) -> None: - missing = tmp_path / "missing.md" - - text = read_optional_text(missing, missing_message="_Missing custom file: `{path}`._") - - assert text == f"_Missing custom file: `{missing}`._\n" - - -def test_write_output_file_creates_parent_directories(tmp_path) -> None: - output = tmp_path / "nested" / "comment.md" - - write_output_file(output, "comment body") - - assert output.read_text(encoding="utf-8") == "comment body" - - -def test_append_github_output_writes_key_value_pairs(tmp_path, monkeypatch) -> None: - github_output = tmp_path / "github_output.txt" - monkeypatch.setenv("GITHUB_OUTPUT", str(github_output)) - - append_github_output({"preflight_comment_file": "comment.md", "preflight_comment_action": "dry_run"}) - - assert github_output.read_text(encoding="utf-8") == ( - "preflight_comment_file=comment.md\n" - "preflight_comment_action=dry_run\n" - ) - - -def test_find_existing_preflight_comment_matches_marker_prefix() -> None: - marker = preflight_marker("2026-06") - - comment_id = find_existing_preflight_comment( - [ - {"id": 1, "body": "unrelated"}, - {"id": 2, "body": marker + "\n## Guarded Auto-Merge Preflight"}, - ], - marker, - ) - - assert comment_id == 2 - - -def test_upsert_preflight_comment_updates_existing_comment() -> None: - calls: list[tuple[str, str, dict[str, object] | None]] = [] - marker = preflight_marker("2026-06") - - def fake_request(method: str, url: str, token: str, payload=None): - calls.append((method, url, payload)) - if method == "GET": - return [{"id": 42, "body": marker + "\nold"}] - if method == "PATCH": - return {"html_url": "https://example.test/comment/42"} - raise AssertionError((method, url, payload)) - - action, url = upsert_preflight_comment( - api_url="https://api.github.com", - repo="QuantStrategyLab/UsEquitySnapshotPipelines", - token="token", - issue_number=12, - body=marker + "\nnew", - marker=marker, - request_fn=fake_request, - ) - - assert action == "updated" - assert url == "https://example.test/comment/42" - assert calls[1] == ( - "PATCH", - "https://api.github.com/repos/QuantStrategyLab/UsEquitySnapshotPipelines/issues/comments/42", - {"body": marker + "\nnew"}, - ) - - -def test_upsert_preflight_comment_creates_missing_comment() -> None: - calls: list[tuple[str, str, dict[str, object] | None]] = [] - marker = preflight_marker("2026-06") - - def fake_request(method: str, url: str, token: str, payload=None): - calls.append((method, url, payload)) - if method == "GET": - return [] - if method == "POST": - return {"html_url": "https://example.test/comment/43"} - raise AssertionError((method, url, payload)) - - action, url = upsert_preflight_comment( - api_url="https://api.github.com", - repo="QuantStrategyLab/UsEquitySnapshotPipelines", - token="token", - issue_number=12, - body=marker + "\nnew", - marker=marker, - request_fn=fake_request, - ) - - assert action == "created" - assert url == "https://example.test/comment/43" - assert calls[1] == ( - "POST", - "https://api.github.com/repos/QuantStrategyLab/UsEquitySnapshotPipelines/issues/12/comments", - {"body": marker + "\nnew"}, - ) - - -def test_list_issue_comments_reads_paginated_comments() -> None: - calls: list[str] = [] - - def fake_request(method: str, url: str, token: str, payload=None): - calls.append(url) - if url.endswith("page=1"): - return [{"id": index, "body": "first page"} for index in range(100)] - if url.endswith("page=2"): - return [{"id": 101, "body": "second page"}] - raise AssertionError(url) - - comments = list_issue_comments( - api_url="https://api.github.com", - repo="QuantStrategyLab/UsEquitySnapshotPipelines", - token="token", - issue_number=12, - request_fn=fake_request, - ) - - assert len(comments) == 101 - assert calls == [ - "https://api.github.com/repos/QuantStrategyLab/UsEquitySnapshotPipelines/issues/12/comments?per_page=100&page=1", - "https://api.github.com/repos/QuantStrategyLab/UsEquitySnapshotPipelines/issues/12/comments?per_page=100&page=2", - ] - - -def test_list_issue_comments_fails_closed_on_invalid_response() -> None: - def fake_request(method: str, url: str, token: str, payload=None): - return {"message": "unexpected"} - - try: - list_issue_comments( - api_url="https://api.github.com", - repo="QuantStrategyLab/UsEquitySnapshotPipelines", - token="token", - issue_number=12, - request_fn=fake_request, - ) - except PreflightCommentError as exc: - assert "issue comments response is invalid for issue #12" in str(exc) - else: - raise AssertionError("PreflightCommentError was not raised") - - -def test_truncate_section_adds_artifact_hint() -> None: - truncated = truncate_section("a" * 12_001, max_chars=12_000) - - assert len(truncated) > 12_000 - assert "Section truncated" in truncated - - -def test_main_dry_run_writes_comment_without_github_token(tmp_path, monkeypatch, capsys) -> None: - readiness = tmp_path / "readiness.md" - plan = tmp_path / "plan.md" - output = tmp_path / "comment.md" - readiness.write_text("## Codex Auto-Merge Readiness\n- Ready: `no`\n", encoding="utf-8") - plan.write_text("## Enablement preflight checklist\n\n- [ ] Ready.\n", encoding="utf-8") - monkeypatch.delenv("GITHUB_TOKEN", raising=False) - monkeypatch.setattr( - "sys.argv", - [ - "post_codex_auto_merge_preflight_comment.py", - "--repo", - "QuantStrategyLab/UsEquitySnapshotPipelines", - "--issue-number", - "12", - "--report-month", - "2026-06", - "--readiness-file", - str(readiness), - "--enablement-plan-file", - str(plan), - "--output-file", - str(output), - "--dry-run", - ], - ) - - assert main() == 0 - - captured = capsys.readouterr() - assert "preflight_comment_file=" in captured.out - assert "preflight_comment_action=dry_run" in captured.out - rendered = output.read_text(encoding="utf-8") - assert rendered.startswith("") - assert "informational only" in rendered - - -def test_main_writes_output_file_before_missing_token_failure(tmp_path, monkeypatch, capsys) -> None: - readiness = tmp_path / "readiness.md" - plan = tmp_path / "plan.md" - output = tmp_path / "comment.md" - readiness.write_text("## Codex Auto-Merge Readiness\n- Ready: `no`\n", encoding="utf-8") - plan.write_text("## Enablement preflight checklist\n\n- [ ] Ready.\n", encoding="utf-8") - monkeypatch.delenv("GITHUB_TOKEN", raising=False) - monkeypatch.setattr( - "sys.argv", - [ - "post_codex_auto_merge_preflight_comment.py", - "--repo", - "QuantStrategyLab/UsEquitySnapshotPipelines", - "--issue-number", - "12", - "--report-month", - "2026-06", - "--readiness-file", - str(readiness), - "--enablement-plan-file", - str(plan), - "--output-file", - str(output), - ], - ) - - assert main() == 1 - - captured = capsys.readouterr() - assert "preflight_comment_file=" in captured.out - assert "GITHUB_TOKEN is required" in captured.err - assert output.read_text(encoding="utf-8").startswith("") - - -def test_main_keeps_output_metadata_when_comment_upsert_fails(tmp_path, monkeypatch, capsys) -> None: - readiness = tmp_path / "readiness.md" - plan = tmp_path / "plan.md" - output = tmp_path / "comment.md" - github_output = tmp_path / "github_output.txt" - readiness.write_text("## Codex Auto-Merge Readiness\n- Ready: `no`\n", encoding="utf-8") - plan.write_text("## Enablement preflight checklist\n\n- [ ] Ready.\n", encoding="utf-8") - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("GITHUB_OUTPUT", str(github_output)) - monkeypatch.setattr( - "scripts.post_codex_auto_merge_preflight_comment.upsert_preflight_comment", - lambda **kwargs: (_ for _ in ()).throw(PreflightCommentError("invalid response")), - ) - monkeypatch.setattr( - "sys.argv", - [ - "post_codex_auto_merge_preflight_comment.py", - "--repo", - "QuantStrategyLab/UsEquitySnapshotPipelines", - "--issue-number", - "12", - "--report-month", - "2026-06", - "--readiness-file", - str(readiness), - "--enablement-plan-file", - str(plan), - "--output-file", - str(output), - ], - ) - - assert main() == 1 - - captured = capsys.readouterr() - assert "Preflight comment failed: invalid response" in captured.err - assert f"preflight_comment_file={output}" in github_output.read_text(encoding="utf-8") - assert output.read_text(encoding="utf-8").startswith("") - - -def test_main_keeps_output_metadata_when_github_http_error_occurs(tmp_path, monkeypatch, capsys) -> None: - readiness = tmp_path / "readiness.md" - plan = tmp_path / "plan.md" - output = tmp_path / "comment.md" - github_output = tmp_path / "github_output.txt" - readiness.write_text("## Codex Auto-Merge Readiness\n- Ready: `no`\n", encoding="utf-8") - plan.write_text("## Enablement preflight checklist\n\n- [ ] Ready.\n", encoding="utf-8") - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("GITHUB_OUTPUT", str(github_output)) - monkeypatch.setattr( - "scripts.post_codex_auto_merge_preflight_comment.upsert_preflight_comment", - lambda **kwargs: (_ for _ in ()).throw( - urllib.error.HTTPError( - url="https://api.github.com/repos/example/issues/12/comments", - code=403, - msg="Forbidden", - hdrs=None, - fp=io.BytesIO(b"forbidden"), - ) - ), - ) - monkeypatch.setattr( - "sys.argv", - [ - "post_codex_auto_merge_preflight_comment.py", - "--repo", - "QuantStrategyLab/UsEquitySnapshotPipelines", - "--issue-number", - "12", - "--report-month", - "2026-06", - "--readiness-file", - str(readiness), - "--enablement-plan-file", - str(plan), - "--output-file", - str(output), - ], - ) - - assert main() == 1 - - captured = capsys.readouterr() - assert "GitHub API request failed: 403 forbidden" in captured.err - assert f"preflight_comment_file={output}" in github_output.read_text(encoding="utf-8") - assert output.read_text(encoding="utf-8").startswith("") - - -def test_main_keeps_output_metadata_when_github_url_error_occurs(tmp_path, monkeypatch, capsys) -> None: - readiness = tmp_path / "readiness.md" - plan = tmp_path / "plan.md" - output = tmp_path / "comment.md" - github_output = tmp_path / "github_output.txt" - readiness.write_text("## Codex Auto-Merge Readiness\n- Ready: `no`\n", encoding="utf-8") - plan.write_text("## Enablement preflight checklist\n\n- [ ] Ready.\n", encoding="utf-8") - monkeypatch.setenv("GITHUB_TOKEN", "token") - monkeypatch.setenv("GITHUB_OUTPUT", str(github_output)) - monkeypatch.setattr( - "scripts.post_codex_auto_merge_preflight_comment.upsert_preflight_comment", - lambda **kwargs: (_ for _ in ()).throw(urllib.error.URLError("network unreachable")), - ) - monkeypatch.setattr( - "sys.argv", - [ - "post_codex_auto_merge_preflight_comment.py", - "--repo", - "QuantStrategyLab/UsEquitySnapshotPipelines", - "--issue-number", - "12", - "--report-month", - "2026-06", - "--readiness-file", - str(readiness), - "--enablement-plan-file", - str(plan), - "--output-file", - str(output), - ], - ) - - assert main() == 1 - - captured = capsys.readouterr() - assert "GitHub API request failed: network unreachable" in captured.err - assert f"preflight_comment_file={output}" in github_output.read_text(encoding="utf-8") - assert output.read_text(encoding="utf-8").startswith("") diff --git a/tests/test_run_codex_pr_review.py b/tests/test_run_codex_pr_review.py deleted file mode 100644 index 60a8854..0000000 --- a/tests/test_run_codex_pr_review.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -def test_retired_local_review_script_is_absent() -> None: - assert not (ROOT / "scripts/run_codex_pr_review.py").exists() - - -def test_custom_codex_review_workflows_are_absent() -> None: - assert not (ROOT / ".github/workflows/codex_pr_review.yml").exists() - assert not (ROOT / ".github/workflows/codex_review_gate.yml").exists() diff --git a/tests/test_sync_codex_auto_merge_labels.py b/tests/test_sync_codex_auto_merge_labels.py deleted file mode 100644 index d00445a..0000000 --- a/tests/test_sync_codex_auto_merge_labels.py +++ /dev/null @@ -1,218 +0,0 @@ -from __future__ import annotations - -import json -import os -from pathlib import Path -import subprocess -import sys - -import scripts.sync_codex_auto_merge_labels as label_sync -from scripts.check_codex_auto_merge_readiness import GitHubApiError -from scripts.sync_codex_auto_merge_labels import render_summary, sync_codex_auto_merge_labels - - -def write_policy(path: Path) -> None: - path.write_text( - json.dumps( - { - "version": 1, - "auto_merge_label": "auto-merge-ok", - "human_review_label": "human-review-required", - "monthly_marker_prefix": "