diff --git a/.github/workflows/monthly_optimization_planner.yml b/.github/workflows/monthly_optimization_planner.yml index 562ee6f..f7005c3 100644 --- a/.github/workflows/monthly_optimization_planner.yml +++ b/.github/workflows/monthly_optimization_planner.yml @@ -88,17 +88,78 @@ jobs: - name: Append optimization summary run: cat data/output/monthly_optimization/optimization_summary.md >> "$GITHUB_STEP_SUMMARY" - - name: Upload planner artifact - uses: actions/upload-artifact@v7 - with: - name: monthly-optimization-plan-${{ inputs.upstream_run_id }}-${{ inputs.downstream_run_id }} - path: data/output/monthly_optimization/ - - name: Create monthly optimization issue + id: optimization_issue run: | - python3 scripts/post_monthly_optimization_issue.py \ + issue_output=$(python3 scripts/post_monthly_optimization_issue.py \ --repo "${GITHUB_REPOSITORY}" \ --plan-file data/output/monthly_optimization/optimization_plan.json \ - --summary-file data/output/monthly_optimization/optimization_summary.md + --summary-file data/output/monthly_optimization/optimization_summary.md) + echo "$issue_output" + issue_number=$(printf '%s\n' "$issue_output" | awk -F= '/^issue_number=/{print $2}' | tail -1) + if [ -z "$issue_number" ]; then + echo "Failed to capture optimization issue number" >&2 + exit 1 + fi + echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Fan out CryptoLeaderRotation task issue + run: | + python3 scripts/fanout_monthly_optimization_tasks.py \ + --plan-file data/output/monthly_optimization/optimization_plan.json \ + --owner-repo CryptoLeaderRotation \ + --repo "${GITHUB_REPOSITORY}" \ + --planner-issue-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/issues/${{ steps.optimization_issue.outputs.issue_number }}" \ + --output-file data/output/monthly_optimization/fanout/crypto_leader_rotation.json env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Fan out CryptoStrategies task issue + run: | + python3 scripts/fanout_monthly_optimization_tasks.py \ + --plan-file data/output/monthly_optimization/optimization_plan.json \ + --owner-repo CryptoStrategies \ + --repo "QuantStrategyLab/CryptoStrategies" \ + --planner-issue-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/issues/${{ steps.optimization_issue.outputs.issue_number }}" \ + --output-file data/output/monthly_optimization/fanout/crypto_strategies.json \ + --allow-permission-skip + env: + GITHUB_TOKEN: ${{ secrets.CROSS_REPO_GITHUB_TOKEN }} + + - name: Fan out BinancePlatform task issue + run: | + python3 scripts/fanout_monthly_optimization_tasks.py \ + --plan-file data/output/monthly_optimization/optimization_plan.json \ + --owner-repo BinancePlatform \ + --repo "${{ inputs.downstream_repo }}" \ + --planner-issue-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/issues/${{ steps.optimization_issue.outputs.issue_number }}" \ + --output-file data/output/monthly_optimization/fanout/binance_platform.json \ + --allow-permission-skip + env: + GITHUB_TOKEN: ${{ secrets.CROSS_REPO_GITHUB_TOKEN }} + + - name: Append fanout summary + run: | + python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY" + import json + from pathlib import Path + + result_dir = Path("data/output/monthly_optimization/fanout") + print("\n## Repo Task Fanout") + for path in sorted(result_dir.glob("*.json")): + data = json.loads(path.read_text(encoding="utf-8")) + line = f"- **{data['owner_repo']}** `{data['status']}`" + if data.get("issue_url"): + line += f": {data['issue_url']}" + elif data.get("reason"): + line += f": {data['reason']}" + print(line) + PY + + - name: Upload planner artifact + uses: actions/upload-artifact@v7 + with: + name: monthly-optimization-plan-${{ inputs.upstream_run_id }}-${{ inputs.downstream_run_id }} + path: data/output/monthly_optimization/ diff --git a/scripts/fanout_monthly_optimization_tasks.py b/scripts/fanout_monthly_optimization_tasks.py new file mode 100644 index 0000000..7c1a912 --- /dev/null +++ b/scripts/fanout_monthly_optimization_tasks.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + + +COMMENT_MARKER_PREFIX = "" + + +def build_marker_from_body(body: str) -> str: + for line in body.splitlines(): + if line.startswith(COMMENT_MARKER_PREFIX): + return line.strip() + return "" + + +def build_issue_title(plan: dict[str, Any], owner_repo: str) -> str: + labels = [review["source_issue"]["title"].split(": ", 1)[-1] for review in plan.get("source_reviews", [])] + return f"Monthly Optimization Tasks · {owner_repo}: {' / '.join(labels)}" + + +def _repo_actions(plan: dict[str, Any], owner_repo: str) -> list[dict[str, Any]]: + repo_summary = plan.get("repo_action_summary", {}).get(owner_repo, {}) + return list(repo_summary.get("actions", [])) + + +def build_issue_body(plan: dict[str, Any], owner_repo: str, planner_issue_url: str | None = None) -> str: + actions = _repo_actions(plan, owner_repo) + repo_summary = plan.get("repo_action_summary", {}).get(owner_repo, {}) + safe_auto_pr_count = sum(1 for action in actions if action.get("auto_pr_safe")) + experiment_count = sum(1 for action in actions if action.get("experiment_only")) + lines = [ + build_marker(plan, owner_repo), + f"# Monthly Optimization Tasks · {owner_repo}", + "", + f"- Actions in this repo: `{len(actions)}`", + f"- Highest repo risk: `{repo_summary.get('highest_risk_level', 'low')}`", + f"- Safe auto-PR candidates here: `{safe_auto_pr_count}`", + f"- Experiment-only tasks here: `{experiment_count}`", + ] + if planner_issue_url: + lines.append(f"- Planner issue: {planner_issue_url}") + + lines.extend(["", "## Actions"]) + for action in actions: + flags: list[str] = [] + if action.get("auto_pr_safe"): + flags.append("auto-pr-safe") + if action.get("experiment_only"): + flags.append("experiment-only") + flag_suffix = f" [{', '.join(flags)}]" if flags else "" + lines.extend( + [ + f"- [ ] `{action['risk_level']}` {action['title']}{flag_suffix}", + f" - Summary: {action['summary']}", + f" - Source: [{action['source_repo']} #{action['source_issue_number']}]({action['source_issue_url']})", + ] + ) + + return "\n".join(lines).strip() + "\n" + + +def github_request(method: str, url: str, token: str, payload: dict[str, Any] | None = None) -> Any: + data = None + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "monthly-optimization-fanout", + } + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + request = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(request) as response: + charset = response.headers.get_content_charset("utf-8") + raw = response.read().decode(charset) + return json.loads(raw) if raw else None + + +def ensure_label(api_url: str, repo: str, token: str) -> None: + label_path = urllib.parse.quote(LABEL_NAME, safe="") + label_url = f"{api_url}/repos/{repo}/labels/{label_path}" + try: + github_request("GET", label_url, token) + except urllib.error.HTTPError as exc: + if exc.code != 404: + raise + github_request( + "POST", + f"{api_url}/repos/{repo}/labels", + token, + { + "name": LABEL_NAME, + "color": LABEL_COLOR, + "description": LABEL_DESCRIPTION, + }, + ) + + +def upsert_issue(*, api_url: str, repo: str, token: str, title: str, body: str) -> tuple[str, int, str]: + issues = github_request( + "GET", + f"{api_url}/repos/{repo}/issues?state=open&labels={urllib.parse.quote(LABEL_NAME)}&per_page=100", + token, + ) + marker = build_marker_from_body(body) + existing = next((issue for issue in issues if build_marker_from_body(issue.get("body", "")) == marker), None) + payload = {"title": title, "body": body, "labels": [LABEL_NAME]} + if existing: + github_request("PATCH", f"{api_url}/repos/{repo}/issues/{existing['number']}", token, payload) + return "updated", int(existing["number"]), str(existing["html_url"]) + created = github_request("POST", f"{api_url}/repos/{repo}/issues", token, payload) + return "created", int(created["number"]), str(created["html_url"]) + + +def build_result( + *, + owner_repo: str, + target_repo: str, + plan: dict[str, Any], + status: str, + issue_number: int | None = None, + issue_url: str | None = None, + reason: str | None = None, +) -> dict[str, Any]: + repo_summary = plan.get("repo_action_summary", {}).get(owner_repo, {}) + return { + "owner_repo": owner_repo, + "target_repo": target_repo, + "status": status, + "actions_count": int(repo_summary.get("count", 0)), + "highest_risk_level": repo_summary.get("highest_risk_level", "low"), + "issue_number": issue_number, + "issue_url": issue_url, + "reason": reason, + } + + +def write_result(output_file: Path, result: dict[str, Any]) -> None: + output_file.parent.mkdir(parents=True, exist_ok=True) + output_file.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Create or update repo-scoped monthly optimization task issues.", + ) + parser.add_argument("--plan-file", required=True, type=Path) + parser.add_argument("--owner-repo", required=True) + parser.add_argument("--repo", required=True) + parser.add_argument("--output-file", required=True, type=Path) + parser.add_argument("--planner-issue-url") + parser.add_argument("--api-url", default=DEFAULT_API_URL) + parser.add_argument("--allow-permission-skip", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + token = os.environ.get("GITHUB_TOKEN") + if not token: + print("GITHUB_TOKEN is required", file=sys.stderr) + return 1 + + plan = json.loads(args.plan_file.read_text(encoding="utf-8")) + actions = _repo_actions(plan, args.owner_repo) + if not actions: + result = build_result( + owner_repo=args.owner_repo, + target_repo=args.repo, + plan=plan, + status="skipped_no_actions", + reason="No recommended actions for this repo in the current optimization plan.", + ) + write_result(args.output_file, result) + print(json.dumps(result, ensure_ascii=False)) + return 0 + + title = build_issue_title(plan, args.owner_repo) + body = build_issue_body(plan, args.owner_repo, planner_issue_url=args.planner_issue_url) + + try: + ensure_label(args.api_url.rstrip("/"), args.repo, token) + status, issue_number, issue_url = upsert_issue( + api_url=args.api_url.rstrip("/"), + repo=args.repo, + token=token, + title=title, + body=body, + ) + result = build_result( + owner_repo=args.owner_repo, + target_repo=args.repo, + plan=plan, + status=status, + issue_number=issue_number, + issue_url=issue_url, + ) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + if args.allow_permission_skip and exc.code in {403, 404}: + result = build_result( + owner_repo=args.owner_repo, + target_repo=args.repo, + plan=plan, + status="skipped_permission", + reason=f"{exc.code}: {detail or 'permission denied or repo not accessible'}", + ) + write_result(args.output_file, result) + print(json.dumps(result, ensure_ascii=False)) + return 0 + print(f"GitHub API request failed: {exc.code} {detail}", file=sys.stderr) + return 1 + + write_result(args.output_file, result) + print(json.dumps(result, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_fanout_monthly_optimization_tasks.py b/tests/test_fanout_monthly_optimization_tasks.py new file mode 100644 index 0000000..89370ce --- /dev/null +++ b/tests/test_fanout_monthly_optimization_tasks.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import unittest + +from scripts.fanout_monthly_optimization_tasks import build_issue_body, build_issue_title, build_marker + + +class FanoutMonthlyOptimizationTasksTests(unittest.TestCase): + def setUp(self) -> None: + self.plan = { + "source_reviews": [ + { + "source_repo": "QuantStrategyLab/CryptoLeaderRotation", + "source_issue": {"number": 11, "title": "Monthly Report Review: 2026-04-01"}, + }, + { + "source_repo": "QuantStrategyLab/BinancePlatform", + "source_issue": {"number": 9, "title": "Monthly Execution Review: 2026-03"}, + }, + ], + "repo_action_summary": { + "BinancePlatform": { + "count": 2, + "highest_risk_level": "high", + "actions": [ + { + "risk_level": "high", + "title": "Reconcile March cash flows", + "summary": "Separate withdrawals from mark-to-market moves.", + "source_repo": "QuantStrategyLab/BinancePlatform", + "source_issue_number": 9, + "source_issue_url": "https://github.com/QuantStrategyLab/BinancePlatform/issues/9", + "auto_pr_safe": False, + "experiment_only": False, + }, + { + "risk_level": "low", + "title": "Add zero-trade diagnostics", + "summary": "Keep gating reasons visible in the report.", + "source_repo": "QuantStrategyLab/BinancePlatform", + "source_issue_number": 9, + "source_issue_url": "https://github.com/QuantStrategyLab/BinancePlatform/issues/9", + "auto_pr_safe": True, + "experiment_only": False, + }, + ], + } + }, + } + + def test_build_marker_and_title_include_owner_repo(self) -> None: + self.assertEqual( + build_marker(self.plan, "BinancePlatform"), + "", + ) + self.assertEqual( + build_issue_title(self.plan, "BinancePlatform"), + "Monthly Optimization Tasks · BinancePlatform: 2026-04-01 / 2026-03", + ) + + def test_build_issue_body_lists_repo_specific_actions_and_flags(self) -> None: + body = build_issue_body( + self.plan, + "BinancePlatform", + planner_issue_url="https://github.com/QuantStrategyLab/CryptoLeaderRotation/issues/20", + ) + + self.assertIn("# Monthly Optimization Tasks · BinancePlatform", body) + self.assertIn("Planner issue: https://github.com/QuantStrategyLab/CryptoLeaderRotation/issues/20", body) + self.assertIn("Actions in this repo: `2`", body) + self.assertIn("Highest repo risk: `high`", body) + self.assertIn("Reconcile March cash flows", body) + self.assertIn("Add zero-trade diagnostics [auto-pr-safe]", body) + self.assertIn("Source: [QuantStrategyLab/BinancePlatform #9]", body) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_monthly_optimization_planner_workflow_config.py b/tests/test_monthly_optimization_planner_workflow_config.py index ddc1427..08845fd 100644 --- a/tests/test_monthly_optimization_planner_workflow_config.py +++ b/tests/test_monthly_optimization_planner_workflow_config.py @@ -9,7 +9,7 @@ class MonthlyOptimizationPlannerWorkflowConfigTests(unittest.TestCase): - def test_planner_workflow_downloads_artifacts_and_posts_issue(self) -> None: + def test_planner_workflow_downloads_artifacts_posts_issue_and_fans_out_tasks(self) -> None: workflow = WORKFLOW_PATH.read_text(encoding="utf-8") self.assertIn("workflow_dispatch:", workflow) @@ -25,6 +25,12 @@ def test_planner_workflow_downloads_artifacts_and_posts_issue(self) -> None: self.assertIn("build_ai_review_payload.py", workflow) self.assertIn("build_monthly_optimization_plan.py", workflow) self.assertIn("post_monthly_optimization_issue.py", workflow) + self.assertIn("fanout_monthly_optimization_tasks.py", workflow) + self.assertIn("Fan out CryptoLeaderRotation task issue", workflow) + self.assertIn("Fan out CryptoStrategies task issue", workflow) + self.assertIn("Fan out BinancePlatform task issue", workflow) + self.assertIn("--allow-permission-skip", workflow) + self.assertIn("Append fanout summary", workflow) self.assertIn("upstream_review_payload.json", workflow) self.assertIn("downstream_review_payload.json", workflow) self.assertIn("actions/upload-artifact@v7", workflow)