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:
UncleCode
2025-07-04 15:02:25 +08:00
parent 048d9b0f5b
commit 74705c1f67
3 changed files with 3 additions and 929 deletions

3
.gitignore vendored
View File

@@ -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]

View File

@@ -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()

View File

@@ -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()