Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .github/workflows/auto_merge_optimization_pr.yml
Original file line number Diff line number Diff line change
@@ -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 = "<!-- auto-optimization-pr:issue-" in body
task_level_allowed = "Task-level auto-merge eligible: `yes`" in body
should_merge = has_marker and task_level_allowed and not pr.get("isDraft") and guard["allowed"]
if not has_marker:
reason = "missing_marker"
elif not task_level_allowed:
reason = "task_level_guard"
elif pr.get("isDraft"):
reason = "draft_pr"
elif not guard["allowed"]:
reason = "sensitive_changed_files"
else:
reason = "ready"

summary_lines = [
"## Auto-Merge Gate",
f"- PR: {pr['url']}",
f"- Draft: `{ 'yes' if pr.get('isDraft') else 'no' }`",
f"- Task-level auto-merge eligible: `{ 'yes' if task_level_allowed else 'no' }`",
f"- Sensitive files touched: `{len(guard['blocked_files'])}`",
f"- Final merge decision: `{ 'merge' if should_merge else 'skip' }`",
f"- Reason: `{reason}`",
]
if guard["blocked_files"]:
summary_lines.append("")
summary_lines.append("### Sensitive changed files")
summary_lines.extend(f"- `{path}`" for path in guard["blocked_files"])
Path("data/output/auto_merge/summary.md").write_text("\n".join(summary_lines).strip() + "\n", encoding="utf-8")
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
print(f"should_merge={'true' if should_merge else 'false'}", file=output)
print(f"reason={reason}", file=output)
print(f"pr_url={pr['url']}", file=output)
PY

- name: Append merge summary
if: steps.pr.outputs.pr_number != ''
run: cat data/output/auto_merge/summary.md >> "$GITHUB_STEP_SUMMARY"

- name: Merge automation PR
if: steps.merge_guard.outputs.should_merge == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh pr merge "${{ steps.pr.outputs.pr_number }}" --rebase --delete-branch
101 changes: 90 additions & 11 deletions .github/workflows/auto_optimization_pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,7 @@ jobs:
- name: Prepare auto optimization payload
id: auto_payload
run: |
python3 scripts/prepare_auto_optimization_pr.py \
--issue-context-file data/output/auto_optimization/issue_context.json \
--output-dir data/output/auto_optimization >> "$GITHUB_OUTPUT"
python3 scripts/prepare_auto_optimization_pr.py --issue-context-file data/output/auto_optimization/issue_context.json --output-dir data/output/auto_optimization >> "$GITHUB_OUTPUT"

- name: Append task summary
run: cat data/output/auto_optimization/task_summary.md >> "$GITHUB_STEP_SUMMARY"
Expand All @@ -115,7 +113,7 @@ jobs:
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"
echo "No eligible low-risk auto-pr-safe tasks were found; skipping automation." >> "$GITHUB_STEP_SUMMARY"
fi

- name: Prepare automation branch
Expand All @@ -137,16 +135,17 @@ jobs:
claude_args: --max-turns 8
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.
Do not create a pull request yourself. The workflow will handle git, PR creation, CI dispatch, and post-CI merge.
Only implement the low-risk tasks explicitly marked `[auto-pr-safe]`.
Ignore any medium-risk or high-risk tasks.
You are working inside CryptoLeaderRotation, the upstream selector repository.
Prefer minimal changes in documentation, report wording, validation, shadow/challenger plumbing, instrumentation, and tests.
Do not change production selector logic or ranking behavior from this issue alone.
If an eligible task is marked `experiment-only`, keep the change non-production.
Never edit files under src/ or config/ in this automation step.
If the selected low-risk tasks already appear implemented on the current main branch, leave the working tree unchanged.
Do not use Bash in this workflow. Limit yourself to file edits and repository-local reasoning.
The workflow will run CI after the draft PR is created.
The workflow will run CI after the PR is created.

## Issue Title
${{ steps.issue_context.outputs.issue_title }}
Expand All @@ -164,32 +163,107 @@ jobs:
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi

- name: Evaluate merge guardrails
id: merge_guard
if: steps.changes.outputs.has_changes == 'true'
run: |
git diff --name-only --relative > data/output/auto_optimization/changed_files.txt
python3 - <<'PY'
import json
import os
from pathlib import Path

from scripts.prepare_auto_optimization_pr import evaluate_changed_files

output_dir = Path("data/output/auto_optimization")
payload = json.loads((output_dir / "payload.json").read_text(encoding="utf-8"))
changed_files = [
line.strip()
for line in (output_dir / "changed_files.txt").read_text(encoding="utf-8").splitlines()
if line.strip()
]
guard = evaluate_changed_files(changed_files)
merge_ready = bool(payload.get("task_level_auto_merge_allowed")) and bool(guard["allowed"])
if not payload.get("task_level_auto_merge_allowed"):
reason = "task_level_guard"
elif not guard["allowed"]:
reason = "sensitive_changed_files"
else:
reason = "ready"

summary_lines = [
"## Merge Guardrails",
f"- Task-level auto-merge eligible: `{ 'yes' if payload.get('task_level_auto_merge_allowed') else 'no' }`",
f"- Changed files reviewed: `{len(changed_files)}`",
f"- Sensitive files touched: `{len(guard['blocked_files'])}`",
]
if guard["blocked_files"]:
summary_lines.append("")
summary_lines.append("### Sensitive changed files")
summary_lines.extend(f"- `{path}`" for path in guard["blocked_files"])
if merge_ready:
summary_lines.extend(["", "Ready PR is allowed; the follow-up auto-merge workflow may merge after CI succeeds."])
else:
summary_lines.extend(["", f"PR will stay draft. Guard reason: `{reason}`"])

(output_dir / "guard_summary.md").write_text("\n".join(summary_lines).strip() + "\n", encoding="utf-8")
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
print(f"merge_ready={'true' if merge_ready else 'false'}", file=output)
print(f"guard_reason={reason}", file=output)
print(f"blocked_file_count={len(guard['blocked_files'])}", file=output)
PY

- name: Append merge guard summary
if: steps.changes.outputs.has_changes == 'true'
run: cat data/output/auto_optimization/guard_summary.md >> "$GITHUB_STEP_SUMMARY"

- 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
- name: Create or update automation PR
id: automation_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 }}"
MERGE_READY="${{ steps.merge_guard.outputs.merge_ready }}"
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}"
if [ "${MERGE_READY}" = "true" ]; then
gh pr ready "${EXISTING_PR_NUMBER}" || true
PR_STATE="ready_for_review"
else
gh pr ready "${EXISTING_PR_NUMBER}" --undo || true
PR_STATE="draft"
fi
PR_URL=$(gh pr view "${EXISTING_PR_NUMBER}" --json url --jq '.url')
PR_NUMBER="${EXISTING_PR_NUMBER}"
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}")
CREATE_ARGS=(--base main --head "${BRANCH_NAME}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}")
if [ "${MERGE_READY}" != "true" ]; then
CREATE_ARGS=(--draft "${CREATE_ARGS[@]}")
fi
PR_URL=$(gh pr create "${CREATE_ARGS[@]}")
PR_NUMBER=$(gh pr view "${PR_URL}" --json number --jq '.number')
if [ "${MERGE_READY}" = "true" ]; then
PR_STATE="ready_for_review"
else
PR_STATE="draft"
fi
echo "pr_action=created" >> "$GITHUB_OUTPUT"
fi
echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT"
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
echo "pr_state=${PR_STATE}" >> "$GITHUB_OUTPUT"

- name: Dispatch CI workflow on automation branch
if: steps.changes.outputs.has_changes == 'true'
Expand All @@ -203,10 +277,15 @@ jobs:
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 "## Automation PR Result"
echo "- PR ${{ steps.automation_pr.outputs.pr_action }}: ${{ steps.automation_pr.outputs.pr_url }}"
echo "- PR state: `${{ steps.automation_pr.outputs.pr_state }}`"
echo "- Guard reason: `${{ steps.merge_guard.outputs.guard_reason }}`"
echo "- CI workflow dispatched on branch: `${{ steps.auto_payload.outputs.branch_name }}`"
} >> "$GITHUB_STEP_SUMMARY"
if [ "${{ steps.merge_guard.outputs.merge_ready }}" = "true" ]; then
echo "Auto-merge will be handled only after a successful CI workflow run." >> "$GITHUB_STEP_SUMMARY"
fi
else
echo "No code changes were produced for the selected low-risk tasks." >> "$GITHUB_STEP_SUMMARY"
fi
64 changes: 11 additions & 53 deletions .github/workflows/monthly_optimization_planner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -212,64 +212,22 @@ jobs:
print(f"issue_number={fanout.get('issue_number') or ''}", file=output)
PY

- name: Trigger BinancePlatform experiment validation by label
- name: Best-effort label BinancePlatform issue for experiment validation
if: steps.downstream_experiment_target.outputs.should_dispatch == 'true'
env:
GITHUB_TOKEN: ${{ secrets.CROSS_REPO_GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.CROSS_REPO_GITHUB_TOKEN }}
TARGET_REPO: ${{ inputs.downstream_repo }}
ISSUE_NUMBER: ${{ steps.downstream_experiment_target.outputs.issue_number }}
run: |
python3 - <<'PY'
import json
import os
import urllib.error
import urllib.parse
import urllib.request

token = os.environ["GITHUB_TOKEN"]
repo = os.environ["TARGET_REPO"]
issue_number = os.environ["ISSUE_NUMBER"]
label_name = "experiment-validation"
api_base = f"https://api.github.com/repos/{repo}"
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "monthly-optimization-planner",
}

def request_json(method: str, url: str, payload: dict | None = None):
data = None
if payload is not None:
data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(url, data=data, method=method, headers=headers)
with urllib.request.urlopen(request) as response:
raw = response.read().decode("utf-8")
return json.loads(raw) if raw else {}

label_path = urllib.parse.quote(label_name, safe="")
try:
request_json("GET", f"{api_base}/labels/{label_path}")
except urllib.error.HTTPError as exc:
if exc.code != 404:
raise
request_json(
"POST",
f"{api_base}/labels",
{
"name": label_name,
"color": "1D76DB",
"description": "Trigger experiment validation for monthly optimization tasks",
},
)

issue = request_json("GET", f"{api_base}/issues/{issue_number}")
existing_labels = [label["name"] for label in issue.get("labels", [])]
if label_name in existing_labels:
request_json("DELETE", f"{api_base}/issues/{issue_number}/labels/{label_path}")
request_json("POST", f"{api_base}/issues/{issue_number}/labels", {"labels": [label_name]})
print(f"Toggled {label_name} on issue #{issue_number} in {repo}")
PY
set +e
gh label create experiment-validation --repo "$TARGET_REPO" --color 1D76DB --description "Trigger experiment validation for monthly optimization tasks" --force
label_status=$?
gh issue edit "$ISSUE_NUMBER" --repo "$TARGET_REPO" --add-label experiment-validation
issue_status=$?
set -e
if [ "$label_status" -ne 0 ] || [ "$issue_status" -ne 0 ]; then
echo "Downstream experiment-validation label update skipped for $TARGET_REPO#$ISSUE_NUMBER." >> "$GITHUB_STEP_SUMMARY"
fi

- name: Dispatch BinancePlatform experiment validation
if: steps.downstream_experiment_target.outputs.should_dispatch == 'true'
Expand Down
Loading