forked from mengyxu/noob-components
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
329 lines
10 KiB
329 lines
10 KiB
#!/usr/bin/env python3 |
|
""" |
|
Multi-Agent Pipeline: Create PR. |
|
|
|
Usage: |
|
python3 create_pr.py [task-dir] [--dry-run] |
|
|
|
This script: |
|
1. Stages and commits all changes (excluding workspace/) |
|
2. Pushes to origin |
|
3. Creates a Draft PR using `gh pr create` |
|
4. Updates task.json with status="completed", pr_url, and current_phase |
|
|
|
Note: This is the only action that performs git commit, as it's the final |
|
step after all implementation and checks are complete. |
|
""" |
|
|
|
from __future__ import annotations |
|
|
|
import argparse |
|
import json |
|
import subprocess |
|
import sys |
|
from pathlib import Path |
|
|
|
# Add parent directory to path for imports |
|
sys.path.insert(0, str(Path(__file__).parent.parent)) |
|
|
|
from common.git_context import _run_git_command |
|
from common.paths import ( |
|
DIR_WORKFLOW, |
|
FILE_TASK_JSON, |
|
get_current_task, |
|
get_repo_root, |
|
) |
|
from common.phase import get_phase_for_action |
|
|
|
# ============================================================================= |
|
# Colors |
|
# ============================================================================= |
|
|
|
|
|
class Colors: |
|
RED = "\033[0;31m" |
|
GREEN = "\033[0;32m" |
|
YELLOW = "\033[1;33m" |
|
BLUE = "\033[0;34m" |
|
NC = "\033[0m" |
|
|
|
|
|
# ============================================================================= |
|
# Helper Functions |
|
# ============================================================================= |
|
|
|
|
|
def _read_json_file(path: Path) -> dict | None: |
|
"""Read and parse a JSON file.""" |
|
try: |
|
return json.loads(path.read_text(encoding="utf-8")) |
|
except (FileNotFoundError, json.JSONDecodeError, OSError): |
|
return None |
|
|
|
|
|
def _write_json_file(path: Path, data: dict) -> bool: |
|
"""Write dict to JSON file.""" |
|
try: |
|
path.write_text( |
|
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" |
|
) |
|
return True |
|
except (OSError, IOError): |
|
return False |
|
|
|
|
|
# ============================================================================= |
|
# Main |
|
# ============================================================================= |
|
|
|
|
|
def main() -> int: |
|
"""Main entry point.""" |
|
parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Create PR") |
|
parser.add_argument("dir", nargs="?", help="Task directory") |
|
parser.add_argument( |
|
"--dry-run", action="store_true", help="Show what would be done" |
|
) |
|
|
|
args = parser.parse_args() |
|
repo_root = get_repo_root() |
|
|
|
# ============================================================================= |
|
# Get Task Directory |
|
# ============================================================================= |
|
target_dir = args.dir |
|
if not target_dir: |
|
# Try to get from .current-task |
|
current_task = get_current_task(repo_root) |
|
if current_task: |
|
target_dir = current_task |
|
|
|
if not target_dir: |
|
print( |
|
f"{Colors.RED}Error: No task directory specified and no current task set{Colors.NC}" |
|
) |
|
print("Usage: python3 create_pr.py [task-dir] [--dry-run]") |
|
return 1 |
|
|
|
# Support relative paths |
|
if not target_dir.startswith("/"): |
|
target_dir_path = repo_root / target_dir |
|
else: |
|
target_dir_path = Path(target_dir) |
|
|
|
task_json = target_dir_path / FILE_TASK_JSON |
|
if not task_json.is_file(): |
|
print(f"{Colors.RED}Error: task.json not found at {target_dir_path}{Colors.NC}") |
|
return 1 |
|
|
|
# ============================================================================= |
|
# Main |
|
# ============================================================================= |
|
print(f"{Colors.BLUE}=== Create PR ==={Colors.NC}") |
|
if args.dry_run: |
|
print( |
|
f"{Colors.YELLOW}[DRY-RUN MODE] No actual changes will be made{Colors.NC}" |
|
) |
|
print() |
|
|
|
# Read task config |
|
task_data = _read_json_file(task_json) |
|
if not task_data: |
|
print(f"{Colors.RED}Error: Failed to read task.json{Colors.NC}") |
|
return 1 |
|
|
|
task_name = task_data.get("name", "") |
|
base_branch = task_data.get("base_branch", "main") |
|
scope = task_data.get("scope", "core") |
|
dev_type = task_data.get("dev_type", "feature") |
|
|
|
# Map dev_type to commit prefix |
|
prefix_map = { |
|
"feature": "feat", |
|
"frontend": "feat", |
|
"backend": "feat", |
|
"fullstack": "feat", |
|
"bugfix": "fix", |
|
"fix": "fix", |
|
"refactor": "refactor", |
|
"docs": "docs", |
|
"test": "test", |
|
} |
|
commit_prefix = prefix_map.get(dev_type, "feat") |
|
|
|
print(f"Task: {task_name}") |
|
print(f"Base branch: {base_branch}") |
|
print(f"Scope: {scope}") |
|
print(f"Commit prefix: {commit_prefix}") |
|
print() |
|
|
|
# Get current branch |
|
_, branch_out, _ = _run_git_command(["branch", "--show-current"]) |
|
current_branch = branch_out.strip() |
|
print(f"Current branch: {current_branch}") |
|
|
|
# Check for changes |
|
print(f"{Colors.YELLOW}Checking for changes...{Colors.NC}") |
|
|
|
# Stage changes |
|
_run_git_command(["add", "-A"]) |
|
|
|
# Exclude workspace and temp files |
|
_run_git_command(["reset", f"{DIR_WORKFLOW}/workspace/"]) |
|
_run_git_command(["reset", ".agent-log", ".session-id"]) |
|
|
|
# Check if there are staged changes |
|
ret, _, _ = _run_git_command(["diff", "--cached", "--quiet"]) |
|
has_staged_changes = ret != 0 |
|
|
|
if not has_staged_changes: |
|
print(f"{Colors.YELLOW}No staged changes to commit{Colors.NC}") |
|
|
|
# Check for unpushed commits |
|
ret, log_out, _ = _run_git_command( |
|
["log", f"origin/{current_branch}..HEAD", "--oneline"] |
|
) |
|
unpushed = len([line for line in log_out.splitlines() if line.strip()]) |
|
|
|
if unpushed == 0: |
|
if args.dry_run: |
|
_run_git_command(["reset", "HEAD"]) |
|
print(f"{Colors.RED}No changes to create PR{Colors.NC}") |
|
return 1 |
|
|
|
print(f"Found {unpushed} unpushed commit(s)") |
|
else: |
|
# Commit changes |
|
print(f"{Colors.YELLOW}Committing changes...{Colors.NC}") |
|
commit_msg = f"{commit_prefix}({scope}): {task_name}" |
|
|
|
if args.dry_run: |
|
print(f"[DRY-RUN] Would commit with message: {commit_msg}") |
|
print("[DRY-RUN] Staged files:") |
|
_, staged_out, _ = _run_git_command(["diff", "--cached", "--name-only"]) |
|
for line in staged_out.splitlines(): |
|
print(f" - {line}") |
|
else: |
|
_run_git_command(["commit", "-m", commit_msg]) |
|
print(f"{Colors.GREEN}Committed: {commit_msg}{Colors.NC}") |
|
|
|
# Push to remote |
|
print(f"{Colors.YELLOW}Pushing to remote...{Colors.NC}") |
|
if args.dry_run: |
|
print(f"[DRY-RUN] Would push to: origin/{current_branch}") |
|
else: |
|
ret, _, err = _run_git_command(["push", "-u", "origin", current_branch]) |
|
if ret != 0: |
|
print(f"{Colors.RED}Failed to push: {err}{Colors.NC}") |
|
return 1 |
|
print(f"{Colors.GREEN}Pushed to origin/{current_branch}{Colors.NC}") |
|
|
|
# Create PR |
|
print(f"{Colors.YELLOW}Creating PR...{Colors.NC}") |
|
pr_title = f"{commit_prefix}({scope}): {task_name}" |
|
pr_url = "" |
|
|
|
if args.dry_run: |
|
print("[DRY-RUN] Would create PR:") |
|
print(f" Title: {pr_title}") |
|
print(f" Base: {base_branch}") |
|
print(f" Head: {current_branch}") |
|
prd_file = target_dir_path / "prd.md" |
|
if prd_file.is_file(): |
|
print(" Body: (from prd.md)") |
|
pr_url = "https://github.com/example/repo/pull/DRY-RUN" |
|
else: |
|
# Check if PR already exists |
|
result = subprocess.run( |
|
[ |
|
"gh", |
|
"pr", |
|
"list", |
|
"--head", |
|
current_branch, |
|
"--base", |
|
base_branch, |
|
"--json", |
|
"url", |
|
"--jq", |
|
".[0].url", |
|
], |
|
capture_output=True, |
|
text=True, |
|
encoding="utf-8", |
|
errors="replace", |
|
) |
|
existing_pr = result.stdout.strip() |
|
|
|
if existing_pr: |
|
print(f"{Colors.YELLOW}PR already exists: {existing_pr}{Colors.NC}") |
|
pr_url = existing_pr |
|
else: |
|
# Read PRD as PR body |
|
pr_body = "" |
|
prd_file = target_dir_path / "prd.md" |
|
if prd_file.is_file(): |
|
pr_body = prd_file.read_text(encoding="utf-8") |
|
|
|
# Create PR |
|
result = subprocess.run( |
|
[ |
|
"gh", |
|
"pr", |
|
"create", |
|
"--draft", |
|
"--base", |
|
base_branch, |
|
"--title", |
|
pr_title, |
|
"--body", |
|
pr_body, |
|
], |
|
capture_output=True, |
|
text=True, |
|
encoding="utf-8", |
|
errors="replace", |
|
) |
|
|
|
if result.returncode != 0: |
|
print(f"{Colors.RED}Failed to create PR: {result.stderr}{Colors.NC}") |
|
return 1 |
|
|
|
pr_url = result.stdout.strip() |
|
print(f"{Colors.GREEN}PR created: {pr_url}{Colors.NC}") |
|
|
|
# Update task.json |
|
print(f"{Colors.YELLOW}Updating task status...{Colors.NC}") |
|
if args.dry_run: |
|
print("[DRY-RUN] Would update task.json:") |
|
print(" status: completed") |
|
print(f" pr_url: {pr_url}") |
|
print(" current_phase: (set to create-pr phase)") |
|
else: |
|
# Get the phase number for create-pr action |
|
create_pr_phase = get_phase_for_action(task_json, "create-pr") |
|
if not create_pr_phase: |
|
create_pr_phase = 4 # Default fallback |
|
|
|
task_data["status"] = "completed" |
|
task_data["pr_url"] = pr_url |
|
task_data["current_phase"] = create_pr_phase |
|
|
|
_write_json_file(task_json, task_data) |
|
print( |
|
f"{Colors.GREEN}Task status updated to 'completed', phase {create_pr_phase}{Colors.NC}" |
|
) |
|
|
|
# In dry-run, reset the staging area |
|
if args.dry_run: |
|
_run_git_command(["reset", "HEAD"]) |
|
|
|
print() |
|
print(f"{Colors.GREEN}=== PR Created Successfully ==={Colors.NC}") |
|
print(f"PR URL: {pr_url}") |
|
|
|
return 0 |
|
|
|
|
|
if __name__ == "__main__": |
|
sys.exit(main())
|
|
|