Skip to content

Commit f2f6a6e

Browse files
committed
feat: add auto optimization draft pr workflow
1 parent 78b1af4 commit f2f6a6e

5 files changed

Lines changed: 445 additions & 0 deletions

File tree

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
name: Auto Optimization Draft PR
2+
3+
"on":
4+
issues:
5+
types: [opened, edited, reopened, labeled]
6+
workflow_dispatch:
7+
inputs:
8+
issue_number:
9+
description: "Monthly optimization task issue number"
10+
required: true
11+
12+
jobs:
13+
auto-pr:
14+
if: contains(github.event.issue.labels.*.name, 'monthly-optimization-task') || inputs.issue_number != ''
15+
runs-on: ubuntu-latest
16+
permissions:
17+
actions: write
18+
contents: write
19+
issues: write
20+
pull-requests: write
21+
22+
env:
23+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
24+
25+
steps:
26+
- name: Checkout
27+
uses: actions/checkout@v6
28+
with:
29+
fetch-depth: 0
30+
31+
- name: Check automation prerequisites
32+
id: prereqs
33+
env:
34+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
35+
run: |
36+
mkdir -p data/output/auto_optimization
37+
if [ -z "${ANTHROPIC_API_KEY}" ]; then
38+
echo "has_anthropic_key=false" >> "$GITHUB_OUTPUT"
39+
echo "Claude automation skipped: ANTHROPIC_API_KEY is not configured for this repo." > data/output/auto_optimization/skip_reason.txt
40+
else
41+
echo "has_anthropic_key=true" >> "$GITHUB_OUTPUT"
42+
fi
43+
44+
- name: Load issue context
45+
id: issue_context
46+
run: |
47+
python3 - <<'PY'
48+
import json
49+
import os
50+
import urllib.request
51+
from pathlib import Path
52+
53+
repo = os.environ["GITHUB_REPOSITORY"]
54+
issue_number = os.environ["ISSUE_NUMBER"]
55+
token = os.environ["GITHUB_TOKEN"]
56+
api_url = f"https://api.github.com/repos/{repo}/issues/{issue_number}"
57+
request = urllib.request.Request(
58+
api_url,
59+
headers={
60+
"Accept": "application/vnd.github+json",
61+
"Authorization": f"Bearer {token}",
62+
"X-GitHub-Api-Version": "2022-11-28",
63+
"User-Agent": "auto-optimization-pr",
64+
},
65+
)
66+
with urllib.request.urlopen(request) as response:
67+
issue = json.load(response)
68+
69+
issue_context = {
70+
"number": issue["number"],
71+
"title": issue["title"],
72+
"body": issue.get("body", ""),
73+
}
74+
output_dir = Path("data/output/auto_optimization")
75+
output_dir.mkdir(parents=True, exist_ok=True)
76+
(output_dir / "issue_context.json").write_text(
77+
json.dumps(issue_context, ensure_ascii=False, indent=2) + "\n",
78+
encoding="utf-8",
79+
)
80+
81+
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
82+
print("issue_title<<EOF", file=output)
83+
print(issue_context["title"], file=output)
84+
print("EOF", file=output)
85+
print("issue_body<<EOF", file=output)
86+
print(issue_context["body"], file=output)
87+
print("EOF", file=output)
88+
PY
89+
env:
90+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
91+
ISSUE_NUMBER: ${{ inputs.issue_number || github.event.issue.number }}
92+
93+
- name: Prepare auto optimization payload
94+
id: auto_payload
95+
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"
99+
100+
- name: Append task summary
101+
run: cat data/output/auto_optimization/task_summary.md >> "$GITHUB_STEP_SUMMARY"
102+
103+
- name: Append skip reason
104+
if: steps.prereqs.outputs.has_anthropic_key != 'true' || steps.auto_payload.outputs.should_run != 'true'
105+
run: |
106+
if [ -f data/output/auto_optimization/skip_reason.txt ]; then
107+
cat data/output/auto_optimization/skip_reason.txt >> "$GITHUB_STEP_SUMMARY"
108+
else
109+
echo "No eligible low-risk auto-pr-safe tasks were found; skipping draft PR generation." >> "$GITHUB_STEP_SUMMARY"
110+
fi
111+
112+
- name: Prepare automation branch
113+
if: steps.prereqs.outputs.has_anthropic_key == 'true' && steps.auto_payload.outputs.should_run == 'true'
114+
run: |
115+
git config user.name "github-actions[bot]"
116+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
117+
git checkout -B "${{ steps.auto_payload.outputs.branch_name }}"
118+
119+
- name: Run Claude auto optimization
120+
if: steps.prereqs.outputs.has_anthropic_key == 'true' && steps.auto_payload.outputs.should_run == 'true'
121+
uses: anthropics/claude-code-action@v1
122+
with:
123+
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
124+
github_token: ${{ secrets.GITHUB_TOKEN }}
125+
use_bedrock: false
126+
use_vertex: false
127+
prompt: |
128+
Do not ask for additional approval.
129+
Do not create a pull request yourself. The workflow will handle git, draft PR creation, and CI dispatch.
130+
Only implement the low-risk tasks explicitly marked `[auto-pr-safe]`.
131+
Ignore any medium-risk or high-risk tasks.
132+
You are working inside CryptoStrategies, the shared strategy-logic repository.
133+
Prefer minimal changes in shared helpers, documentation, and tests.
134+
Avoid changing shared production strategy behavior unless the task remains clearly low-risk and local.
135+
If the selected low-risk tasks do not map cleanly to this repository, leave the working tree unchanged.
136+
Run the most relevant tests or ruff checks when you make a change.
137+
138+
## Issue Title
139+
${{ steps.issue_context.outputs.issue_title }}
140+
141+
## Issue Body
142+
${{ steps.issue_context.outputs.issue_body }}
143+
144+
- name: Detect changes
145+
id: changes
146+
if: steps.prereqs.outputs.has_anthropic_key == 'true' && steps.auto_payload.outputs.should_run == 'true'
147+
run: |
148+
if git diff --quiet; then
149+
echo "has_changes=false" >> "$GITHUB_OUTPUT"
150+
else
151+
echo "has_changes=true" >> "$GITHUB_OUTPUT"
152+
fi
153+
154+
- name: Commit and push automation branch
155+
if: steps.changes.outputs.has_changes == 'true'
156+
run: |
157+
git add -A
158+
git commit -m "${{ steps.auto_payload.outputs.commit_message }}"
159+
git push --force-with-lease origin "${{ steps.auto_payload.outputs.branch_name }}"
160+
161+
- name: Create or update draft PR
162+
id: draft_pr
163+
if: steps.changes.outputs.has_changes == 'true'
164+
env:
165+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
166+
run: |
167+
BRANCH_NAME="${{ steps.auto_payload.outputs.branch_name }}"
168+
PR_TITLE="${{ steps.auto_payload.outputs.pr_title }}"
169+
PR_BODY_FILE="${{ steps.auto_payload.outputs.pr_body_file }}"
170+
EXISTING_PR_NUMBER=$(gh pr list --state open --head "${BRANCH_NAME}" --json number --jq '.[0].number // empty')
171+
if [ -n "${EXISTING_PR_NUMBER}" ]; then
172+
gh pr edit "${EXISTING_PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
173+
PR_URL=$(gh pr view "${EXISTING_PR_NUMBER}" --json url --jq '.url')
174+
echo "pr_action=updated" >> "$GITHUB_OUTPUT"
175+
else
176+
PR_URL=$(gh pr create --draft --base main --head "${BRANCH_NAME}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}")
177+
echo "pr_action=created" >> "$GITHUB_OUTPUT"
178+
fi
179+
echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT"
180+
181+
- name: Dispatch CI workflow on automation branch
182+
if: steps.changes.outputs.has_changes == 'true'
183+
env:
184+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
185+
run: gh workflow run ci.yml --ref "${{ steps.auto_payload.outputs.branch_name }}"
186+
187+
- name: Append automation result
188+
if: steps.prereqs.outputs.has_anthropic_key == 'true' && steps.auto_payload.outputs.should_run == 'true'
189+
run: |
190+
if [ "${{ steps.changes.outputs.has_changes }}" = "true" ]; then
191+
{
192+
echo ""
193+
echo "## Draft PR Result"
194+
echo "- Draft PR ${{ steps.draft_pr.outputs.pr_action }}: ${{ steps.draft_pr.outputs.pr_url }}"
195+
echo "- CI workflow dispatched on branch: `${{ steps.auto_payload.outputs.branch_name }}`"
196+
} >> "$GITHUB_STEP_SUMMARY"
197+
else
198+
echo "No code changes were produced for the selected low-risk tasks." >> "$GITHUB_STEP_SUMMARY"
199+
fi

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ on:
44
push:
55
branches: [ main ]
66
pull_request:
7+
workflow_dispatch:
78

89
jobs:
910
test:
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import json
5+
import re
6+
from pathlib import Path
7+
from typing import Any
8+
9+
10+
ACTION_RE = re.compile(r"^- \[ \] `(?P<risk>low|medium|high)` (?P<title>.+?)(?: \[(?P<flags>[^\]]+)\])?$")
11+
SUMMARY_RE = re.compile(r"^\s+- Summary: (?P<summary>.+)$")
12+
SOURCE_RE = re.compile(r"^\s+- Source: \[(?P<label>.+?)\]\((?P<url>[^)]+)\)$")
13+
MARKER_PREFIX = "<!-- auto-optimization-pr:issue-"
14+
15+
16+
def parse_actions(issue_body: str) -> list[dict[str, Any]]:
17+
actions: list[dict[str, Any]] = []
18+
current: dict[str, Any] | None = None
19+
in_actions = False
20+
21+
for raw_line in issue_body.splitlines():
22+
line = raw_line.rstrip()
23+
if line == "## Actions":
24+
in_actions = True
25+
continue
26+
if in_actions and line.startswith("## "):
27+
break
28+
if not in_actions:
29+
continue
30+
31+
action_match = ACTION_RE.match(line)
32+
if action_match:
33+
if current is not None:
34+
actions.append(current)
35+
flags = [flag.strip() for flag in (action_match.group("flags") or "").split(",") if flag.strip()]
36+
current = {
37+
"risk_level": action_match.group("risk"),
38+
"title": action_match.group("title").strip(),
39+
"flags": flags,
40+
}
41+
continue
42+
43+
if current is None:
44+
continue
45+
46+
summary_match = SUMMARY_RE.match(line)
47+
if summary_match:
48+
current["summary"] = summary_match.group("summary").strip()
49+
continue
50+
51+
source_match = SOURCE_RE.match(line)
52+
if source_match:
53+
current["source_label"] = source_match.group("label").strip()
54+
current["source_url"] = source_match.group("url").strip()
55+
56+
if current is not None:
57+
actions.append(current)
58+
return actions
59+
60+
61+
def build_payload(issue_context: dict[str, Any]) -> dict[str, Any]:
62+
issue_number = int(issue_context["number"])
63+
issue_title = str(issue_context["title"]).strip()
64+
issue_body = str(issue_context["body"])
65+
parsed_actions = parse_actions(issue_body)
66+
safe_actions = [
67+
action for action in parsed_actions
68+
if action["risk_level"] == "low" and "auto-pr-safe" in action.get("flags", [])
69+
]
70+
return {
71+
"issue_number": issue_number,
72+
"issue_title": issue_title,
73+
"branch_name": f"automation/monthly-optimization-issue-{issue_number}",
74+
"commit_message": f"chore: address monthly optimization issue #{issue_number}",
75+
"pr_title": f"Draft: address monthly optimization issue #{issue_number}",
76+
"should_run": bool(safe_actions),
77+
"safe_task_count": len(safe_actions),
78+
"safe_actions": safe_actions,
79+
}
80+
81+
82+
def render_task_summary(payload: dict[str, Any]) -> str:
83+
lines = [
84+
"# Auto Optimization Candidate Tasks",
85+
"",
86+
f"- Issue: #{payload['issue_number']} {payload['issue_title']}",
87+
f"- Eligible low-risk auto-pr-safe tasks: `{payload['safe_task_count']}`",
88+
]
89+
if not payload["safe_actions"]:
90+
lines.extend(["", "No eligible low-risk [auto-pr-safe] tasks were found in this issue."])
91+
return "\n".join(lines).strip() + "\n"
92+
93+
lines.extend(["", "## Selected Tasks"])
94+
for action in payload["safe_actions"]:
95+
flag_suffix = f" [{', '.join(action['flags'])}]" if action.get("flags") else ""
96+
lines.extend(
97+
[
98+
f"- `{action['risk_level']}` {action['title']}{flag_suffix}",
99+
f" - Summary: {action.get('summary', 'No summary provided.')}",
100+
f" - Source: {action.get('source_label', 'Unknown source')} ({action.get('source_url', 'n/a')})",
101+
]
102+
)
103+
return "\n".join(lines).strip() + "\n"
104+
105+
106+
def render_pr_body(payload: dict[str, Any]) -> str:
107+
lines = [
108+
f"{MARKER_PREFIX}{payload['issue_number']} -->",
109+
"## Summary",
110+
"This draft PR was generated from a monthly optimization task issue.",
111+
"It only targets low-risk items explicitly marked `[auto-pr-safe]`.",
112+
"",
113+
"## Auto-selected tasks",
114+
]
115+
for action in payload["safe_actions"]:
116+
lines.append(f"- {action['title']}: {action.get('summary', 'No summary provided.')}")
117+
lines.extend(["", f"Refs #{payload['issue_number']}"])
118+
return "\n".join(lines).strip() + "\n"
119+
120+
121+
def parse_args() -> argparse.Namespace:
122+
parser = argparse.ArgumentParser(description="Prepare metadata for auto-generated optimization draft PRs.")
123+
parser.add_argument("--issue-context-file", required=True, type=Path)
124+
parser.add_argument("--output-dir", required=True, type=Path)
125+
return parser.parse_args()
126+
127+
128+
def main() -> int:
129+
args = parse_args()
130+
issue_context = json.loads(args.issue_context_file.read_text(encoding="utf-8"))
131+
payload = build_payload(issue_context)
132+
args.output_dir.mkdir(parents=True, exist_ok=True)
133+
payload_file = args.output_dir / "payload.json"
134+
task_summary_file = args.output_dir / "task_summary.md"
135+
pr_body_file = args.output_dir / "pr_body.md"
136+
payload_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
137+
task_summary_file.write_text(render_task_summary(payload), encoding="utf-8")
138+
pr_body_file.write_text(render_pr_body(payload), encoding="utf-8")
139+
print(f"should_run={'true' if payload['should_run'] else 'false'}")
140+
print(f"issue_number={payload['issue_number']}")
141+
print(f"branch_name={payload['branch_name']}")
142+
print(f"commit_message={payload['commit_message']}")
143+
print(f"pr_title={payload['pr_title']}")
144+
print(f"safe_task_count={payload['safe_task_count']}")
145+
print(f"payload_file={payload_file}")
146+
print(f"task_summary_file={task_summary_file}")
147+
print(f"pr_body_file={pr_body_file}")
148+
return 0
149+
150+
151+
if __name__ == "__main__":
152+
raise SystemExit(main())
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from __future__ import annotations
2+
3+
import unittest
4+
from pathlib import Path
5+
6+
7+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
8+
AUTO_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "auto_optimization_pr.yml"
9+
CI_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "ci.yml"
10+
11+
12+
class AutoOptimizationPrWorkflowConfigTests(unittest.TestCase):
13+
def test_auto_optimization_workflow_handles_monthly_task_issues(self) -> None:
14+
workflow = AUTO_WORKFLOW.read_text(encoding="utf-8")
15+
16+
self.assertIn("issues:", workflow)
17+
self.assertIn("monthly-optimization-task", workflow)
18+
self.assertIn("workflow_dispatch:", workflow)
19+
self.assertIn("issue_number:", workflow)
20+
self.assertIn("actions: write", workflow)
21+
self.assertIn("contents: write", workflow)
22+
self.assertIn("pull-requests: write", workflow)
23+
self.assertIn("ANTHROPIC_API_KEY", workflow)
24+
self.assertIn("prepare_auto_optimization_pr.py", workflow)
25+
self.assertIn("anthropics/claude-code-action@v1", workflow)
26+
self.assertIn("gh pr create --draft", workflow)
27+
self.assertIn("gh workflow run ci.yml", workflow)
28+
self.assertIn("fetch-depth: 0", workflow)
29+
self.assertIn('FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"', workflow)
30+
self.assertIn("You are working inside CryptoStrategies, the shared strategy-logic repository.", workflow)
31+
32+
def test_ci_workflow_supports_manual_dispatch(self) -> None:
33+
workflow = CI_WORKFLOW.read_text(encoding="utf-8")
34+
self.assertIn("workflow_dispatch:", workflow)
35+
36+
37+
if __name__ == "__main__":
38+
unittest.main()

0 commit comments

Comments
 (0)