Move release scripts to private .scripts folder
- Remove release-agent.py, build-nightly.py from public repo - Add .scripts/ to .gitignore for private tools - Maintain clean public repository while keeping internal tools
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
|
# Scripts folder (private tools)
|
||||||
|
.scripts/
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|||||||
137
build-nightly.py
137
build-nightly.py
@@ -1,137 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Build script for creating nightly versions of Crawl4AI.
|
|
||||||
This script temporarily modifies pyproject.toml to build the nightly package.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
def modify_files_for_nightly():
|
|
||||||
"""Modify pyproject.toml and __version__.py for nightly package."""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
# Generate date-based version: YY.M.D.HHMMSS
|
|
||||||
now = datetime.utcnow()
|
|
||||||
nightly_version = f"{now.year % 100}.{now.month}.{now.day}.{now.strftime('%H%M%S')}"
|
|
||||||
|
|
||||||
# 1. Modify pyproject.toml
|
|
||||||
pyproject_path = Path("pyproject.toml")
|
|
||||||
if not pyproject_path.exists():
|
|
||||||
print("Error: pyproject.toml not found!")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
with open(pyproject_path, 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Create backup
|
|
||||||
pyproject_backup = pyproject_path.with_suffix('.toml.backup')
|
|
||||||
shutil.copy2(pyproject_path, pyproject_backup)
|
|
||||||
print(f"Created backup: {pyproject_backup}")
|
|
||||||
|
|
||||||
# Modify content for nightly build
|
|
||||||
modified_content = content.replace(
|
|
||||||
'name = "Crawl4AI"',
|
|
||||||
'name = "crawl4ai-nightly"'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Also update the description
|
|
||||||
modified_content = modified_content.replace(
|
|
||||||
'description = "🚀🤖 Crawl4AI: Open-source LLM Friendly Web Crawler & scraper"',
|
|
||||||
'description = "🚀🤖 Crawl4AI: Open-source LLM Friendly Web Crawler & scraper (Nightly Build)"'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update the version attribute to use __nightly_version__
|
|
||||||
modified_content = modified_content.replace(
|
|
||||||
'version = {attr = "crawl4ai.__version__.__version__"}',
|
|
||||||
'version = {attr = "crawl4ai.__version__.__nightly_version__"}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Write modified content
|
|
||||||
with open(pyproject_path, 'w') as f:
|
|
||||||
f.write(modified_content)
|
|
||||||
print("Modified pyproject.toml for nightly build")
|
|
||||||
|
|
||||||
# 2. Update __nightly_version__ in __version__.py
|
|
||||||
version_path = Path("crawl4ai/__version__.py")
|
|
||||||
if not version_path.exists():
|
|
||||||
print("Error: crawl4ai/__version__.py not found!")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
with open(version_path, 'r') as f:
|
|
||||||
version_content = f.read()
|
|
||||||
|
|
||||||
# Create backup
|
|
||||||
version_backup = version_path.with_suffix('.py.backup')
|
|
||||||
shutil.copy2(version_path, version_backup)
|
|
||||||
print(f"Created backup: {version_backup}")
|
|
||||||
|
|
||||||
# Update __nightly_version__
|
|
||||||
modified_version_content = version_content.replace(
|
|
||||||
'__nightly_version__ = None',
|
|
||||||
f'__nightly_version__ = "{nightly_version}"'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Write modified content
|
|
||||||
with open(version_path, 'w') as f:
|
|
||||||
f.write(modified_version_content)
|
|
||||||
print(f"Set nightly version: {nightly_version}")
|
|
||||||
|
|
||||||
return pyproject_backup, version_backup
|
|
||||||
|
|
||||||
def restore_files(pyproject_backup, version_backup):
|
|
||||||
"""Restore original files from backups."""
|
|
||||||
# Restore pyproject.toml
|
|
||||||
pyproject_path = Path("pyproject.toml")
|
|
||||||
shutil.move(pyproject_backup, pyproject_path)
|
|
||||||
print("Restored original pyproject.toml")
|
|
||||||
|
|
||||||
# Restore __version__.py
|
|
||||||
version_path = Path("crawl4ai/__version__.py")
|
|
||||||
shutil.move(version_backup, version_path)
|
|
||||||
print("Restored original __version__.py")
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main function to handle build process."""
|
|
||||||
# Set environment variable for nightly versioning
|
|
||||||
os.environ['CRAWL4AI_NIGHTLY'] = '1'
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Modify files for nightly
|
|
||||||
pyproject_backup, version_backup = modify_files_for_nightly()
|
|
||||||
|
|
||||||
print("\nReady for nightly build!")
|
|
||||||
print("Run your build command now (e.g., 'python -m build')")
|
|
||||||
print(f"\nTo restore original files, run:")
|
|
||||||
print(f" python build-nightly.py --restore")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
def restore_mode():
|
|
||||||
"""Restore original files from backups."""
|
|
||||||
pyproject_backup = Path("pyproject.toml.backup")
|
|
||||||
version_backup = Path("crawl4ai/__version__.py.backup")
|
|
||||||
|
|
||||||
if pyproject_backup.exists() and version_backup.exists():
|
|
||||||
restore_files(pyproject_backup, version_backup)
|
|
||||||
else:
|
|
||||||
if pyproject_backup.exists():
|
|
||||||
shutil.move(pyproject_backup, Path("pyproject.toml"))
|
|
||||||
print("Restored pyproject.toml")
|
|
||||||
if version_backup.exists():
|
|
||||||
shutil.move(version_backup, Path("crawl4ai/__version__.py"))
|
|
||||||
print("Restored __version__.py")
|
|
||||||
if not pyproject_backup.exists() and not version_backup.exists():
|
|
||||||
print("No backups found. Nothing to restore.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
if len(sys.argv) > 1 and sys.argv[1] == "--restore":
|
|
||||||
restore_mode()
|
|
||||||
else:
|
|
||||||
main()
|
|
||||||
792
release-agent.py
792
release-agent.py
@@ -1,792 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Crawl4AI Release Agent - Automated release management with LLM assistance
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import re
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import List, Dict, Optional, Literal, Tuple
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
import click
|
|
||||||
from rich.console import Console
|
|
||||||
from rich.prompt import Prompt, Confirm
|
|
||||||
from rich.table import Table
|
|
||||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
||||||
import litellm
|
|
||||||
|
|
||||||
console = Console()
|
|
||||||
|
|
||||||
# State machine states
|
|
||||||
States = Literal[
|
|
||||||
"init",
|
|
||||||
"commit_selection",
|
|
||||||
"version_bump",
|
|
||||||
"test_generation",
|
|
||||||
"test_execution",
|
|
||||||
"release_notes",
|
|
||||||
"demo_generation",
|
|
||||||
"docs_update",
|
|
||||||
"branch_creation",
|
|
||||||
"build_publish",
|
|
||||||
"complete"
|
|
||||||
]
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SharedContext:
|
|
||||||
"""Shared context that grows throughout the release process"""
|
|
||||||
selected_commits: List[Dict] = field(default_factory=list)
|
|
||||||
version: str = ""
|
|
||||||
old_version: str = ""
|
|
||||||
test_script: str = ""
|
|
||||||
test_results: Dict = field(default_factory=dict)
|
|
||||||
release_notes: str = ""
|
|
||||||
demo_script: str = ""
|
|
||||||
branch_name: str = ""
|
|
||||||
|
|
||||||
# Growing context
|
|
||||||
decisions: List[Dict] = field(default_factory=list)
|
|
||||||
files_changed: List[str] = field(default_factory=list)
|
|
||||||
api_changes: List[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
def add_decision(self, step: str, decision: str, reason: str = ""):
|
|
||||||
"""Track decisions made during the process"""
|
|
||||||
self.decisions.append({
|
|
||||||
"step": step,
|
|
||||||
"decision": decision,
|
|
||||||
"reason": reason,
|
|
||||||
"timestamp": datetime.now().isoformat()
|
|
||||||
})
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class JudgeResult:
|
|
||||||
"""Result from the judge LLM"""
|
|
||||||
status: Literal["good", "retry", "human"]
|
|
||||||
feedback: str
|
|
||||||
specific_issues: List[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
class LLMManager:
|
|
||||||
"""Manages stateless LLM calls with context engineering"""
|
|
||||||
|
|
||||||
def __init__(self, main_model: str = "claude-sonnet-4-20250514", judge_model: str = "claude-sonnet-4-20250514"):
|
|
||||||
self.main_model = os.getenv("MAIN_MODEL", main_model)
|
|
||||||
self.judge_model = os.getenv("JUDGE_MODEL", judge_model)
|
|
||||||
|
|
||||||
def call(self,
|
|
||||||
task: str,
|
|
||||||
context: Dict,
|
|
||||||
model: Optional[str] = None,
|
|
||||||
temperature: float = 0.7) -> str:
|
|
||||||
"""
|
|
||||||
Make a stateless LLM call with engineered context
|
|
||||||
"""
|
|
||||||
model = model or self.main_model
|
|
||||||
|
|
||||||
# Build system message with context engineering
|
|
||||||
system_message = self._build_system_message(context)
|
|
||||||
|
|
||||||
# Single user message with the task
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": system_message},
|
|
||||||
{"role": "user", "content": task}
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = litellm.completion(
|
|
||||||
model=model,
|
|
||||||
messages=messages,
|
|
||||||
temperature=temperature,
|
|
||||||
max_tokens=16000,
|
|
||||||
)
|
|
||||||
return response.choices[0].message.content
|
|
||||||
except Exception as e:
|
|
||||||
console.print(f"[red]LLM Error: {e}[/red]")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _extract_json(self, response: str) -> Dict:
|
|
||||||
"""Extract JSON from <JSON></JSON> tags"""
|
|
||||||
import re
|
|
||||||
json_match = re.search(r'<JSON>(.*?)</JSON>', response, re.DOTALL)
|
|
||||||
if json_match:
|
|
||||||
json_str = json_match.group(1).strip()
|
|
||||||
return json.loads(json_str)
|
|
||||||
raise ValueError("No JSON found in response")
|
|
||||||
|
|
||||||
def get_relevant_files(self, query: str, num_files: int = 10) -> List[Dict[str, str]]:
|
|
||||||
"""Use LLM to select relevant files from codebase for context"""
|
|
||||||
|
|
||||||
# Get directory structure
|
|
||||||
crawl4ai_files = []
|
|
||||||
docs_files = []
|
|
||||||
examples_files = []
|
|
||||||
|
|
||||||
# Scan crawl4ai directory
|
|
||||||
for root, dirs, files in os.walk("crawl4ai"):
|
|
||||||
# Skip __pycache__ and other unwanted directories
|
|
||||||
dirs[:] = [d for d in dirs if not d.startswith('__') and d != '.git']
|
|
||||||
for file in files:
|
|
||||||
if file.endswith('.py') and not file.startswith('__'):
|
|
||||||
rel_path = os.path.relpath(os.path.join(root, file))
|
|
||||||
crawl4ai_files.append(rel_path)
|
|
||||||
|
|
||||||
# Scan docs directory
|
|
||||||
if os.path.exists("docs"):
|
|
||||||
for root, dirs, files in os.walk("docs"):
|
|
||||||
dirs[:] = [d for d in dirs if not d.startswith('.')]
|
|
||||||
for file in files:
|
|
||||||
if file.endswith(('.md', '.rst')):
|
|
||||||
rel_path = os.path.relpath(os.path.join(root, file))
|
|
||||||
docs_files.append(rel_path)
|
|
||||||
|
|
||||||
# Scan examples directory
|
|
||||||
if os.path.exists("examples"):
|
|
||||||
for root, dirs, files in os.walk("examples"):
|
|
||||||
for file in files:
|
|
||||||
if file.endswith('.py'):
|
|
||||||
rel_path = os.path.relpath(os.path.join(root, file))
|
|
||||||
examples_files.append(rel_path)
|
|
||||||
|
|
||||||
# Build file selection prompt
|
|
||||||
file_selection_prompt = f"""Select the most relevant files to understand Crawl4AI for the following task:
|
|
||||||
|
|
||||||
<TASK>
|
|
||||||
{query}
|
|
||||||
</TASK>
|
|
||||||
|
|
||||||
<AVAILABLE_FILES>
|
|
||||||
## Core Library Files:
|
|
||||||
{chr(10).join(crawl4ai_files[:50])} # Limit to prevent context overflow
|
|
||||||
|
|
||||||
## Documentation Files:
|
|
||||||
{chr(10).join(docs_files[:30])}
|
|
||||||
|
|
||||||
## Example Files:
|
|
||||||
{chr(10).join(examples_files[:20])}
|
|
||||||
</AVAILABLE_FILES>
|
|
||||||
|
|
||||||
Select exactly {num_files} files that would be most helpful for understanding Crawl4AI in the context of the given task.
|
|
||||||
Prioritize:
|
|
||||||
1. Core API classes and interfaces
|
|
||||||
2. Relevant examples
|
|
||||||
3. Documentation explaining key concepts
|
|
||||||
4. Files related to the specific task
|
|
||||||
|
|
||||||
IMPORTANT: Return ONLY a JSON response wrapped in <JSON></JSON> tags.
|
|
||||||
<JSON>
|
|
||||||
{{
|
|
||||||
"selected_files": [
|
|
||||||
"crawl4ai/core_api.py",
|
|
||||||
"docs/getting_started.md",
|
|
||||||
"examples/basic_usage.py"
|
|
||||||
],
|
|
||||||
"reasoning": "Brief explanation of why these files were selected"
|
|
||||||
}}
|
|
||||||
</JSON>"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = self.call(file_selection_prompt, {}, temperature=0.3)
|
|
||||||
result = self._extract_json(response)
|
|
||||||
selected_files = result.get("selected_files", [])
|
|
||||||
|
|
||||||
# Read the selected files
|
|
||||||
file_contents = []
|
|
||||||
for file_path in selected_files:
|
|
||||||
if os.path.exists(file_path):
|
|
||||||
try:
|
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
|
||||||
content = f.read()
|
|
||||||
# Limit file size to prevent context overflow
|
|
||||||
if len(content) > 10000:
|
|
||||||
content = content[:10000] + "\n... (truncated)"
|
|
||||||
file_contents.append({
|
|
||||||
"path": file_path,
|
|
||||||
"content": content
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
console.print(f"[yellow]Warning: Could not read {file_path}: {e}[/yellow]")
|
|
||||||
|
|
||||||
return file_contents
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
console.print(f"[yellow]Warning: Could not select relevant files: {e}[/yellow]")
|
|
||||||
# Fallback: return some default important files
|
|
||||||
default_files = [
|
|
||||||
"crawl4ai/__init__.py",
|
|
||||||
"crawl4ai/async_crawler.py",
|
|
||||||
"README.md"
|
|
||||||
]
|
|
||||||
file_contents = []
|
|
||||||
for file_path in default_files:
|
|
||||||
if os.path.exists(file_path):
|
|
||||||
try:
|
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
|
||||||
content = f.read()[:10000]
|
|
||||||
file_contents.append({
|
|
||||||
"path": file_path,
|
|
||||||
"content": content
|
|
||||||
})
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
return file_contents
|
|
||||||
|
|
||||||
def _build_system_message(self, context: Dict) -> str:
|
|
||||||
"""Build engineered system message with all context"""
|
|
||||||
sections = []
|
|
||||||
|
|
||||||
# Role and objective
|
|
||||||
sections.append("""You are a Release Engineering Assistant for Crawl4AI.
|
|
||||||
Your role is to help create high-quality releases with proper testing, documentation, and validation.
|
|
||||||
You work step-by-step, focusing on the current task while aware of the overall release context.""")
|
|
||||||
|
|
||||||
# Add context sections with unique delimiters
|
|
||||||
if "codebase_info" in context:
|
|
||||||
sections.append(f"""
|
|
||||||
<<CODEBASE_INFORMATION_START>>
|
|
||||||
{context['codebase_info']}
|
|
||||||
<<CODEBASE_INFORMATION_END>>""")
|
|
||||||
|
|
||||||
if "commit_diffs" in context:
|
|
||||||
sections.append(f"""
|
|
||||||
<<COMMIT_CHANGES_START>>
|
|
||||||
{context['commit_diffs']}
|
|
||||||
<<COMMIT_CHANGES_END>>""")
|
|
||||||
|
|
||||||
if "previous_decisions" in context:
|
|
||||||
sections.append(f"""
|
|
||||||
<<PREVIOUS_DECISIONS_START>>
|
|
||||||
{context['previous_decisions']}
|
|
||||||
<<PREVIOUS_DECISIONS_END>>""")
|
|
||||||
|
|
||||||
if "existing_patterns" in context:
|
|
||||||
sections.append(f"""
|
|
||||||
<<EXISTING_PATTERNS_START>>
|
|
||||||
{context['existing_patterns']}
|
|
||||||
<<EXISTING_PATTERNS_END>>""")
|
|
||||||
|
|
||||||
if "constraints" in context:
|
|
||||||
sections.append(f"""
|
|
||||||
<<TASK_CONSTRAINTS_START>>
|
|
||||||
{context['constraints']}
|
|
||||||
<<TASK_CONSTRAINTS_END>>""")
|
|
||||||
|
|
||||||
if "judge_feedback" in context:
|
|
||||||
sections.append(f"""
|
|
||||||
<<JUDGE_FEEDBACK_START>>
|
|
||||||
{context['judge_feedback']}
|
|
||||||
<<JUDGE_FEEDBACK_END>>""")
|
|
||||||
|
|
||||||
return "\n".join(sections)
|
|
||||||
|
|
||||||
def judge(self,
|
|
||||||
step_output: str,
|
|
||||||
expected_criteria: List[str],
|
|
||||||
context: Dict) -> JudgeResult:
|
|
||||||
"""Judge the quality of a step's output"""
|
|
||||||
|
|
||||||
judge_task = f"""Evaluate the following output against the criteria:
|
|
||||||
|
|
||||||
<OUTPUT_TO_JUDGE>
|
|
||||||
{step_output}
|
|
||||||
</OUTPUT_TO_JUDGE>
|
|
||||||
|
|
||||||
<SUCCESS_CRITERIA>
|
|
||||||
{chr(10).join(f"- {c}" for c in expected_criteria)}
|
|
||||||
</SUCCESS_CRITERIA>
|
|
||||||
|
|
||||||
## Evaluation Required
|
|
||||||
1. Does the output meet ALL criteria?
|
|
||||||
2. Are there any issues or improvements needed?
|
|
||||||
3. Is human intervention required?
|
|
||||||
|
|
||||||
IMPORTANT: Return ONLY a JSON response wrapped in <JSON></JSON> tags.
|
|
||||||
Do NOT include any markdown code blocks, backticks, or explanatory text.
|
|
||||||
The JSON will be directly parsed, so any extra formatting will cause errors.
|
|
||||||
|
|
||||||
Return your evaluation as:
|
|
||||||
<JSON>
|
|
||||||
{{
|
|
||||||
"status": "good" | "retry" | "human",
|
|
||||||
"feedback": "Clear explanation of the evaluation",
|
|
||||||
"specific_issues": ["specific issue 1", "specific issue 2"]
|
|
||||||
}}
|
|
||||||
</JSON>"""
|
|
||||||
|
|
||||||
response = self.call(judge_task, context, model=self.judge_model, temperature=0.3)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Extract JSON between tags
|
|
||||||
import re
|
|
||||||
json_match = re.search(r'<JSON>(.*?)</JSON>', response, re.DOTALL)
|
|
||||||
if json_match:
|
|
||||||
json_str = json_match.group(1).strip()
|
|
||||||
result = json.loads(json_str)
|
|
||||||
return JudgeResult(**result)
|
|
||||||
else:
|
|
||||||
raise ValueError("No JSON found in response")
|
|
||||||
except Exception as e:
|
|
||||||
# Fallback if JSON parsing fails
|
|
||||||
console.print(f"[yellow]Judge parsing error: {e}[/yellow]")
|
|
||||||
return JudgeResult(
|
|
||||||
status="retry",
|
|
||||||
feedback="Failed to parse judge response",
|
|
||||||
specific_issues=["Invalid judge response format"]
|
|
||||||
)
|
|
||||||
|
|
||||||
class GitOperations:
|
|
||||||
"""Handle git operations"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_commits_between_branches(base: str = "main", head: str = "next") -> List[Dict]:
|
|
||||||
"""Get commits in head that aren't in base"""
|
|
||||||
cmd = ["git", "log", f"{base}..{head}", "--pretty=format:%H|%an|%ae|%at|%s", "--reverse"]
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
||||||
|
|
||||||
commits = []
|
|
||||||
for line in result.stdout.strip().split('\n'):
|
|
||||||
if line:
|
|
||||||
hash, author, email, timestamp, subject = line.split('|', 4)
|
|
||||||
commits.append({
|
|
||||||
"hash": hash,
|
|
||||||
"author": author,
|
|
||||||
"email": email,
|
|
||||||
"date": datetime.fromtimestamp(int(timestamp)).isoformat(),
|
|
||||||
"subject": subject,
|
|
||||||
"selected": False
|
|
||||||
})
|
|
||||||
return commits
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_commit_diff(commit_hash: str) -> str:
|
|
||||||
"""Get the diff for a specific commit"""
|
|
||||||
cmd = ["git", "show", commit_hash, "--pretty=format:", "--unified=3"]
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
||||||
return result.stdout
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def cherry_pick_commits(commits: List[str], branch: str) -> bool:
|
|
||||||
"""Cherry pick commits to a branch"""
|
|
||||||
# Create and checkout branch
|
|
||||||
subprocess.run(["git", "checkout", "-b", branch], check=True)
|
|
||||||
|
|
||||||
# Cherry pick each commit
|
|
||||||
for commit in commits:
|
|
||||||
result = subprocess.run(["git", "cherry-pick", commit])
|
|
||||||
if result.returncode != 0:
|
|
||||||
console.print(f"[red]Failed to cherry-pick {commit}[/red]")
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
class ReleaseAgent:
|
|
||||||
"""Main release agent orchestrating the entire process"""
|
|
||||||
|
|
||||||
def __init__(self, auto_mode: bool = False, select_all: bool = False, test_mode: bool = False):
|
|
||||||
self.state: States = "init"
|
|
||||||
self.context = SharedContext()
|
|
||||||
self.llm = LLMManager()
|
|
||||||
self.auto_mode = auto_mode
|
|
||||||
self.select_all = select_all
|
|
||||||
self.test_mode = test_mode
|
|
||||||
|
|
||||||
# Load current version
|
|
||||||
self._load_current_version()
|
|
||||||
|
|
||||||
def _load_current_version(self):
|
|
||||||
"""Load current version from __version__.py"""
|
|
||||||
version_file = Path("crawl4ai/__version__.py")
|
|
||||||
if version_file.exists():
|
|
||||||
content = version_file.read_text()
|
|
||||||
for line in content.split('\n'):
|
|
||||||
if '__version__' in line and '=' in line:
|
|
||||||
self.context.old_version = line.split('=')[1].strip().strip('"')
|
|
||||||
break
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
"""Run the release process"""
|
|
||||||
console.print("[bold cyan]🚀 Crawl4AI Release Agent[/bold cyan]\n")
|
|
||||||
|
|
||||||
# State machine
|
|
||||||
while self.state != "complete":
|
|
||||||
try:
|
|
||||||
if self.state == "init":
|
|
||||||
self.state = "commit_selection"
|
|
||||||
elif self.state == "commit_selection":
|
|
||||||
self._select_commits()
|
|
||||||
self.state = "version_bump"
|
|
||||||
elif self.state == "version_bump":
|
|
||||||
self._bump_version()
|
|
||||||
self.state = "test_generation"
|
|
||||||
elif self.state == "test_generation":
|
|
||||||
self._generate_tests()
|
|
||||||
self.state = "test_execution"
|
|
||||||
elif self.state == "test_execution":
|
|
||||||
if self._run_tests():
|
|
||||||
self.state = "branch_creation"
|
|
||||||
else:
|
|
||||||
console.print("[red]Tests failed! Fix issues and try again.[/red]")
|
|
||||||
break
|
|
||||||
elif self.state == "branch_creation":
|
|
||||||
self._create_version_branch()
|
|
||||||
self.state = "release_notes"
|
|
||||||
elif self.state == "release_notes":
|
|
||||||
self._generate_release_notes()
|
|
||||||
self.state = "demo_generation"
|
|
||||||
elif self.state == "demo_generation":
|
|
||||||
self._generate_demo()
|
|
||||||
self.state = "docs_update"
|
|
||||||
elif self.state == "docs_update":
|
|
||||||
self._update_docs()
|
|
||||||
self.state = "build_publish"
|
|
||||||
elif self.state == "build_publish":
|
|
||||||
if self._build_and_publish():
|
|
||||||
self.state = "complete"
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
console.print("\n[yellow]Release process interrupted by user[/yellow]")
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
console.print(f"[red]Error in state {self.state}: {e}[/red]")
|
|
||||||
break
|
|
||||||
|
|
||||||
if self.state == "complete":
|
|
||||||
console.print("\n[green]✅ Release completed successfully![/green]")
|
|
||||||
|
|
||||||
def _select_commits(self):
|
|
||||||
"""Select commits to include in release"""
|
|
||||||
console.print("[bold]Step 1: Select Commits[/bold]")
|
|
||||||
|
|
||||||
commits = GitOperations.get_commits_between_branches()
|
|
||||||
|
|
||||||
if self.select_all:
|
|
||||||
# Auto-select all commits
|
|
||||||
for commit in commits:
|
|
||||||
commit["selected"] = True
|
|
||||||
self.context.selected_commits = commits
|
|
||||||
console.print(f"[green]Auto-selected all {len(commits)} commits[/green]")
|
|
||||||
else:
|
|
||||||
# Interactive selection
|
|
||||||
table = Table(title="Commits in 'next' not in 'main'")
|
|
||||||
table.add_column("", style="cyan", width=3)
|
|
||||||
table.add_column("Hash", style="yellow")
|
|
||||||
table.add_column("Author", style="green")
|
|
||||||
table.add_column("Date", style="blue")
|
|
||||||
table.add_column("Subject", style="white")
|
|
||||||
|
|
||||||
for i, commit in enumerate(commits):
|
|
||||||
table.add_row(
|
|
||||||
str(i),
|
|
||||||
commit["hash"][:8],
|
|
||||||
commit["author"],
|
|
||||||
commit["date"][:10],
|
|
||||||
commit["subject"]
|
|
||||||
)
|
|
||||||
|
|
||||||
console.print(table)
|
|
||||||
|
|
||||||
# Get selections
|
|
||||||
selections = Prompt.ask(
|
|
||||||
"Select commits (e.g., 0,2,3-5 or 'all')",
|
|
||||||
default="all"
|
|
||||||
)
|
|
||||||
|
|
||||||
if selections.lower() == "all":
|
|
||||||
for commit in commits:
|
|
||||||
commit["selected"] = True
|
|
||||||
else:
|
|
||||||
# Parse selection
|
|
||||||
for part in selections.split(','):
|
|
||||||
if '-' in part:
|
|
||||||
start, end = map(int, part.split('-'))
|
|
||||||
for i in range(start, end + 1):
|
|
||||||
if 0 <= i < len(commits):
|
|
||||||
commits[i]["selected"] = True
|
|
||||||
else:
|
|
||||||
i = int(part.strip())
|
|
||||||
if 0 <= i < len(commits):
|
|
||||||
commits[i]["selected"] = True
|
|
||||||
|
|
||||||
self.context.selected_commits = [c for c in commits if c["selected"]]
|
|
||||||
|
|
||||||
# Collect diffs for selected commits
|
|
||||||
for commit in self.context.selected_commits:
|
|
||||||
diff = GitOperations.get_commit_diff(commit["hash"])
|
|
||||||
# Store simplified diff info
|
|
||||||
self.context.files_changed.extend(self._extract_changed_files(diff))
|
|
||||||
|
|
||||||
console.print(f"[green]Selected {len(self.context.selected_commits)} commits[/green]")
|
|
||||||
|
|
||||||
def _bump_version(self):
|
|
||||||
"""Determine and confirm version bump"""
|
|
||||||
console.print("\n[bold]Step 2: Version Bump[/bold]")
|
|
||||||
|
|
||||||
# Analyze commits to suggest version bump
|
|
||||||
commit_types = {"feat": 0, "fix": 0, "breaking": 0}
|
|
||||||
for commit in self.context.selected_commits:
|
|
||||||
subject = commit["subject"].lower()
|
|
||||||
if "breaking" in subject or "!" in subject:
|
|
||||||
commit_types["breaking"] += 1
|
|
||||||
elif subject.startswith("feat"):
|
|
||||||
commit_types["feat"] += 1
|
|
||||||
elif subject.startswith("fix"):
|
|
||||||
commit_types["fix"] += 1
|
|
||||||
|
|
||||||
# Suggest version
|
|
||||||
current_parts = self.context.old_version.split('.')
|
|
||||||
major, minor, patch = map(int, current_parts)
|
|
||||||
|
|
||||||
if commit_types["breaking"] > 0:
|
|
||||||
suggested = f"{major + 1}.0.0"
|
|
||||||
elif commit_types["feat"] > 0:
|
|
||||||
suggested = f"{major}.{minor + 1}.0"
|
|
||||||
else:
|
|
||||||
suggested = f"{major}.{minor}.{patch + 1}"
|
|
||||||
|
|
||||||
if self.auto_mode:
|
|
||||||
self.context.version = suggested
|
|
||||||
else:
|
|
||||||
self.context.version = Prompt.ask(
|
|
||||||
f"New version (current: {self.context.old_version})",
|
|
||||||
default=suggested
|
|
||||||
)
|
|
||||||
|
|
||||||
console.print(f"[green]Version: {self.context.old_version} → {self.context.version}[/green]")
|
|
||||||
self.context.branch_name = f"v{self.context.version}"
|
|
||||||
|
|
||||||
def _generate_tests(self):
|
|
||||||
"""Generate test script using LLM"""
|
|
||||||
console.print("\n[bold]Step 3: Generate Tests[/bold]")
|
|
||||||
|
|
||||||
with Progress(
|
|
||||||
SpinnerColumn(),
|
|
||||||
TextColumn("[progress.description]{task.description}"),
|
|
||||||
console=console
|
|
||||||
) as progress:
|
|
||||||
task = progress.add_task("Generating test script...", total=None)
|
|
||||||
|
|
||||||
# Get relevant files for understanding Crawl4AI
|
|
||||||
query = f"Generate tests for Crawl4AI with these changes: {self._format_commits_for_llm()}"
|
|
||||||
relevant_files = self.llm.get_relevant_files(query, num_files=8)
|
|
||||||
|
|
||||||
# Format file contents for context
|
|
||||||
codebase_context = []
|
|
||||||
for file_info in relevant_files:
|
|
||||||
codebase_context.append(f"<FILE path=\"{file_info['path']}\">\n{file_info['content']}\n</FILE>")
|
|
||||||
|
|
||||||
# Build context for test generation
|
|
||||||
context = {
|
|
||||||
"commit_diffs": self._get_selected_diffs_summary(),
|
|
||||||
"existing_patterns": self._load_test_patterns(),
|
|
||||||
"constraints": "Generate comprehensive tests for all changed functionality",
|
|
||||||
"codebase_info": "\n\n".join(codebase_context)
|
|
||||||
}
|
|
||||||
|
|
||||||
task_prompt = f"""Generate a Python test script for the following changes in Crawl4AI v{self.context.version}:
|
|
||||||
|
|
||||||
<SELECTED_COMMITS>
|
|
||||||
{self._format_commits_for_llm()}
|
|
||||||
</SELECTED_COMMITS>
|
|
||||||
|
|
||||||
<REQUIREMENTS>
|
|
||||||
0. No mock data or uni test style, simple use them like a user will use.
|
|
||||||
1. Test all new features and changes
|
|
||||||
2. Be runnable with pytest
|
|
||||||
3. Return exit code 0 on success, non-zero on failure
|
|
||||||
4. Dont make it too ling, these are all already tested, this is final test after cherry-pick
|
|
||||||
</REQUIREMENTS>
|
|
||||||
|
|
||||||
IMPORTANT: Return ONLY a JSON response wrapped in <JSON></JSON> tags.
|
|
||||||
Do NOT include any markdown code blocks, backticks, or explanatory text.
|
|
||||||
The JSON will be directly parsed, so any extra formatting will cause errors.
|
|
||||||
|
|
||||||
Return the test script as:
|
|
||||||
<JSON>
|
|
||||||
{{
|
|
||||||
"test_script": "# Complete Python test script here\\nfrom crawl4ai..."
|
|
||||||
}}
|
|
||||||
</JSON>"""
|
|
||||||
|
|
||||||
response = self.llm.call(task_prompt, context)
|
|
||||||
|
|
||||||
# Extract test script from JSON response
|
|
||||||
start_index = response.find("<JSON>")
|
|
||||||
end_index = response.find("</JSON>", start_index)
|
|
||||||
if start_index != -1 and end_index != -1:
|
|
||||||
json_str = response[start_index + len("<JSON>"):end_index].strip()
|
|
||||||
result = json.loads(json_str)
|
|
||||||
self.context.test_script = result.get("test_script", "")
|
|
||||||
else:
|
|
||||||
console.print("[red]Failed to extract test script from response[/red]")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Judge the generated tests
|
|
||||||
judge_result = self.llm.judge(
|
|
||||||
self.context.test_script,
|
|
||||||
[
|
|
||||||
"Tests cover all selected commits",
|
|
||||||
"Tests are comprehensive and meaningful",
|
|
||||||
"Test script is valid Python code",
|
|
||||||
"Tests check both success and failure cases"
|
|
||||||
],
|
|
||||||
context
|
|
||||||
)
|
|
||||||
|
|
||||||
if judge_result.status == "retry":
|
|
||||||
console.print(f"[yellow]Regenerating tests: {judge_result.feedback}[/yellow]")
|
|
||||||
# Add feedback to context and retry
|
|
||||||
context["judge_feedback"] = judge_result.feedback
|
|
||||||
response = self.llm.call(task_prompt, context)
|
|
||||||
# Extract again
|
|
||||||
json_match = re.search(r'<JSON>(.*?)</JSON>', response, re.DOTALL)
|
|
||||||
if json_match:
|
|
||||||
json_str = json_match.group(1).strip()
|
|
||||||
result = json.loads(json_str)
|
|
||||||
self.context.test_script = result.get("test_script", "")
|
|
||||||
elif judge_result.status == "human":
|
|
||||||
console.print(f"[yellow]Human intervention needed: {judge_result.feedback}[/yellow]")
|
|
||||||
# TODO: Implement human feedback loop
|
|
||||||
|
|
||||||
progress.update(task, completed=True)
|
|
||||||
|
|
||||||
# Save test script
|
|
||||||
test_file = Path(f"test_release_{self.context.version}.py")
|
|
||||||
test_file.write_text(self.context.test_script)
|
|
||||||
console.print(f"[green]Test script saved to {test_file}[/green]")
|
|
||||||
|
|
||||||
def _run_tests(self) -> bool:
|
|
||||||
"""Run the generated tests"""
|
|
||||||
console.print("\n[bold]Step 4: Run Tests[/bold]")
|
|
||||||
|
|
||||||
test_file = f"test_release_{self.context.version}.py"
|
|
||||||
|
|
||||||
with Progress(
|
|
||||||
SpinnerColumn(),
|
|
||||||
TextColumn("[progress.description]{task.description}"),
|
|
||||||
console=console
|
|
||||||
) as progress:
|
|
||||||
task = progress.add_task("Running tests...", total=None)
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
["python", test_file],
|
|
||||||
capture_output=True,
|
|
||||||
text=True
|
|
||||||
)
|
|
||||||
|
|
||||||
progress.update(task, completed=True)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
console.print("[green]✅ All tests passed![/green]")
|
|
||||||
self.context.test_results = {"status": "passed", "output": result.stdout}
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
console.print("[red]❌ Tests failed![/red]")
|
|
||||||
console.print(result.stdout)
|
|
||||||
console.print(result.stderr)
|
|
||||||
self.context.test_results = {
|
|
||||||
"status": "failed",
|
|
||||||
"output": result.stdout,
|
|
||||||
"error": result.stderr
|
|
||||||
}
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _create_version_branch(self):
|
|
||||||
"""Create version branch and cherry-pick commits"""
|
|
||||||
console.print(f"\n[bold]Step 5: Create Branch {self.context.branch_name}[/bold]")
|
|
||||||
|
|
||||||
# Checkout main first
|
|
||||||
subprocess.run(["git", "checkout", "main"], check=True)
|
|
||||||
|
|
||||||
# Create version branch
|
|
||||||
commit_hashes = [c["hash"] for c in self.context.selected_commits]
|
|
||||||
|
|
||||||
if GitOperations.cherry_pick_commits(commit_hashes, self.context.branch_name):
|
|
||||||
console.print(f"[green]Created branch {self.context.branch_name} with {len(commit_hashes)} commits[/green]")
|
|
||||||
else:
|
|
||||||
raise Exception("Failed to create version branch")
|
|
||||||
|
|
||||||
def _generate_release_notes(self):
|
|
||||||
"""Generate release notes"""
|
|
||||||
console.print("\n[bold]Step 6: Generate Release Notes[/bold]")
|
|
||||||
|
|
||||||
# Implementation continues...
|
|
||||||
# (Keeping it minimal as requested)
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _generate_demo(self):
|
|
||||||
"""Generate demo script"""
|
|
||||||
console.print("\n[bold]Step 7: Generate Demo[/bold]")
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _update_docs(self):
|
|
||||||
"""Update documentation"""
|
|
||||||
console.print("\n[bold]Step 8: Update Documentation[/bold]")
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _build_and_publish(self):
|
|
||||||
"""Build and publish to PyPI"""
|
|
||||||
console.print("\n[bold]Step 9: Build and Publish[/bold]")
|
|
||||||
|
|
||||||
if not self.auto_mode:
|
|
||||||
if not Confirm.ask("Ready to publish to PyPI?"):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Run publish.sh
|
|
||||||
result = subprocess.run(["./publish.sh"], capture_output=True)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
console.print(f"[green]✅ Published v{self.context.version} to PyPI![/green]")
|
|
||||||
|
|
||||||
# Merge to main
|
|
||||||
subprocess.run(["git", "checkout", "main"], check=True)
|
|
||||||
subprocess.run(["git", "merge", "--squash", self.context.branch_name], check=True)
|
|
||||||
subprocess.run(["git", "commit", "-m", f"Release v{self.context.version}"], check=True)
|
|
||||||
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
console.print("[red]Publishing failed![/red]")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Helper methods
|
|
||||||
def _extract_changed_files(self, diff: str) -> List[str]:
|
|
||||||
"""Extract changed file paths from diff"""
|
|
||||||
files = []
|
|
||||||
for line in diff.split('\n'):
|
|
||||||
if line.startswith('+++') or line.startswith('---'):
|
|
||||||
file = line[4:].split('\t')[0]
|
|
||||||
if file != '/dev/null' and file not in files:
|
|
||||||
files.append(file)
|
|
||||||
return files
|
|
||||||
|
|
||||||
def _get_selected_diffs_summary(self) -> str:
|
|
||||||
"""Get summary of diffs for selected commits"""
|
|
||||||
# Simplified for brevity
|
|
||||||
return f"{len(self.context.selected_commits)} commits selected"
|
|
||||||
|
|
||||||
def _load_test_patterns(self) -> str:
|
|
||||||
"""Load existing test patterns"""
|
|
||||||
# Would load from existing test files
|
|
||||||
return "Follow pytest patterns"
|
|
||||||
|
|
||||||
def _format_commits_for_llm(self) -> str:
|
|
||||||
"""Format commits for LLM consumption"""
|
|
||||||
lines = []
|
|
||||||
for commit in self.context.selected_commits:
|
|
||||||
lines.append(f"- {commit['hash'][:8]}: {commit['subject']}")
|
|
||||||
return '\n'.join(lines)
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.option('--all', is_flag=True, help='Select all commits automatically')
|
|
||||||
@click.option('-y', '--yes', is_flag=True, help='Auto-confirm version bump')
|
|
||||||
@click.option('--dry-run', is_flag=True, help='Run without publishing')
|
|
||||||
@click.option('--test', is_flag=True, help='Test mode - no git operations, no publishing')
|
|
||||||
def main(all, yes, dry_run, test):
|
|
||||||
"""Crawl4AI Release Agent - Automated release management"""
|
|
||||||
agent = ReleaseAgent(auto_mode=yes, select_all=all, test_mode=test)
|
|
||||||
agent.run()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
Reference in New Issue
Block a user