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 1e5ca1e..2bf0525 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 CryptoStrategies, the shared strategy-logic repository.", workflow) + self.assertIn("Never edit files under src/ 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_prepare_auto_optimization_pr.py b/tests/test_prepare_auto_optimization_pr.py index 8cbf992..52505c0 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 class PrepareAutoOptimizationPrTests(unittest.TestCase): @@ -18,8 +18,8 @@ def setUp(self) -> None: - [ ] `high` Investigate an upstream issue - Summary: Manual follow-up only. - Source: [Sandbox #0](https://example.com/issues/0) -- [ ] `low` Add zero-trade diagnostics to the report [auto-pr-safe] - - Summary: Include the top failed gating reason counts. +- [ ] `low` Add a short README note [auto-pr-safe] + - Summary: Document a small operator-facing behavior. - Source: [Sandbox #1](https://example.com/issues/1) - [ ] `low` Add a boundary tracker [auto-pr-safe, experiment-only] - Summary: Track near-cutoff symbols monthly. @@ -36,23 +36,62 @@ def test_parse_actions_preserves_risk_flags_and_source(self) -> None: self.assertEqual(actions[2]["flags"], ["auto-pr-safe", "experiment-only"]) self.assertEqual(actions[2]["source_label"], "Sandbox #2") - def test_build_payload_selects_only_non_experiment_low_auto_pr_safe_actions(self) -> None: + def test_build_payload_selects_readme_note_as_auto_merge_candidate(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: - payload = build_payload(self.issue_context, repo_root=Path(temp_dir)) + repo_root = Path(temp_dir) / "CryptoStrategies" + repo_root.mkdir() + payload = build_payload(self.issue_context, repo_root=repo_root) self.assertTrue(payload["should_run"]) self.assertEqual(payload["safe_task_count"], 1) - self.assertEqual(payload["skipped_task_count"], 0) + self.assertEqual(payload["auto_merge_candidate_count"], 1) + self.assertEqual(payload["draft_only_task_count"], 0) + self.assertTrue(payload["task_level_auto_merge_allowed"]) self.assertEqual(payload["branch_name"], "automation/monthly-optimization-issue-15") - self.assertEqual(payload["safe_actions"][0]["title"], "Add zero-trade diagnostics to the report") + self.assertEqual(payload["safe_actions"][0]["title"], "Add a short README note") + + def test_build_payload_keeps_shared_strategy_logic_tasks_as_draft_only(self) -> None: + issue_context = { + "number": 18, + "title": "Monthly Optimization Tasks · Sandbox", + "body": """# Monthly Optimization Tasks · Sandbox + +## Actions +- [ ] `low` Document strategy signal thresholds [auto-pr-safe] + - Summary: Document the strategy threshold handling for shared strategy modules. + - Source: [Sandbox #3](https://example.com/issues/3) +""", + } + with tempfile.TemporaryDirectory() as temp_dir: + repo_root = Path(temp_dir) / "CryptoStrategies" + repo_root.mkdir() + payload = build_payload(issue_context, repo_root=repo_root) + + self.assertTrue(payload["should_run"]) + self.assertEqual(payload["auto_merge_candidate_count"], 0) + self.assertEqual(payload["draft_only_task_count"], 1) + self.assertFalse(payload["task_level_auto_merge_allowed"]) + self.assertIn("guarded_keyword:strategy", payload["draft_only_actions"][0]["auto_merge_blocker"]) + + def test_evaluate_changed_files_blocks_shared_strategy_paths(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + repo_root = Path(temp_dir) + allowed = evaluate_changed_files(["README.md", "tests/test_catalog.py"], repo_root=repo_root) + repo_root = Path('/Users/lisiyi/Projects/CryptoStrategies') + blocked = evaluate_changed_files(["src/crypto_strategies/catalog.py", "README.md"], repo_root=repo_root) + + self.assertTrue(allowed["allowed"]) + self.assertFalse(blocked["allowed"]) + self.assertEqual(blocked["blocked_files"], ["src/crypto_strategies/catalog.py"]) - def test_render_pr_body_contains_marker_and_issue_reference(self) -> None: + def test_render_pr_body_contains_merge_policy_and_issue_reference(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: payload = build_payload(self.issue_context, repo_root=Path(temp_dir)) body = render_pr_body(payload) self.assertIn("", body) - self.assertIn("Add zero-trade diagnostics to the report", body) + self.assertIn("Task-level auto-merge eligible: `yes`", body) + self.assertIn("Add a short README note", body) self.assertIn("Refs #15", body)