From 5eceaa72010f9d7a8bf237c4f8c7ca2c4960d815 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 2 Apr 2026 03:58:43 +0800 Subject: [PATCH] feat: add auto optimization draft pr workflow --- .github/workflows/auto_optimization_pr.yml | 199 ++++++++++++++++++ .github/workflows/ci.yml | 1 + scripts/prepare_auto_optimization_pr.py | 152 +++++++++++++ ...st_auto_optimization_pr_workflow_config.py | 38 ++++ tests/test_prepare_auto_optimization_pr.py | 55 +++++ 5 files changed, 445 insertions(+) create mode 100644 .github/workflows/auto_optimization_pr.yml create mode 100644 scripts/prepare_auto_optimization_pr.py create mode 100644 tests/test_auto_optimization_pr_workflow_config.py create mode 100644 tests/test_prepare_auto_optimization_pr.py diff --git a/.github/workflows/auto_optimization_pr.yml b/.github/workflows/auto_optimization_pr.yml new file mode 100644 index 0000000..255f5da --- /dev/null +++ b/.github/workflows/auto_optimization_pr.yml @@ -0,0 +1,199 @@ +name: Auto Optimization Draft PR + +"on": + issues: + types: [opened, edited, reopened, labeled] + workflow_dispatch: + inputs: + issue_number: + description: "Monthly optimization task issue number" + required: true + +jobs: + auto-pr: + if: contains(github.event.issue.labels.*.name, 'monthly-optimization-task') || inputs.issue_number != '' + runs-on: ubuntu-latest + permissions: + actions: write + contents: write + issues: write + pull-requests: write + + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Check automation prerequisites + id: prereqs + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + mkdir -p data/output/auto_optimization + if [ -z "${ANTHROPIC_API_KEY}" ]; then + echo "has_anthropic_key=false" >> "$GITHUB_OUTPUT" + echo "Claude automation skipped: ANTHROPIC_API_KEY is not configured for this repo." > data/output/auto_optimization/skip_reason.txt + else + echo "has_anthropic_key=true" >> "$GITHUB_OUTPUT" + fi + + - name: Load issue context + id: issue_context + run: | + python3 - <<'PY' + import json + import os + import urllib.request + from pathlib import Path + + repo = os.environ["GITHUB_REPOSITORY"] + issue_number = os.environ["ISSUE_NUMBER"] + token = os.environ["GITHUB_TOKEN"] + api_url = f"https://api.github.com/repos/{repo}/issues/{issue_number}" + request = urllib.request.Request( + api_url, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "auto-optimization-pr", + }, + ) + with urllib.request.urlopen(request) as response: + issue = json.load(response) + + issue_context = { + "number": issue["number"], + "title": issue["title"], + "body": issue.get("body", ""), + } + output_dir = Path("data/output/auto_optimization") + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / "issue_context.json").write_text( + json.dumps(issue_context, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + print("issue_title<> "$GITHUB_OUTPUT" + + - name: Append task summary + run: cat data/output/auto_optimization/task_summary.md >> "$GITHUB_STEP_SUMMARY" + + - name: Append skip reason + if: steps.prereqs.outputs.has_anthropic_key != 'true' || steps.auto_payload.outputs.should_run != 'true' + run: | + if [ -f data/output/auto_optimization/skip_reason.txt ]; then + cat data/output/auto_optimization/skip_reason.txt >> "$GITHUB_STEP_SUMMARY" + else + echo "No eligible low-risk auto-pr-safe tasks were found; skipping draft PR generation." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Prepare automation branch + if: steps.prereqs.outputs.has_anthropic_key == 'true' && steps.auto_payload.outputs.should_run == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "${{ steps.auto_payload.outputs.branch_name }}" + + - name: Run Claude auto optimization + if: steps.prereqs.outputs.has_anthropic_key == 'true' && steps.auto_payload.outputs.should_run == 'true' + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} + use_bedrock: false + use_vertex: false + prompt: | + Do not ask for additional approval. + Do not create a pull request yourself. The workflow will handle git, draft PR creation, and CI dispatch. + Only implement the low-risk tasks explicitly marked `[auto-pr-safe]`. + Ignore any medium-risk or high-risk tasks. + You are working inside CryptoStrategies, the shared strategy-logic repository. + Prefer minimal changes in shared helpers, documentation, and tests. + Avoid changing shared production strategy behavior unless the task remains clearly low-risk and local. + If the selected low-risk tasks do not map cleanly to this repository, leave the working tree unchanged. + Run the most relevant tests or ruff checks when you make a change. + + ## Issue Title + ${{ steps.issue_context.outputs.issue_title }} + + ## Issue Body + ${{ steps.issue_context.outputs.issue_body }} + + - name: Detect changes + id: changes + if: steps.prereqs.outputs.has_anthropic_key == 'true' && steps.auto_payload.outputs.should_run == 'true' + run: | + if git diff --quiet; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + else + echo "has_changes=true" >> "$GITHUB_OUTPUT" + fi + + - name: Commit and push automation branch + if: steps.changes.outputs.has_changes == 'true' + run: | + git add -A + git commit -m "${{ steps.auto_payload.outputs.commit_message }}" + git push --force-with-lease origin "${{ steps.auto_payload.outputs.branch_name }}" + + - name: Create or update draft PR + id: draft_pr + if: steps.changes.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BRANCH_NAME="${{ steps.auto_payload.outputs.branch_name }}" + PR_TITLE="${{ steps.auto_payload.outputs.pr_title }}" + PR_BODY_FILE="${{ steps.auto_payload.outputs.pr_body_file }}" + EXISTING_PR_NUMBER=$(gh pr list --state open --head "${BRANCH_NAME}" --json number --jq '.[0].number // empty') + if [ -n "${EXISTING_PR_NUMBER}" ]; then + gh pr edit "${EXISTING_PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}" + PR_URL=$(gh pr view "${EXISTING_PR_NUMBER}" --json url --jq '.url') + echo "pr_action=updated" >> "$GITHUB_OUTPUT" + else + PR_URL=$(gh pr create --draft --base main --head "${BRANCH_NAME}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}") + echo "pr_action=created" >> "$GITHUB_OUTPUT" + fi + echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT" + + - name: Dispatch CI workflow on automation branch + if: steps.changes.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh workflow run ci.yml --ref "${{ steps.auto_payload.outputs.branch_name }}" + + - name: Append automation result + if: steps.prereqs.outputs.has_anthropic_key == 'true' && steps.auto_payload.outputs.should_run == 'true' + run: | + if [ "${{ steps.changes.outputs.has_changes }}" = "true" ]; then + { + echo "" + echo "## Draft PR Result" + echo "- Draft PR ${{ steps.draft_pr.outputs.pr_action }}: ${{ steps.draft_pr.outputs.pr_url }}" + echo "- CI workflow dispatched on branch: `${{ steps.auto_payload.outputs.branch_name }}`" + } >> "$GITHUB_STEP_SUMMARY" + else + echo "No code changes were produced for the selected low-risk tasks." >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4350571..5e21941 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [ main ] pull_request: + workflow_dispatch: jobs: test: diff --git a/scripts/prepare_auto_optimization_pr.py b/scripts/prepare_auto_optimization_pr.py new file mode 100644 index 0000000..9a9fd4e --- /dev/null +++ b/scripts/prepare_auto_optimization_pr.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + + +ACTION_RE = re.compile(r"^- \[ \] `(?Plow|medium|high)` (?P.+?)(?: \[(?P<flags>[^\]]+)\])?$") +SUMMARY_RE = re.compile(r"^\s+- Summary: (?P<summary>.+)$") +SOURCE_RE = re.compile(r"^\s+- Source: \[(?P<label>.+?)\]\((?P<url>[^)]+)\)$") +MARKER_PREFIX = "<!-- auto-optimization-pr:issue-" + + +def parse_actions(issue_body: str) -> list[dict[str, Any]]: + actions: list[dict[str, Any]] = [] + current: dict[str, Any] | None = None + in_actions = False + + for raw_line in issue_body.splitlines(): + line = raw_line.rstrip() + if line == "## Actions": + in_actions = True + continue + if in_actions and line.startswith("## "): + break + if not in_actions: + continue + + action_match = ACTION_RE.match(line) + if action_match: + if current is not None: + actions.append(current) + flags = [flag.strip() for flag in (action_match.group("flags") or "").split(",") if flag.strip()] + current = { + "risk_level": action_match.group("risk"), + "title": action_match.group("title").strip(), + "flags": flags, + } + continue + + if current is None: + continue + + summary_match = SUMMARY_RE.match(line) + if summary_match: + current["summary"] = summary_match.group("summary").strip() + continue + + source_match = SOURCE_RE.match(line) + if source_match: + current["source_label"] = source_match.group("label").strip() + current["source_url"] = source_match.group("url").strip() + + if current is not None: + actions.append(current) + return actions + + +def build_payload(issue_context: dict[str, Any]) -> dict[str, Any]: + issue_number = int(issue_context["number"]) + issue_title = str(issue_context["title"]).strip() + issue_body = str(issue_context["body"]) + parsed_actions = parse_actions(issue_body) + safe_actions = [ + action for action in parsed_actions + if action["risk_level"] == "low" and "auto-pr-safe" in action.get("flags", []) + ] + return { + "issue_number": issue_number, + "issue_title": issue_title, + "branch_name": f"automation/monthly-optimization-issue-{issue_number}", + "commit_message": f"chore: address monthly optimization issue #{issue_number}", + "pr_title": f"Draft: address monthly optimization issue #{issue_number}", + "should_run": bool(safe_actions), + "safe_task_count": len(safe_actions), + "safe_actions": safe_actions, + } + + +def render_task_summary(payload: dict[str, Any]) -> str: + lines = [ + "# Auto Optimization Candidate Tasks", + "", + f"- Issue: #{payload['issue_number']} {payload['issue_title']}", + f"- Eligible low-risk auto-pr-safe tasks: `{payload['safe_task_count']}`", + ] + if not payload["safe_actions"]: + lines.extend(["", "No eligible low-risk [auto-pr-safe] tasks were found in this issue."]) + return "\n".join(lines).strip() + "\n" + + lines.extend(["", "## Selected Tasks"]) + for action in payload["safe_actions"]: + flag_suffix = f" [{', '.join(action['flags'])}]" if action.get("flags") else "" + lines.extend( + [ + f"- `{action['risk_level']}` {action['title']}{flag_suffix}", + f" - Summary: {action.get('summary', 'No summary provided.')}", + f" - Source: {action.get('source_label', 'Unknown source')} ({action.get('source_url', 'n/a')})", + ] + ) + return "\n".join(lines).strip() + "\n" + + +def render_pr_body(payload: dict[str, Any]) -> str: + lines = [ + f"{MARKER_PREFIX}{payload['issue_number']} -->", + "## Summary", + "This draft PR was generated from a monthly optimization task issue.", + "It only targets low-risk items explicitly marked `[auto-pr-safe]`.", + "", + "## Auto-selected tasks", + ] + for action in payload["safe_actions"]: + lines.append(f"- {action['title']}: {action.get('summary', 'No summary provided.')}") + 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.add_argument("--issue-context-file", required=True, type=Path) + parser.add_argument("--output-dir", required=True, type=Path) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + issue_context = json.loads(args.issue_context_file.read_text(encoding="utf-8")) + payload = build_payload(issue_context) + args.output_dir.mkdir(parents=True, exist_ok=True) + payload_file = args.output_dir / "payload.json" + task_summary_file = args.output_dir / "task_summary.md" + pr_body_file = args.output_dir / "pr_body.md" + payload_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + task_summary_file.write_text(render_task_summary(payload), encoding="utf-8") + pr_body_file.write_text(render_pr_body(payload), encoding="utf-8") + print(f"should_run={'true' if payload['should_run'] else 'false'}") + print(f"issue_number={payload['issue_number']}") + print(f"branch_name={payload['branch_name']}") + 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"payload_file={payload_file}") + print(f"task_summary_file={task_summary_file}") + print(f"pr_body_file={pr_body_file}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_auto_optimization_pr_workflow_config.py b/tests/test_auto_optimization_pr_workflow_config.py new file mode 100644 index 0000000..e804300 --- /dev/null +++ b/tests/test_auto_optimization_pr_workflow_config.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +AUTO_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "auto_optimization_pr.yml" +CI_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "ci.yml" + + +class AutoOptimizationPrWorkflowConfigTests(unittest.TestCase): + def test_auto_optimization_workflow_handles_monthly_task_issues(self) -> None: + workflow = AUTO_WORKFLOW.read_text(encoding="utf-8") + + self.assertIn("issues:", workflow) + self.assertIn("monthly-optimization-task", workflow) + self.assertIn("workflow_dispatch:", workflow) + self.assertIn("issue_number:", workflow) + self.assertIn("actions: write", workflow) + self.assertIn("contents: write", workflow) + self.assertIn("pull-requests: write", workflow) + self.assertIn("ANTHROPIC_API_KEY", workflow) + self.assertIn("prepare_auto_optimization_pr.py", workflow) + self.assertIn("anthropics/claude-code-action@v1", workflow) + self.assertIn("gh pr create --draft", 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) + + def test_ci_workflow_supports_manual_dispatch(self) -> None: + workflow = CI_WORKFLOW.read_text(encoding="utf-8") + self.assertIn("workflow_dispatch:", workflow) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_prepare_auto_optimization_pr.py b/tests/test_prepare_auto_optimization_pr.py new file mode 100644 index 0000000..d48da32 --- /dev/null +++ b/tests/test_prepare_auto_optimization_pr.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import unittest + +from scripts.prepare_auto_optimization_pr import build_payload, parse_actions, render_pr_body + + +class PrepareAutoOptimizationPrTests(unittest.TestCase): + def setUp(self) -> None: + self.issue_context = { + "number": 15, + "title": "Monthly Optimization Tasks · BinancePlatform: 2026-04-01 / 2026-03", + "body": """# Monthly Optimization Tasks · BinancePlatform + +## Actions +- [ ] `high` Reconcile March cash flows and open-position state + - Summary: Pull Binance transaction history for March. + - Source: [QuantStrategyLab/BinancePlatform #9](https://github.com/QuantStrategyLab/BinancePlatform/issues/9) +- [ ] `low` Add zero-trade diagnostics to the report [auto-pr-safe] + - Summary: Include the top failed gating reason counts. + - Source: [QuantStrategyLab/BinancePlatform #9](https://github.com/QuantStrategyLab/BinancePlatform/issues/9) +- [ ] `low` Add a boundary tracker [auto-pr-safe, experiment-only] + - Summary: Track near-cutoff symbols monthly. + - Source: [QuantStrategyLab/CryptoLeaderRotation #11](https://github.com/QuantStrategyLab/CryptoLeaderRotation/issues/11) +""", + } + + def test_parse_actions_preserves_risk_flags_and_source(self) -> None: + actions = parse_actions(self.issue_context["body"]) + + self.assertEqual(len(actions), 3) + self.assertEqual(actions[0]["risk_level"], "high") + self.assertEqual(actions[1]["flags"], ["auto-pr-safe"]) + self.assertEqual(actions[2]["flags"], ["auto-pr-safe", "experiment-only"]) + self.assertEqual(actions[2]["source_label"], "QuantStrategyLab/CryptoLeaderRotation #11") + + def test_build_payload_selects_only_low_auto_pr_safe_actions(self) -> None: + payload = build_payload(self.issue_context) + + self.assertTrue(payload["should_run"]) + self.assertEqual(payload["safe_task_count"], 2) + self.assertEqual(payload["branch_name"], "automation/monthly-optimization-issue-15") + self.assertEqual(payload["safe_actions"][0]["title"], "Add zero-trade diagnostics to the report") + + def test_render_pr_body_contains_marker_and_issue_reference(self) -> None: + payload = build_payload(self.issue_context) + body = render_pr_body(payload) + + self.assertIn("<!-- auto-optimization-pr:issue-15 -->", body) + self.assertIn("Add zero-trade diagnostics to the report", body) + self.assertIn("Refs #15", body) + + +if __name__ == "__main__": + unittest.main()