Skip to content

Commit a1c964d

Browse files
committed
feat: add guarded auto merge for optimization PRs
1 parent 91fefc6 commit a1c964d

7 files changed

Lines changed: 440 additions & 83 deletions
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
name: Auto Merge Optimization PR
2+
3+
"on":
4+
workflow_run:
5+
workflows: ["CI"]
6+
types: [completed]
7+
8+
jobs:
9+
auto-merge:
10+
if: github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'automation/monthly-optimization-issue-')
11+
runs-on: ubuntu-latest
12+
permissions:
13+
contents: write
14+
pull-requests: write
15+
16+
steps:
17+
- name: Checkout
18+
uses: actions/checkout@v6
19+
20+
- name: Resolve automation PR
21+
id: pr
22+
env:
23+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
24+
run: |
25+
mkdir -p data/output/auto_merge
26+
BRANCH_NAME="${{ github.event.workflow_run.head_branch }}"
27+
PR_NUMBER=$(gh pr list --state open --head "${BRANCH_NAME}" --json number --jq '.[0].number // empty')
28+
if [ -z "${PR_NUMBER}" ]; then
29+
echo "No open automation PR found for ${BRANCH_NAME}." >> "$GITHUB_STEP_SUMMARY"
30+
exit 0
31+
fi
32+
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
33+
34+
- name: Evaluate merge eligibility
35+
id: merge_guard
36+
if: steps.pr.outputs.pr_number != ''
37+
env:
38+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
39+
run: |
40+
gh pr view "${{ steps.pr.outputs.pr_number }}" --json number,isDraft,body,url,files > data/output/auto_merge/pr.json
41+
python3 - <<'PY'
42+
import json
43+
import os
44+
from pathlib import Path
45+
46+
from scripts.prepare_auto_optimization_pr import evaluate_changed_files
47+
48+
pr = json.loads(Path("data/output/auto_merge/pr.json").read_text(encoding="utf-8"))
49+
body = pr.get("body") or ""
50+
changed_files = [item.get("path", "") for item in pr.get("files", [])]
51+
guard = evaluate_changed_files(changed_files)
52+
has_marker = "<!-- auto-optimization-pr:issue-" in body
53+
task_level_allowed = "Task-level auto-merge eligible: `yes`" in body
54+
should_merge = has_marker and task_level_allowed and not pr.get("isDraft") and guard["allowed"]
55+
if not has_marker:
56+
reason = "missing_marker"
57+
elif not task_level_allowed:
58+
reason = "task_level_guard"
59+
elif pr.get("isDraft"):
60+
reason = "draft_pr"
61+
elif not guard["allowed"]:
62+
reason = "sensitive_changed_files"
63+
else:
64+
reason = "ready"
65+
66+
summary_lines = [
67+
"## Auto-Merge Gate",
68+
f"- PR: {pr['url']}",
69+
f"- Draft: `{ 'yes' if pr.get('isDraft') else 'no' }`",
70+
f"- Task-level auto-merge eligible: `{ 'yes' if task_level_allowed else 'no' }`",
71+
f"- Sensitive files touched: `{len(guard['blocked_files'])}`",
72+
f"- Final merge decision: `{ 'merge' if should_merge else 'skip' }`",
73+
f"- Reason: `{reason}`",
74+
]
75+
if guard["blocked_files"]:
76+
summary_lines.append("")
77+
summary_lines.append("### Sensitive changed files")
78+
summary_lines.extend(f"- `{path}`" for path in guard["blocked_files"])
79+
Path("data/output/auto_merge/summary.md").write_text("\n".join(summary_lines).strip() + "\n", encoding="utf-8")
80+
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
81+
print(f"should_merge={'true' if should_merge else 'false'}", file=output)
82+
print(f"reason={reason}", file=output)
83+
print(f"pr_url={pr['url']}", file=output)
84+
PY
85+
86+
- name: Append merge summary
87+
if: steps.pr.outputs.pr_number != ''
88+
run: cat data/output/auto_merge/summary.md >> "$GITHUB_STEP_SUMMARY"
89+
90+
- name: Merge automation PR
91+
if: steps.merge_guard.outputs.should_merge == 'true'
92+
env:
93+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
94+
run: gh pr merge "${{ steps.pr.outputs.pr_number }}" --rebase --delete-branch

.github/workflows/auto_optimization_pr.yml

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,7 @@ jobs:
9393
- name: Prepare auto optimization payload
9494
id: auto_payload
9595
run: |
96-
python3 scripts/prepare_auto_optimization_pr.py \
97-
--issue-context-file data/output/auto_optimization/issue_context.json \
98-
--output-dir data/output/auto_optimization >> "$GITHUB_OUTPUT"
96+
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"
9997
10098
- name: Append task summary
10199
run: cat data/output/auto_optimization/task_summary.md >> "$GITHUB_STEP_SUMMARY"
@@ -115,7 +113,7 @@ jobs:
115113
if [ -f data/output/auto_optimization/skip_reason.txt ]; then
116114
cat data/output/auto_optimization/skip_reason.txt >> "$GITHUB_STEP_SUMMARY"
117115
else
118-
echo "No eligible low-risk auto-pr-safe tasks were found; skipping draft PR generation." >> "$GITHUB_STEP_SUMMARY"
116+
echo "No eligible low-risk auto-pr-safe tasks were found; skipping automation." >> "$GITHUB_STEP_SUMMARY"
119117
fi
120118
121119
- name: Prepare automation branch
@@ -137,16 +135,17 @@ jobs:
137135
claude_args: --max-turns 8
138136
prompt: |
139137
Do not ask for additional approval.
140-
Do not create a pull request yourself. The workflow will handle git, draft PR creation, and CI dispatch.
138+
Do not create a pull request yourself. The workflow will handle git, PR creation, CI dispatch, and post-CI merge.
141139
Only implement the low-risk tasks explicitly marked `[auto-pr-safe]`.
142140
Ignore any medium-risk or high-risk tasks.
143141
You are working inside CryptoLeaderRotation, the upstream selector repository.
144142
Prefer minimal changes in documentation, report wording, validation, shadow/challenger plumbing, instrumentation, and tests.
145143
Do not change production selector logic or ranking behavior from this issue alone.
146144
If an eligible task is marked `experiment-only`, keep the change non-production.
145+
Never edit files under src/ or config/ in this automation step.
147146
If the selected low-risk tasks already appear implemented on the current main branch, leave the working tree unchanged.
148147
Do not use Bash in this workflow. Limit yourself to file edits and repository-local reasoning.
149-
The workflow will run CI after the draft PR is created.
148+
The workflow will run CI after the PR is created.
150149
151150
## Issue Title
152151
${{ steps.issue_context.outputs.issue_title }}
@@ -164,32 +163,107 @@ jobs:
164163
echo "has_changes=true" >> "$GITHUB_OUTPUT"
165164
fi
166165
166+
- name: Evaluate merge guardrails
167+
id: merge_guard
168+
if: steps.changes.outputs.has_changes == 'true'
169+
run: |
170+
git diff --name-only --relative > data/output/auto_optimization/changed_files.txt
171+
python3 - <<'PY'
172+
import json
173+
import os
174+
from pathlib import Path
175+
176+
from scripts.prepare_auto_optimization_pr import evaluate_changed_files
177+
178+
output_dir = Path("data/output/auto_optimization")
179+
payload = json.loads((output_dir / "payload.json").read_text(encoding="utf-8"))
180+
changed_files = [
181+
line.strip()
182+
for line in (output_dir / "changed_files.txt").read_text(encoding="utf-8").splitlines()
183+
if line.strip()
184+
]
185+
guard = evaluate_changed_files(changed_files)
186+
merge_ready = bool(payload.get("task_level_auto_merge_allowed")) and bool(guard["allowed"])
187+
if not payload.get("task_level_auto_merge_allowed"):
188+
reason = "task_level_guard"
189+
elif not guard["allowed"]:
190+
reason = "sensitive_changed_files"
191+
else:
192+
reason = "ready"
193+
194+
summary_lines = [
195+
"## Merge Guardrails",
196+
f"- Task-level auto-merge eligible: `{ 'yes' if payload.get('task_level_auto_merge_allowed') else 'no' }`",
197+
f"- Changed files reviewed: `{len(changed_files)}`",
198+
f"- Sensitive files touched: `{len(guard['blocked_files'])}`",
199+
]
200+
if guard["blocked_files"]:
201+
summary_lines.append("")
202+
summary_lines.append("### Sensitive changed files")
203+
summary_lines.extend(f"- `{path}`" for path in guard["blocked_files"])
204+
if merge_ready:
205+
summary_lines.extend(["", "Ready PR is allowed; the follow-up auto-merge workflow may merge after CI succeeds."])
206+
else:
207+
summary_lines.extend(["", f"PR will stay draft. Guard reason: `{reason}`"])
208+
209+
(output_dir / "guard_summary.md").write_text("\n".join(summary_lines).strip() + "\n", encoding="utf-8")
210+
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
211+
print(f"merge_ready={'true' if merge_ready else 'false'}", file=output)
212+
print(f"guard_reason={reason}", file=output)
213+
print(f"blocked_file_count={len(guard['blocked_files'])}", file=output)
214+
PY
215+
216+
- name: Append merge guard summary
217+
if: steps.changes.outputs.has_changes == 'true'
218+
run: cat data/output/auto_optimization/guard_summary.md >> "$GITHUB_STEP_SUMMARY"
219+
167220
- name: Commit and push automation branch
168221
if: steps.changes.outputs.has_changes == 'true'
169222
run: |
170223
git add -A
171224
git commit -m "${{ steps.auto_payload.outputs.commit_message }}"
172225
git push --force-with-lease origin "${{ steps.auto_payload.outputs.branch_name }}"
173226
174-
- name: Create or update draft PR
175-
id: draft_pr
227+
- name: Create or update automation PR
228+
id: automation_pr
176229
if: steps.changes.outputs.has_changes == 'true'
177230
env:
178231
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
179232
run: |
180233
BRANCH_NAME="${{ steps.auto_payload.outputs.branch_name }}"
181234
PR_TITLE="${{ steps.auto_payload.outputs.pr_title }}"
182235
PR_BODY_FILE="${{ steps.auto_payload.outputs.pr_body_file }}"
236+
MERGE_READY="${{ steps.merge_guard.outputs.merge_ready }}"
183237
EXISTING_PR_NUMBER=$(gh pr list --state open --head "${BRANCH_NAME}" --json number --jq '.[0].number // empty')
184238
if [ -n "${EXISTING_PR_NUMBER}" ]; then
185239
gh pr edit "${EXISTING_PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
240+
if [ "${MERGE_READY}" = "true" ]; then
241+
gh pr ready "${EXISTING_PR_NUMBER}" || true
242+
PR_STATE="ready_for_review"
243+
else
244+
gh pr ready "${EXISTING_PR_NUMBER}" --undo || true
245+
PR_STATE="draft"
246+
fi
186247
PR_URL=$(gh pr view "${EXISTING_PR_NUMBER}" --json url --jq '.url')
248+
PR_NUMBER="${EXISTING_PR_NUMBER}"
187249
echo "pr_action=updated" >> "$GITHUB_OUTPUT"
188250
else
189-
PR_URL=$(gh pr create --draft --base main --head "${BRANCH_NAME}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}")
251+
CREATE_ARGS=(--base main --head "${BRANCH_NAME}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}")
252+
if [ "${MERGE_READY}" != "true" ]; then
253+
CREATE_ARGS=(--draft "${CREATE_ARGS[@]}")
254+
fi
255+
PR_URL=$(gh pr create "${CREATE_ARGS[@]}")
256+
PR_NUMBER=$(gh pr view "${PR_URL}" --json number --jq '.number')
257+
if [ "${MERGE_READY}" = "true" ]; then
258+
PR_STATE="ready_for_review"
259+
else
260+
PR_STATE="draft"
261+
fi
190262
echo "pr_action=created" >> "$GITHUB_OUTPUT"
191263
fi
192264
echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT"
265+
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
266+
echo "pr_state=${PR_STATE}" >> "$GITHUB_OUTPUT"
193267
194268
- name: Dispatch CI workflow on automation branch
195269
if: steps.changes.outputs.has_changes == 'true'
@@ -203,10 +277,15 @@ jobs:
203277
if [ "${{ steps.changes.outputs.has_changes }}" = "true" ]; then
204278
{
205279
echo ""
206-
echo "## Draft PR Result"
207-
echo "- Draft PR ${{ steps.draft_pr.outputs.pr_action }}: ${{ steps.draft_pr.outputs.pr_url }}"
280+
echo "## Automation PR Result"
281+
echo "- PR ${{ steps.automation_pr.outputs.pr_action }}: ${{ steps.automation_pr.outputs.pr_url }}"
282+
echo "- PR state: `${{ steps.automation_pr.outputs.pr_state }}`"
283+
echo "- Guard reason: `${{ steps.merge_guard.outputs.guard_reason }}`"
208284
echo "- CI workflow dispatched on branch: `${{ steps.auto_payload.outputs.branch_name }}`"
209285
} >> "$GITHUB_STEP_SUMMARY"
286+
if [ "${{ steps.merge_guard.outputs.merge_ready }}" = "true" ]; then
287+
echo "Auto-merge will be handled only after a successful CI workflow run." >> "$GITHUB_STEP_SUMMARY"
288+
fi
210289
else
211290
echo "No code changes were produced for the selected low-risk tasks." >> "$GITHUB_STEP_SUMMARY"
212291
fi

.github/workflows/monthly_optimization_planner.yml

Lines changed: 11 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -212,64 +212,22 @@ jobs:
212212
print(f"issue_number={fanout.get('issue_number') or ''}", file=output)
213213
PY
214214
215-
- name: Trigger BinancePlatform experiment validation by label
215+
- name: Best-effort label BinancePlatform issue for experiment validation
216216
if: steps.downstream_experiment_target.outputs.should_dispatch == 'true'
217217
env:
218-
GITHUB_TOKEN: ${{ secrets.CROSS_REPO_GITHUB_TOKEN }}
218+
GH_TOKEN: ${{ secrets.CROSS_REPO_GITHUB_TOKEN }}
219219
TARGET_REPO: ${{ inputs.downstream_repo }}
220220
ISSUE_NUMBER: ${{ steps.downstream_experiment_target.outputs.issue_number }}
221221
run: |
222-
python3 - <<'PY'
223-
import json
224-
import os
225-
import urllib.error
226-
import urllib.parse
227-
import urllib.request
228-
229-
token = os.environ["GITHUB_TOKEN"]
230-
repo = os.environ["TARGET_REPO"]
231-
issue_number = os.environ["ISSUE_NUMBER"]
232-
label_name = "experiment-validation"
233-
api_base = f"https://api.github.com/repos/{repo}"
234-
headers = {
235-
"Authorization": f"Bearer {token}",
236-
"Accept": "application/vnd.github+json",
237-
"X-GitHub-Api-Version": "2022-11-28",
238-
"User-Agent": "monthly-optimization-planner",
239-
}
240-
241-
def request_json(method: str, url: str, payload: dict | None = None):
242-
data = None
243-
if payload is not None:
244-
data = json.dumps(payload).encode("utf-8")
245-
request = urllib.request.Request(url, data=data, method=method, headers=headers)
246-
with urllib.request.urlopen(request) as response:
247-
raw = response.read().decode("utf-8")
248-
return json.loads(raw) if raw else {}
249-
250-
label_path = urllib.parse.quote(label_name, safe="")
251-
try:
252-
request_json("GET", f"{api_base}/labels/{label_path}")
253-
except urllib.error.HTTPError as exc:
254-
if exc.code != 404:
255-
raise
256-
request_json(
257-
"POST",
258-
f"{api_base}/labels",
259-
{
260-
"name": label_name,
261-
"color": "1D76DB",
262-
"description": "Trigger experiment validation for monthly optimization tasks",
263-
},
264-
)
265-
266-
issue = request_json("GET", f"{api_base}/issues/{issue_number}")
267-
existing_labels = [label["name"] for label in issue.get("labels", [])]
268-
if label_name in existing_labels:
269-
request_json("DELETE", f"{api_base}/issues/{issue_number}/labels/{label_path}")
270-
request_json("POST", f"{api_base}/issues/{issue_number}/labels", {"labels": [label_name]})
271-
print(f"Toggled {label_name} on issue #{issue_number} in {repo}")
272-
PY
222+
set +e
223+
gh label create experiment-validation --repo "$TARGET_REPO" --color 1D76DB --description "Trigger experiment validation for monthly optimization tasks" --force
224+
label_status=$?
225+
gh issue edit "$ISSUE_NUMBER" --repo "$TARGET_REPO" --add-label experiment-validation
226+
issue_status=$?
227+
set -e
228+
if [ "$label_status" -ne 0 ] || [ "$issue_status" -ne 0 ]; then
229+
echo "Downstream experiment-validation label update skipped for $TARGET_REPO#$ISSUE_NUMBER." >> "$GITHUB_STEP_SUMMARY"
230+
fi
273231
274232
- name: Dispatch BinancePlatform experiment validation
275233
if: steps.downstream_experiment_target.outputs.should_dispatch == 'true'

0 commit comments

Comments
 (0)