Skip to content

Commit 0a2feff

Browse files
committed
feat: add guarded auto merge for optimization PRs
1 parent 384e4e0 commit 0a2feff

5 files changed

Lines changed: 447 additions & 39 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: 94 additions & 14 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,15 +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 CryptoStrategies, the shared strategy-logic repository.
144-
Prefer minimal changes in shared helpers, documentation, and tests.
145-
Avoid changing shared production strategy behavior unless the task remains clearly low-risk and local.
146-
If the selected low-risk tasks do not map cleanly to this repository, leave the working tree unchanged.
142+
Prefer minimal changes in documentation, report wording, validation, shadow/challenger plumbing, instrumentation, and tests.
143+
Do not change production selector logic or ranking behavior from this issue alone.
144+
If an eligible task is marked `experiment-only`, keep the change non-production.
145+
Never edit files under src/ in this automation step.
146+
If the selected low-risk tasks already appear implemented on the current main branch, leave the working tree unchanged.
147147
Do not use Bash in this workflow. Limit yourself to file edits and repository-local reasoning.
148-
The workflow will run CI after the draft PR is created.
148+
The workflow will run CI after the PR is created.
149149
150150
## Issue Title
151151
${{ steps.issue_context.outputs.issue_title }}
@@ -163,32 +163,107 @@ jobs:
163163
echo "has_changes=true" >> "$GITHUB_OUTPUT"
164164
fi
165165
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+
166220
- name: Commit and push automation branch
167221
if: steps.changes.outputs.has_changes == 'true'
168222
run: |
169223
git add -A
170224
git commit -m "${{ steps.auto_payload.outputs.commit_message }}"
171225
git push --force-with-lease origin "${{ steps.auto_payload.outputs.branch_name }}"
172226
173-
- name: Create or update draft PR
174-
id: draft_pr
227+
- name: Create or update automation PR
228+
id: automation_pr
175229
if: steps.changes.outputs.has_changes == 'true'
176230
env:
177231
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
178232
run: |
179233
BRANCH_NAME="${{ steps.auto_payload.outputs.branch_name }}"
180234
PR_TITLE="${{ steps.auto_payload.outputs.pr_title }}"
181235
PR_BODY_FILE="${{ steps.auto_payload.outputs.pr_body_file }}"
236+
MERGE_READY="${{ steps.merge_guard.outputs.merge_ready }}"
182237
EXISTING_PR_NUMBER=$(gh pr list --state open --head "${BRANCH_NAME}" --json number --jq '.[0].number // empty')
183238
if [ -n "${EXISTING_PR_NUMBER}" ]; then
184239
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
185247
PR_URL=$(gh pr view "${EXISTING_PR_NUMBER}" --json url --jq '.url')
248+
PR_NUMBER="${EXISTING_PR_NUMBER}"
186249
echo "pr_action=updated" >> "$GITHUB_OUTPUT"
187250
else
188-
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
189262
echo "pr_action=created" >> "$GITHUB_OUTPUT"
190263
fi
191264
echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT"
265+
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
266+
echo "pr_state=${PR_STATE}" >> "$GITHUB_OUTPUT"
192267
193268
- name: Dispatch CI workflow on automation branch
194269
if: steps.changes.outputs.has_changes == 'true'
@@ -202,10 +277,15 @@ jobs:
202277
if [ "${{ steps.changes.outputs.has_changes }}" = "true" ]; then
203278
{
204279
echo ""
205-
echo "## Draft PR Result"
206-
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 }}`"
207284
echo "- CI workflow dispatched on branch: `${{ steps.auto_payload.outputs.branch_name }}`"
208285
} >> "$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
209289
else
210290
echo "No code changes were produced for the selected low-risk tasks." >> "$GITHUB_STEP_SUMMARY"
211291
fi

0 commit comments

Comments
 (0)