diff --git a/.github/workflows/auto_merge_optimization_pr.yml b/.github/workflows/auto_merge_optimization_pr.yml new file mode 100644 index 0000000..18d4620 --- /dev/null +++ b/.github/workflows/auto_merge_optimization_pr.yml @@ -0,0 +1,94 @@ +name: Auto Merge Optimization PR + +"on": + workflow_run: + workflows: ["CI"] + types: [completed] + +jobs: + auto-merge: + if: github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'automation/monthly-optimization-issue-') + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Resolve automation PR + id: pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p data/output/auto_merge + BRANCH_NAME="${{ github.event.workflow_run.head_branch }}" + PR_NUMBER=$(gh pr list --state open --head "${BRANCH_NAME}" --json number --jq '.[0].number // empty') + if [ -z "${PR_NUMBER}" ]; then + echo "No open automation PR found for ${BRANCH_NAME}." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" + + - name: Evaluate merge eligibility + id: merge_guard + if: steps.pr.outputs.pr_number != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr view "${{ steps.pr.outputs.pr_number }}" --json number,isDraft,body,url,files > data/output/auto_merge/pr.json + python3 - <<'PY' + import json + import os + from pathlib import Path + + from scripts.prepare_auto_optimization_pr import evaluate_changed_files + + pr = json.loads(Path("data/output/auto_merge/pr.json").read_text(encoding="utf-8")) + body = pr.get("body") or "" + changed_files = [item.get("path", "") for item in pr.get("files", [])] + guard = evaluate_changed_files(changed_files) + has_marker = "", "## Summary", - "This draft PR was generated from a monthly optimization task issue.", + "This PR was generated from a monthly optimization task issue.", "It only targets low-risk items explicitly marked `[auto-pr-safe]`.", "", + "## Merge Policy", + f"- Task-level auto-merge eligible: `{'yes' if payload['task_level_auto_merge_allowed'] else 'no'}`", + "- High-risk guardrails remain active for selector logic, live execution, and shared strategy code paths.", + "", "## Auto-selected tasks", ] for action in payload["safe_actions"]: lines.append(f"- {action['title']}: {action.get('summary', 'No summary provided.')}") + if payload["draft_only_actions"]: + lines.extend(["", "## Draft-only tasks"]) + for action in payload["draft_only_actions"]: + lines.append(f"- {action['title']}: {action.get('auto_merge_blocker', 'guarded_task')}") lines.extend(["", f"Refs #{payload['issue_number']}"]) return "\n".join(lines).strip() + "\n" def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Prepare metadata for auto-generated optimization draft PRs.") + parser = argparse.ArgumentParser(description="Prepare metadata for auto-generated optimization PRs.") parser.add_argument("--issue-context-file", required=True, type=Path) parser.add_argument("--output-dir", required=True, type=Path) return parser.parse_args() @@ -201,6 +378,8 @@ def main() -> int: print(f"commit_message={payload['commit_message']}") print(f"pr_title={payload['pr_title']}") print(f"safe_task_count={payload['safe_task_count']}") + print(f"auto_merge_candidate_count={payload['auto_merge_candidate_count']}") + print(f"task_level_auto_merge_allowed={'true' if payload['task_level_auto_merge_allowed'] else 'false'}") print(f"payload_file={payload_file}") print(f"task_summary_file={task_summary_file}") print(f"pr_body_file={pr_body_file}") diff --git a/tests/test_auto_optimization_pr_workflow_config.py b/tests/test_auto_optimization_pr_workflow_config.py index 5db08c2..95c4cf9 100644 --- a/tests/test_auto_optimization_pr_workflow_config.py +++ b/tests/test_auto_optimization_pr_workflow_config.py @@ -6,6 +6,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] AUTO_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "auto_optimization_pr.yml" +MERGE_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "auto_merge_optimization_pr.yml" CI_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "ci.yml" @@ -28,12 +29,27 @@ def test_auto_optimization_workflow_handles_monthly_task_issues(self) -> None: self.assertIn("timeout-minutes: 15", workflow) self.assertIn("steps.selected_tasks.outputs.task_summary", workflow) self.assertIn("Do not use Bash in this workflow.", workflow) - self.assertIn("The workflow will run CI after the draft PR is created.", workflow) - self.assertIn("gh pr create --draft", workflow) + self.assertIn("The workflow will run CI after the PR is created.", workflow) + self.assertIn("Evaluate merge guardrails", workflow) + self.assertIn("task_level_auto_merge_allowed", workflow) + self.assertIn("gh pr ready", workflow) + self.assertIn("steps.merge_guard.outputs.merge_ready", workflow) self.assertIn("gh workflow run ci.yml", workflow) self.assertIn("fetch-depth: 0", workflow) self.assertIn('FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"', workflow) self.assertIn("You are working inside CryptoLeaderRotation, the upstream selector repository.", workflow) + self.assertIn("Never edit files under src/ or config/ in this automation step.", workflow) + + def test_auto_merge_workflow_waits_for_ci_and_merges_only_safe_ready_prs(self) -> None: + workflow = MERGE_WORKFLOW.read_text(encoding="utf-8") + + self.assertIn("workflow_run:", workflow) + self.assertIn('workflows: ["CI"]', workflow) + self.assertIn("automation/monthly-optimization-issue-", workflow) + self.assertIn("gh pr view", workflow) + self.assertIn("evaluate_changed_files", workflow) + self.assertIn("Task-level auto-merge eligible: `yes`", workflow) + self.assertIn("gh pr merge", workflow) def test_ci_workflow_supports_manual_dispatch(self) -> None: workflow = CI_WORKFLOW.read_text(encoding="utf-8") diff --git a/tests/test_monthly_optimization_planner_workflow_config.py b/tests/test_monthly_optimization_planner_workflow_config.py index 2439ca9..7cd1f39 100644 --- a/tests/test_monthly_optimization_planner_workflow_config.py +++ b/tests/test_monthly_optimization_planner_workflow_config.py @@ -32,7 +32,7 @@ def test_planner_workflow_downloads_artifacts_posts_issue_and_fans_out_tasks(sel self.assertIn("Resolve upstream experiment validation target", workflow) self.assertIn("Dispatch CryptoLeaderRotation experiment validation", workflow) self.assertIn("Resolve downstream experiment validation target", workflow) - self.assertIn("Trigger BinancePlatform experiment validation by label", workflow) + self.assertIn("Best-effort label BinancePlatform issue for experiment validation", workflow) self.assertIn("experiment-validation", workflow) self.assertIn("Dispatch BinancePlatform experiment validation", workflow) self.assertIn("gh workflow run experiment_validation.yml", workflow) diff --git a/tests/test_prepare_auto_optimization_pr.py b/tests/test_prepare_auto_optimization_pr.py index 8ed9a34..6690aed 100644 --- a/tests/test_prepare_auto_optimization_pr.py +++ b/tests/test_prepare_auto_optimization_pr.py @@ -4,7 +4,7 @@ import unittest from pathlib import Path -from scripts.prepare_auto_optimization_pr import build_payload, parse_actions, render_pr_body +from scripts.prepare_auto_optimization_pr import build_payload, evaluate_changed_files, parse_actions, render_pr_body PROJECT_ROOT = Path(__file__).resolve().parents[1] @@ -45,6 +45,8 @@ def test_build_payload_skips_completed_clr_tasks_and_excludes_experiments(self) self.assertFalse(payload["should_run"]) self.assertEqual(payload["safe_task_count"], 0) self.assertEqual(payload["skipped_task_count"], 2) + self.assertEqual(payload["auto_merge_candidate_count"], 0) + self.assertFalse(payload["task_level_auto_merge_allowed"]) self.assertEqual( [action["title"] for action in payload["skipped_actions"]], [ @@ -53,7 +55,35 @@ def test_build_payload_skips_completed_clr_tasks_and_excludes_experiments(self) ], ) - def test_render_pr_body_contains_marker_and_issue_reference(self) -> None: + def test_build_payload_marks_readme_note_as_auto_merge_candidate(self) -> None: + issue_context = { + "number": 30, + "title": "Monthly Optimization Tasks · Sandbox", + "body": """# Monthly Optimization Tasks · Sandbox + +## Actions +- [ ] `low` Add a short README note [auto-pr-safe] + - Summary: Document a small operator-facing behavior. + - Source: [Sandbox #1](https://example.com/issues/1) +""", + } + with tempfile.TemporaryDirectory() as temp_dir: + payload = build_payload(issue_context, repo_root=Path(temp_dir)) + self.assertTrue(payload["should_run"]) + self.assertEqual(payload["safe_task_count"], 1) + self.assertEqual(payload["auto_merge_candidate_count"], 1) + self.assertEqual(payload["draft_only_task_count"], 0) + self.assertTrue(payload["task_level_auto_merge_allowed"]) + + def test_evaluate_changed_files_blocks_selector_paths(self) -> None: + allowed = evaluate_changed_files(["docs/operator_runbook.md"], repo_root=PROJECT_ROOT) + blocked = evaluate_changed_files(["src/ranking.py", "README.md"], repo_root=PROJECT_ROOT) + + self.assertTrue(allowed["allowed"]) + self.assertFalse(blocked["allowed"]) + self.assertEqual(blocked["blocked_files"], ["src/ranking.py"]) + + def test_render_pr_body_contains_merge_policy_and_issue_reference(self) -> None: issue_context = { "number": 30, "title": "Monthly Optimization Tasks · Sandbox", @@ -70,6 +100,7 @@ def test_render_pr_body_contains_marker_and_issue_reference(self) -> None: body = render_pr_body(payload) self.assertIn("", body) + self.assertIn("Task-level auto-merge eligible: `yes`", body) self.assertIn("Add a short README note", body) self.assertIn("Refs #30", body)