mirror of https://github.com/milvus-io/milvus.git
3953 lines
140 KiB
Python
Executable File
3953 lines
140 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
mgit - Intelligent Git Workflow Tool for Milvus
|
||
|
||
A smart Git workflow automation tool that:
|
||
- Generates AI-powered commit messages following Milvus conventions
|
||
- Automates fork → issue → PR → cherry-pick workflow
|
||
- Syncs with master and squashes commits for clean history
|
||
- Ensures DCO compliance
|
||
- Cherry-picks PRs to release branches with proper formatting
|
||
|
||
Usage:
|
||
mgit.py --commit # Smart commit workflow
|
||
mgit.py --rebase # Rebase onto master and squash commits
|
||
mgit.py --pr # PR creation workflow
|
||
mgit.py --search # Search GitHub issues and PRs by keyword
|
||
mgit.py --cherry-pick # Cherry-pick a merged PR to release branch
|
||
mgit.py --backport # Backport current branch to release branch with auto-rebase
|
||
mgit.py --all # Complete workflow (rebase + commit + PR)
|
||
mgit.py # Same as --all
|
||
"""
|
||
|
||
import subprocess
|
||
import sys
|
||
import os
|
||
import json
|
||
import argparse
|
||
import re
|
||
import time
|
||
import shutil
|
||
from typing import Any, Dict, List, Tuple, Optional
|
||
from dataclasses import dataclass
|
||
import urllib.request
|
||
import urllib.error
|
||
import uuid
|
||
|
||
|
||
def create_local_temp_file(content: str, suffix: str = ".txt") -> str:
|
||
"""Create a temporary file in the current directory.
|
||
|
||
Args:
|
||
content: Content to write to the file
|
||
suffix: File suffix (e.g., '.txt', '.md')
|
||
|
||
Returns:
|
||
Path to the created temp file
|
||
"""
|
||
filename = f".mgit_temp_{uuid.uuid4().hex[:8]}{suffix}"
|
||
with open(filename, "w", encoding="utf-8") as f:
|
||
f.write(content)
|
||
return filename
|
||
|
||
|
||
def remove_local_temp_file(filepath: str):
|
||
"""Remove a local temp file if it exists."""
|
||
if os.path.exists(filepath):
|
||
os.remove(filepath)
|
||
|
||
|
||
# ANSI colors for terminal output
|
||
class Colors:
|
||
GREEN = "\033[92m"
|
||
YELLOW = "\033[93m"
|
||
RED = "\033[91m"
|
||
BLUE = "\033[94m"
|
||
RESET = "\033[0m"
|
||
BOLD = "\033[1m"
|
||
|
||
|
||
def print_success(msg: str):
|
||
print(f"{Colors.GREEN}✓ {msg}{Colors.RESET}")
|
||
|
||
|
||
def print_error(msg: str):
|
||
print(f"{Colors.RED}✗ {msg}{Colors.RESET}")
|
||
|
||
|
||
def print_warning(msg: str):
|
||
print(f"{Colors.YELLOW}⚠ {msg}{Colors.RESET}")
|
||
|
||
|
||
def print_info(msg: str):
|
||
print(f"{Colors.BLUE}ℹ {msg}{Colors.RESET}")
|
||
|
||
|
||
def print_header(msg: str):
|
||
print(f"\n{Colors.BOLD}{msg}{Colors.RESET}")
|
||
|
||
|
||
# ============================================================================
|
||
# Git Module - Git operations
|
||
# ============================================================================
|
||
|
||
|
||
class GitOperations:
|
||
"""Handles all git command operations"""
|
||
|
||
@staticmethod
|
||
def run_command(
|
||
command: List[str], capture_output: bool = True, check: bool = True
|
||
) -> str:
|
||
"""Execute a git command and return output"""
|
||
try:
|
||
result = subprocess.run(
|
||
command,
|
||
check=check,
|
||
text=True,
|
||
stdout=subprocess.PIPE if capture_output else None,
|
||
stderr=subprocess.PIPE if capture_output else None,
|
||
)
|
||
return result.stdout.rstrip() if capture_output else ""
|
||
except subprocess.CalledProcessError as e:
|
||
if check:
|
||
raise Exception(f"Command failed: {' '.join(command)}\n{e.stderr}")
|
||
return ""
|
||
|
||
@staticmethod
|
||
def get_status() -> Tuple[List[str], List[str]]:
|
||
"""
|
||
Get git status
|
||
Returns: (staged_files, unstaged_files)
|
||
"""
|
||
output = GitOperations.run_command(["git", "status", "--porcelain"])
|
||
staged = []
|
||
unstaged = []
|
||
|
||
for line in output.splitlines():
|
||
if not line:
|
||
continue
|
||
status = line[:2]
|
||
filepath = line[3:]
|
||
|
||
# Handle rename/copy: "R old -> new" or "C old -> new"
|
||
# We want the new path for git operations
|
||
if " -> " in filepath:
|
||
filepath = filepath.split(" -> ", 1)[1]
|
||
|
||
# Staged changes (first character is not space)
|
||
if status[0] != " " and status[0] != "?":
|
||
staged.append(filepath)
|
||
# Unstaged changes
|
||
if status[1] != " " or status[0] == "?":
|
||
unstaged.append(filepath)
|
||
|
||
return staged, unstaged
|
||
|
||
@staticmethod
|
||
def get_staged_diff() -> str:
|
||
"""Get diff of staged changes"""
|
||
return GitOperations.run_command(["git", "diff", "--staged"])
|
||
|
||
@staticmethod
|
||
def get_staged_diff_stat() -> str:
|
||
"""Get diff statistics"""
|
||
return GitOperations.run_command(["git", "diff", "--staged", "--stat"])
|
||
|
||
@staticmethod
|
||
def stage_files(files: List[str]):
|
||
"""Stage specified files"""
|
||
GitOperations.run_command(["git", "add"] + files)
|
||
|
||
@staticmethod
|
||
def stage_all():
|
||
"""Stage all changes"""
|
||
GitOperations.run_command(["git", "add", "-A"])
|
||
|
||
@staticmethod
|
||
def commit(message: str):
|
||
"""Create commit with DCO signature"""
|
||
# Write message to local temporary file
|
||
msg_file = create_local_temp_file(message, ".txt")
|
||
|
||
try:
|
||
# Commit with -s flag (auto-adds DCO)
|
||
GitOperations.run_command(["git", "commit", "-s", "-F", msg_file])
|
||
finally:
|
||
# Clean up
|
||
remove_local_temp_file(msg_file)
|
||
|
||
@staticmethod
|
||
def get_commit_hash(ref: str = "HEAD") -> str:
|
||
"""Get commit hash"""
|
||
return GitOperations.run_command(["git", "rev-parse", "--short", ref])
|
||
|
||
@staticmethod
|
||
def get_current_branch() -> str:
|
||
"""Get current branch name"""
|
||
return GitOperations.run_command(["git", "rev-parse", "--abbrev-ref", "HEAD"])
|
||
|
||
@staticmethod
|
||
def get_last_commit_message() -> str:
|
||
"""Get last commit message"""
|
||
return GitOperations.run_command(["git", "log", "-1", "--pretty=%B"])
|
||
|
||
@staticmethod
|
||
def get_commit_count(base_branch: str = "master") -> int:
|
||
"""Get number of commits ahead of base branch (uses upstream remote)"""
|
||
upstream_master = GitOperations.get_upstream_master()
|
||
return GitOperations.get_commit_count_from_ref(upstream_master)
|
||
|
||
@staticmethod
|
||
def get_commit_count_from_ref(ref: str) -> int:
|
||
"""Get number of commits ahead of a specific ref (e.g., 'upstream/master')"""
|
||
try:
|
||
count = GitOperations.run_command(
|
||
["git", "rev-list", "--count", f"{ref}..HEAD"], check=False
|
||
)
|
||
return int(count) if count else 0
|
||
except Exception:
|
||
return 0
|
||
|
||
@staticmethod
|
||
def push(branch: str, force: bool = False, remote: str = None):
|
||
"""Push branch to remote (auto-detects fork if not specified)"""
|
||
if remote is None:
|
||
remote = GitOperations.get_fork_remote()
|
||
cmd = ["git", "push", remote, branch]
|
||
if force:
|
||
cmd.append("-f")
|
||
GitOperations.run_command(cmd)
|
||
|
||
@staticmethod
|
||
def get_fork_remote() -> str:
|
||
"""
|
||
Auto-detect the remote that points to user's fork.
|
||
Returns remote name (e.g., 'origin', 'fork')
|
||
"""
|
||
# Get current GitHub username
|
||
try:
|
||
username = GitOperations.run_command(["gh", "api", "user", "-q", ".login"])
|
||
except Exception:
|
||
username = None
|
||
|
||
# Get all remotes
|
||
remotes_output = GitOperations.run_command(["git", "remote", "-v"])
|
||
remotes = {}
|
||
for line in remotes_output.splitlines():
|
||
parts = line.split()
|
||
if len(parts) >= 2 and "(push)" in line:
|
||
name = parts[0]
|
||
url = parts[1]
|
||
remotes[name] = url
|
||
|
||
# Find remote that matches user's fork
|
||
for name, url in remotes.items():
|
||
# Check if URL contains username (github.com/username/ or github.com:username/)
|
||
if username and username.lower() in url.lower():
|
||
return name
|
||
# Check for common fork remote names
|
||
if name in ["fork", "myfork", "personal"]:
|
||
return name
|
||
|
||
# If origin points to milvus-io/milvus, look for another remote
|
||
origin_url = remotes.get("origin", "")
|
||
if "milvus-io/milvus" in origin_url:
|
||
# Origin is upstream, find fork
|
||
for name, url in remotes.items():
|
||
if name != "origin" and "milvus" in url.lower():
|
||
return name
|
||
|
||
# Default to origin
|
||
return "origin"
|
||
|
||
@staticmethod
|
||
def get_fork_info() -> Tuple[str, str]:
|
||
"""
|
||
Get fork remote name and owner
|
||
Returns: (remote_name, owner_username)
|
||
"""
|
||
remote = GitOperations.get_fork_remote()
|
||
remote_url = GitOperations.run_command(["git", "remote", "get-url", remote])
|
||
|
||
# Parse username from URL
|
||
# SSH: git@github.com:username/repo.git
|
||
# HTTPS: https://github.com/username/repo.git
|
||
if "github.com:" in remote_url:
|
||
# SSH format
|
||
owner = remote_url.split("github.com:")[1].split("/")[0]
|
||
elif "github.com/" in remote_url:
|
||
# HTTPS format
|
||
owner = remote_url.split("github.com/")[1].split("/")[0]
|
||
else:
|
||
# Fallback: try gh api
|
||
owner = GitOperations.run_command(["gh", "api", "user", "-q", ".login"])
|
||
|
||
return remote, owner
|
||
|
||
@staticmethod
|
||
def get_user_info() -> Tuple[str, str]:
|
||
"""Get git user name and email"""
|
||
name = GitOperations.run_command(["git", "config", "user.name"])
|
||
email = GitOperations.run_command(["git", "config", "user.email"])
|
||
return name, email
|
||
|
||
@staticmethod
|
||
def create_branch(branch_name: str):
|
||
"""Create and checkout a new branch"""
|
||
GitOperations.run_command(["git", "checkout", "-b", branch_name])
|
||
|
||
@staticmethod
|
||
def branch_exists(branch_name: str) -> bool:
|
||
"""Check if a branch exists locally"""
|
||
result = GitOperations.run_command(
|
||
["git", "rev-parse", "--verify", branch_name], check=False
|
||
)
|
||
return bool(result)
|
||
|
||
@staticmethod
|
||
def fetch(remote: str = "origin", branch: str = None):
|
||
"""Fetch from remote"""
|
||
cmd = ["git", "fetch", remote]
|
||
if branch:
|
||
cmd.append(branch)
|
||
GitOperations.run_command(cmd)
|
||
|
||
@staticmethod
|
||
def get_upstream_remote() -> str:
|
||
"""
|
||
Find the remote that points to milvus-io/milvus (upstream).
|
||
Returns remote name (e.g., 'upstream', 'origin').
|
||
Always rebase against the official repo, not a potentially stale fork.
|
||
"""
|
||
remotes_output = GitOperations.run_command(["git", "remote", "-v"])
|
||
|
||
# First, look for a remote pointing to milvus-io/milvus
|
||
for line in remotes_output.splitlines():
|
||
if "milvus-io/milvus" in line and "(fetch)" in line:
|
||
return line.split()[0]
|
||
|
||
# If not found, check common upstream remote names
|
||
remotes = [
|
||
line.split()[0] for line in remotes_output.splitlines() if "(fetch)" in line
|
||
]
|
||
for name in ["upstream", "milvus", "official"]:
|
||
if name in remotes:
|
||
return name
|
||
|
||
# Fallback to origin (user should configure upstream properly)
|
||
print_warning("No upstream remote found pointing to milvus-io/milvus")
|
||
print_info(
|
||
"Consider adding: git remote add upstream git@github.com:milvus-io/milvus.git"
|
||
)
|
||
return "origin"
|
||
|
||
@staticmethod
|
||
def get_upstream_master() -> str:
|
||
"""Get the upstream master reference (e.g., 'upstream/master')"""
|
||
upstream = GitOperations.get_upstream_remote()
|
||
return f"{upstream}/master"
|
||
|
||
@staticmethod
|
||
def rebase(target: str) -> Tuple[bool, str]:
|
||
"""
|
||
Rebase current branch onto target
|
||
Returns: (success, error_message)
|
||
"""
|
||
try:
|
||
GitOperations.run_command(["git", "rebase", target])
|
||
return True, ""
|
||
except Exception as e:
|
||
return False, str(e)
|
||
|
||
@staticmethod
|
||
def rebase_abort():
|
||
"""Abort an in-progress rebase"""
|
||
GitOperations.run_command(["git", "rebase", "--abort"], check=False)
|
||
|
||
@staticmethod
|
||
def is_rebase_in_progress() -> bool:
|
||
"""Check if a rebase is in progress"""
|
||
git_dir = GitOperations.run_command(["git", "rev-parse", "--git-dir"])
|
||
return os.path.exists(os.path.join(git_dir, "rebase-merge")) or os.path.exists(
|
||
os.path.join(git_dir, "rebase-apply")
|
||
)
|
||
|
||
@staticmethod
|
||
def reset_soft(target: str):
|
||
"""Soft reset to target (keeps changes staged)"""
|
||
GitOperations.run_command(["git", "reset", "--soft", target])
|
||
|
||
@staticmethod
|
||
def get_all_changes_diff(base: str = None) -> str:
|
||
"""Get diff of all changes from base (defaults to upstream master)"""
|
||
if base is None:
|
||
base = GitOperations.get_upstream_master()
|
||
return GitOperations.run_command(["git", "diff", base])
|
||
|
||
@staticmethod
|
||
def get_all_changes_stat(base: str = None) -> str:
|
||
"""Get diff statistics from base (defaults to upstream master)"""
|
||
if base is None:
|
||
base = GitOperations.get_upstream_master()
|
||
return GitOperations.run_command(["git", "diff", "--stat", base])
|
||
|
||
@staticmethod
|
||
def get_commit_messages(base: str = None) -> List[str]:
|
||
"""Get all commit messages from base to HEAD (defaults to upstream master)"""
|
||
if base is None:
|
||
base = GitOperations.get_upstream_master()
|
||
output = GitOperations.run_command(
|
||
["git", "log", f"{base}..HEAD", "--pretty=%B", "--reverse"]
|
||
)
|
||
return [msg.strip() for msg in output.split("\n\n") if msg.strip()]
|
||
|
||
@staticmethod
|
||
def checkout_branch(branch: str, create: bool = False):
|
||
"""Checkout a branch"""
|
||
cmd = ["git", "checkout"]
|
||
if create:
|
||
cmd.append("-b")
|
||
cmd.append(branch)
|
||
GitOperations.run_command(cmd)
|
||
|
||
@staticmethod
|
||
def checkout_remote_branch(remote: str, branch: str, local_name: str = None):
|
||
"""Checkout a remote branch to a local branch"""
|
||
if local_name is None:
|
||
local_name = branch
|
||
GitOperations.run_command(
|
||
["git", "checkout", "-b", local_name, f"{remote}/{branch}"]
|
||
)
|
||
|
||
@staticmethod
|
||
def cherry_pick(commit_sha: str) -> Tuple[bool, str, List[str]]:
|
||
"""
|
||
Cherry-pick a commit
|
||
|
||
Returns: (success, error_message, conflict_files)
|
||
"""
|
||
try:
|
||
GitOperations.run_command(["git", "cherry-pick", commit_sha])
|
||
return True, "", []
|
||
except Exception as e:
|
||
error_msg = str(e)
|
||
# Get list of conflicting files
|
||
conflict_files = []
|
||
try:
|
||
status_output = GitOperations.run_command(
|
||
["git", "status", "--porcelain"]
|
||
)
|
||
for line in status_output.splitlines():
|
||
if (
|
||
line.startswith("UU ")
|
||
or line.startswith("AA ")
|
||
or line.startswith("DD ")
|
||
):
|
||
conflict_files.append(line[3:])
|
||
except Exception:
|
||
pass
|
||
return False, error_msg, conflict_files
|
||
|
||
@staticmethod
|
||
def cherry_pick_abort():
|
||
"""Abort an in-progress cherry-pick"""
|
||
GitOperations.run_command(["git", "cherry-pick", "--abort"], check=False)
|
||
|
||
@staticmethod
|
||
def cherry_pick_continue():
|
||
"""Continue cherry-pick after resolving conflicts"""
|
||
GitOperations.run_command(["git", "cherry-pick", "--continue"])
|
||
|
||
@staticmethod
|
||
def is_cherry_pick_in_progress() -> bool:
|
||
"""Check if a cherry-pick is in progress"""
|
||
git_dir = GitOperations.run_command(["git", "rev-parse", "--git-dir"])
|
||
return os.path.exists(os.path.join(git_dir, "CHERRY_PICK_HEAD"))
|
||
|
||
@staticmethod
|
||
def get_conflict_diff(file_path: str) -> str:
|
||
"""Get the conflict markers content for a file"""
|
||
try:
|
||
with open(file_path, "r") as f:
|
||
return f.read()
|
||
except Exception:
|
||
return ""
|
||
|
||
@staticmethod
|
||
def remote_branch_exists(remote: str, branch: str) -> bool:
|
||
"""Check if a remote branch exists"""
|
||
try:
|
||
output = GitOperations.run_command(
|
||
["git", "ls-remote", "--heads", remote, branch]
|
||
)
|
||
return bool(output.strip())
|
||
except Exception:
|
||
return False
|
||
|
||
@staticmethod
|
||
def delete_branch(branch: str, force: bool = False):
|
||
"""Delete a local branch"""
|
||
cmd = ["git", "branch", "-D" if force else "-d", branch]
|
||
GitOperations.run_command(cmd, check=False)
|
||
|
||
|
||
# ============================================================================
|
||
# AI Module - Claude and OpenAI API integration
|
||
# ============================================================================
|
||
|
||
|
||
class AIService:
|
||
"""Handles AI API calls for commit message and PR generation"""
|
||
|
||
COMMIT_TYPES = ["fix", "enhance", "feat", "refactor", "test", "docs", "chore"]
|
||
|
||
def __init__(self):
|
||
# Check for local claude CLI first
|
||
self.has_claude_cli = self._check_claude_cli()
|
||
|
||
self.gemini_key = os.getenv("GEMINI_API_KEY")
|
||
self.anthropic_key = os.getenv("ANTHROPIC_API_KEY")
|
||
self.openai_key = os.getenv("OPENAI_API_KEY")
|
||
|
||
# Has API key if any key is set OR local claude is available
|
||
self.has_api_key = bool(
|
||
self.has_claude_cli
|
||
or self.gemini_key
|
||
or self.anthropic_key
|
||
or self.openai_key
|
||
)
|
||
|
||
@staticmethod
|
||
def _check_claude_cli() -> bool:
|
||
"""Check if local claude CLI is available"""
|
||
# Use shutil.which() for cross-platform compatibility (works on Windows too)
|
||
return shutil.which("claude") is not None
|
||
|
||
def generate_commit_message(
|
||
self, diff: str, files: List[str], stats: str
|
||
) -> Dict[str, str]:
|
||
"""
|
||
Generate commit message using AI
|
||
|
||
Returns:
|
||
{
|
||
'type': 'fix|enhance|feat|...',
|
||
'title': 'Short summary (≤80 chars)',
|
||
'body': 'Optional detailed explanation'
|
||
}
|
||
"""
|
||
# Limit diff size to avoid API limits
|
||
max_diff_lines = 10000
|
||
diff_lines = diff.splitlines()
|
||
if len(diff_lines) > max_diff_lines:
|
||
print_warning(
|
||
f"Diff has {len(diff_lines)} lines (>{max_diff_lines}). Consider splitting into smaller PRs."
|
||
)
|
||
truncated_diff = "\n".join(diff_lines[:max_diff_lines])
|
||
truncated_diff += (
|
||
f"\n\n... (truncated {len(diff_lines) - max_diff_lines} lines)"
|
||
)
|
||
else:
|
||
truncated_diff = diff
|
||
|
||
# Check if any AI source is available
|
||
if not self.has_api_key:
|
||
raise Exception(
|
||
"No AI available. Please either:\n"
|
||
" - Install Claude Code CLI (https://claude.com/code), or\n"
|
||
" - Set one of: GEMINI_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY"
|
||
)
|
||
|
||
prompt = self._build_commit_prompt(truncated_diff, files, stats)
|
||
|
||
# Try local Claude CLI first if available
|
||
if self.has_claude_cli:
|
||
try:
|
||
return self._call_claude_cli(prompt)
|
||
except Exception as e:
|
||
print_warning(f"Claude CLI failed: {e}")
|
||
if self.gemini_key or self.anthropic_key or self.openai_key:
|
||
print_info("Falling back to API providers...")
|
||
else:
|
||
raise
|
||
|
||
# Try Gemini API if available
|
||
if self.gemini_key:
|
||
try:
|
||
return self._call_gemini(prompt)
|
||
except Exception as e:
|
||
print_warning(f"Gemini API failed: {e}")
|
||
if self.anthropic_key or self.openai_key:
|
||
print_info("Falling back to other AI providers...")
|
||
else:
|
||
raise
|
||
|
||
# Fall back to Claude
|
||
if self.anthropic_key:
|
||
try:
|
||
return self._call_claude(prompt)
|
||
except Exception as e:
|
||
print_warning(f"Claude API failed: {e}")
|
||
if self.openai_key:
|
||
print_info("Falling back to OpenAI...")
|
||
else:
|
||
raise
|
||
|
||
# Fall back to OpenAI
|
||
if self.openai_key:
|
||
return self._call_openai(prompt)
|
||
|
||
raise Exception("All AI API calls failed")
|
||
|
||
def _build_commit_prompt(self, diff: str, files: List[str], stats: str) -> str:
|
||
"""Build prompt for commit message generation"""
|
||
examples = """
|
||
Examples of good Milvus commits:
|
||
- fix: Fix missing handling of FlushAllMsg in recovery storage
|
||
- enhance: optimize jieba and lindera analyzer clone
|
||
- feat: Add semantic highlight
|
||
- refactor: Simplify go unit tests
|
||
- test: Add planparserv2 benchmarks
|
||
"""
|
||
files_list = "\n".join(f" - {f}" for f in files)
|
||
|
||
return f"""You are a commit message generator for the Milvus vector database project.
|
||
|
||
Generate a commit message following these guidelines:
|
||
|
||
1. Format: <type>: <summary>
|
||
Types: fix, enhance, feat, refactor, test, docs, chore
|
||
2. Title MUST be ≤80 characters
|
||
3. Title should describe WHAT changed and WHY
|
||
4. Use imperative mood (e.g., "Fix bug" not "Fixed bug")
|
||
5. Optional body for complex changes (multiple paragraphs OK)
|
||
|
||
{examples}
|
||
|
||
Changed Files:
|
||
{files_list}
|
||
|
||
Statistics: {stats}
|
||
|
||
Diff:
|
||
{diff}
|
||
|
||
Return ONLY a JSON object with this exact format:
|
||
{{"type": "fix", "title": "your title here", "body": "optional detailed explanation"}}
|
||
|
||
If the change is simple, set body to empty string.
|
||
Ensure title is concise and ≤80 characters.
|
||
"""
|
||
|
||
def _call_claude_cli(self, prompt: str) -> Dict[str, str]:
|
||
"""Call local Claude Code CLI"""
|
||
try:
|
||
result = subprocess.run(
|
||
["claude", "-p", prompt], capture_output=True, text=True, timeout=60
|
||
)
|
||
|
||
if result.returncode != 0:
|
||
raise Exception(f"Claude CLI returned error: {result.stderr}")
|
||
|
||
content = result.stdout.strip()
|
||
return self._parse_ai_response(content)
|
||
|
||
except subprocess.TimeoutExpired:
|
||
raise Exception("Claude CLI timed out (>60s)")
|
||
except Exception as e:
|
||
raise Exception(f"Claude CLI error: {e}")
|
||
|
||
def _call_claude(self, prompt: str) -> Dict[str, str]:
|
||
"""Call Claude API"""
|
||
url = "https://api.anthropic.com/v1/messages"
|
||
|
||
data = {
|
||
"model": "claude-3-5-sonnet-20241022",
|
||
"max_tokens": 1024,
|
||
"messages": [{"role": "user", "content": prompt}],
|
||
}
|
||
|
||
req = urllib.request.Request(
|
||
url,
|
||
data=json.dumps(data).encode("utf-8"),
|
||
headers={
|
||
"x-api-key": self.anthropic_key,
|
||
"anthropic-version": "2023-06-01",
|
||
"content-type": "application/json",
|
||
},
|
||
)
|
||
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=30) as response:
|
||
result = json.loads(response.read().decode("utf-8"))
|
||
content = result["content"][0]["text"]
|
||
return self._parse_ai_response(content)
|
||
except urllib.error.HTTPError as e:
|
||
error_body = e.read().decode("utf-8")
|
||
raise Exception(f"Claude API HTTP {e.code}: {error_body}")
|
||
except Exception as e:
|
||
raise Exception(f"Claude API error: {e}")
|
||
|
||
def _call_openai(self, prompt: str) -> Dict[str, str]:
|
||
"""Call OpenAI API"""
|
||
url = "https://api.openai.com/v1/chat/completions"
|
||
|
||
data = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": prompt}],
|
||
"temperature": 0.7,
|
||
}
|
||
|
||
req = urllib.request.Request(
|
||
url,
|
||
data=json.dumps(data).encode("utf-8"),
|
||
headers={
|
||
"Authorization": f"Bearer {self.openai_key}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
)
|
||
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=30) as response:
|
||
result = json.loads(response.read().decode("utf-8"))
|
||
content = result["choices"][0]["message"]["content"]
|
||
return self._parse_ai_response(content)
|
||
except urllib.error.HTTPError as e:
|
||
error_body = e.read().decode("utf-8")
|
||
raise Exception(f"OpenAI API HTTP {e.code}: {error_body}")
|
||
except Exception as e:
|
||
raise Exception(f"OpenAI API error: {e}")
|
||
|
||
def _call_gemini(self, prompt: str) -> Dict[str, str]:
|
||
"""Call Google Gemini API"""
|
||
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={self.gemini_key}"
|
||
|
||
data = {
|
||
"contents": [{"parts": [{"text": prompt}]}],
|
||
"generationConfig": {"temperature": 0.7, "maxOutputTokens": 2048},
|
||
}
|
||
|
||
req = urllib.request.Request(
|
||
url,
|
||
data=json.dumps(data).encode("utf-8"),
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=30) as response:
|
||
result = json.loads(response.read().decode("utf-8"))
|
||
content = result["candidates"][0]["content"]["parts"][0]["text"]
|
||
return self._parse_ai_response(content)
|
||
except urllib.error.HTTPError as e:
|
||
error_body = e.read().decode("utf-8")
|
||
raise Exception(f"Gemini API HTTP {e.code}: {error_body}")
|
||
except Exception as e:
|
||
raise Exception(f"Gemini API error: {e}")
|
||
|
||
def _extract_json(self, content: str) -> str:
|
||
"""Extract JSON object from content that may contain extra text"""
|
||
# Remove markdown code blocks
|
||
content = content.replace("```json", "").replace("```", "").strip()
|
||
|
||
# Try direct parse first
|
||
try:
|
||
json.loads(content)
|
||
return content
|
||
except Exception:
|
||
pass
|
||
|
||
# Find the first { and last } to extract JSON
|
||
start = content.find("{")
|
||
if start == -1:
|
||
return content
|
||
|
||
# Find matching closing brace
|
||
depth = 0
|
||
end = -1
|
||
for i in range(start, len(content)):
|
||
if content[i] == "{":
|
||
depth += 1
|
||
elif content[i] == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
end = i
|
||
break
|
||
|
||
if end != -1:
|
||
return content[start : end + 1]
|
||
|
||
# Fallback: use last }
|
||
end = content.rfind("}")
|
||
if end > start:
|
||
return content[start : end + 1]
|
||
|
||
return content
|
||
|
||
def _parse_ai_response(self, content: str) -> Dict[str, str]:
|
||
"""Parse AI response to extract commit message"""
|
||
content = self._extract_json(content)
|
||
|
||
try:
|
||
data = json.loads(content)
|
||
|
||
# Validate required fields
|
||
if "type" not in data or "title" not in data:
|
||
raise ValueError("Missing required fields")
|
||
|
||
# Validate type
|
||
if data["type"] not in self.COMMIT_TYPES:
|
||
print_warning(f"Unknown commit type: {data['type']}, using 'chore'")
|
||
data["type"] = "chore"
|
||
|
||
# Ensure title is ≤80 chars
|
||
if len(data["title"]) > 80:
|
||
print_warning(
|
||
f"Title too long ({len(data['title'])} chars), truncating..."
|
||
)
|
||
data["title"] = data["title"][:77] + "..."
|
||
|
||
# Ensure body exists
|
||
if "body" not in data:
|
||
data["body"] = ""
|
||
|
||
return data
|
||
|
||
except json.JSONDecodeError as e:
|
||
raise Exception(
|
||
f"Failed to parse AI response as JSON: {e}\nContent: {content}"
|
||
)
|
||
|
||
def generate_issue_content(
|
||
self, diff: str, files: List[str], stats: str, issue_type: str
|
||
) -> Dict[str, str]:
|
||
"""
|
||
Generate issue title and body using AI
|
||
|
||
Returns:
|
||
{
|
||
'title': 'Issue title',
|
||
'body': 'Issue description'
|
||
}
|
||
"""
|
||
# Limit diff size
|
||
max_diff_lines = 10000
|
||
diff_lines = diff.splitlines()
|
||
if len(diff_lines) > max_diff_lines:
|
||
print_warning(
|
||
f"Diff has {len(diff_lines)} lines (>{max_diff_lines}). Consider splitting into smaller PRs."
|
||
)
|
||
truncated_diff = "\n".join(diff_lines[:max_diff_lines])
|
||
truncated_diff += (
|
||
f"\n\n... (truncated {len(diff_lines) - max_diff_lines} lines)"
|
||
)
|
||
else:
|
||
truncated_diff = diff
|
||
|
||
if not self.has_api_key:
|
||
raise Exception("No AI available for issue generation")
|
||
|
||
prompt = self._build_issue_prompt(truncated_diff, files, stats, issue_type)
|
||
|
||
# Try AI providers in order
|
||
if self.has_claude_cli:
|
||
try:
|
||
return self._call_ai_for_issue(prompt)
|
||
except Exception as e:
|
||
print_warning(f"Claude CLI failed: {e}")
|
||
if not (self.gemini_key or self.anthropic_key or self.openai_key):
|
||
raise
|
||
|
||
if self.gemini_key:
|
||
try:
|
||
return self._call_ai_for_issue(prompt, "gemini")
|
||
except Exception as e:
|
||
print_warning(f"Gemini API failed: {e}")
|
||
if not (self.anthropic_key or self.openai_key):
|
||
raise
|
||
|
||
if self.anthropic_key:
|
||
try:
|
||
return self._call_ai_for_issue(prompt, "claude")
|
||
except Exception as e:
|
||
print_warning(f"Claude API failed: {e}")
|
||
if not self.openai_key:
|
||
raise
|
||
|
||
if self.openai_key:
|
||
return self._call_ai_for_issue(prompt, "openai")
|
||
|
||
raise Exception("All AI API calls failed")
|
||
|
||
def _build_issue_prompt(
|
||
self, diff: str, files: List[str], stats: str, issue_type: str
|
||
) -> str:
|
||
"""Build prompt for issue content generation"""
|
||
# Map issue type to prefix and description
|
||
type_config = {
|
||
"bug": {"prefix": "[Bug]", "desc": "a bug fix"},
|
||
"feature": {"prefix": "[Feature]", "desc": "a new feature"},
|
||
"enhancement": {
|
||
"prefix": "[Enhancement]",
|
||
"desc": "an enhancement to existing functionality",
|
||
},
|
||
"benchmark": {
|
||
"prefix": "[Benchmark]",
|
||
"desc": "a benchmark or performance improvement",
|
||
},
|
||
}
|
||
config = type_config.get(
|
||
issue_type, {"prefix": "[Feature]", "desc": "a change"}
|
||
)
|
||
prefix = config["prefix"]
|
||
type_desc = config["desc"]
|
||
files_list = "\n".join(f" - {f}" for f in files)
|
||
|
||
return f"""You are generating a GitHub issue for the Milvus vector database project.
|
||
This issue is for {type_desc}.
|
||
|
||
Based on the code changes below, generate:
|
||
1. A clear, concise issue title following Milvus convention:
|
||
- MUST start with "{prefix}" prefix
|
||
- Format: "{prefix} Brief description of the issue"
|
||
- Example: "{prefix} Add support for sparse vector search"
|
||
- Total length ≤80 characters (including prefix)
|
||
2. A detailed issue description explaining:
|
||
- What problem this solves or what feature this adds
|
||
- Why this change is needed
|
||
- Brief technical approach (if relevant)
|
||
|
||
Changed Files:
|
||
{files_list}
|
||
|
||
Statistics: {stats}
|
||
|
||
Diff:
|
||
{diff}
|
||
|
||
Return ONLY a JSON object with this exact format:
|
||
{{"title": "{prefix} Your title here", "body": "Detailed issue description here"}}
|
||
|
||
IMPORTANT: The title MUST start with "{prefix}".
|
||
Make the description professional and informative.
|
||
Use markdown formatting in the body where appropriate.
|
||
"""
|
||
|
||
def _call_ai_for_issue(self, prompt: str, provider: str = "cli") -> Dict[str, str]:
|
||
"""Call AI and parse issue response"""
|
||
if provider == "cli":
|
||
result = subprocess.run(
|
||
["claude", "-p", prompt], capture_output=True, text=True, timeout=60
|
||
)
|
||
if result.returncode != 0:
|
||
raise Exception(f"Claude CLI error: {result.stderr}")
|
||
content = result.stdout.strip()
|
||
elif provider == "gemini":
|
||
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={self.gemini_key}"
|
||
data = {
|
||
"contents": [{"parts": [{"text": prompt}]}],
|
||
"generationConfig": {"temperature": 0.7, "maxOutputTokens": 2048},
|
||
}
|
||
req = urllib.request.Request(
|
||
url,
|
||
data=json.dumps(data).encode("utf-8"),
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
with urllib.request.urlopen(req, timeout=30) as response:
|
||
result = json.loads(response.read().decode("utf-8"))
|
||
content = result["candidates"][0]["content"]["parts"][0]["text"]
|
||
elif provider == "claude":
|
||
url = "https://api.anthropic.com/v1/messages"
|
||
data = {
|
||
"model": "claude-3-5-sonnet-20241022",
|
||
"max_tokens": 1024,
|
||
"messages": [{"role": "user", "content": prompt}],
|
||
}
|
||
req = urllib.request.Request(
|
||
url,
|
||
data=json.dumps(data).encode("utf-8"),
|
||
headers={
|
||
"x-api-key": self.anthropic_key,
|
||
"anthropic-version": "2023-06-01",
|
||
"content-type": "application/json",
|
||
},
|
||
)
|
||
with urllib.request.urlopen(req, timeout=30) as response:
|
||
result = json.loads(response.read().decode("utf-8"))
|
||
content = result["content"][0]["text"]
|
||
elif provider == "openai":
|
||
url = "https://api.openai.com/v1/chat/completions"
|
||
data = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": prompt}],
|
||
"temperature": 0.7,
|
||
}
|
||
req = urllib.request.Request(
|
||
url,
|
||
data=json.dumps(data).encode("utf-8"),
|
||
headers={
|
||
"Authorization": f"Bearer {self.openai_key}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
)
|
||
with urllib.request.urlopen(req, timeout=30) as response:
|
||
result = json.loads(response.read().decode("utf-8"))
|
||
content = result["choices"][0]["message"]["content"]
|
||
else:
|
||
raise Exception(f"Unknown provider: {provider}")
|
||
|
||
# Parse response using shared extraction method
|
||
content = self._extract_json(content)
|
||
|
||
try:
|
||
data = json.loads(content)
|
||
if "title" not in data or "body" not in data:
|
||
raise ValueError("Missing required fields")
|
||
return data
|
||
except json.JSONDecodeError as e:
|
||
raise Exception(f"Failed to parse AI response: {e}")
|
||
|
||
def analyze_conflict(
|
||
self, conflict_files: List[str], original_pr_title: str
|
||
) -> str:
|
||
"""
|
||
Analyze cherry-pick conflicts using AI
|
||
|
||
Args:
|
||
conflict_files: List of file paths with conflicts
|
||
original_pr_title: Title of the original PR being cherry-picked
|
||
|
||
Returns: AI analysis and suggestions
|
||
"""
|
||
if not self.has_api_key:
|
||
return "No AI available for conflict analysis."
|
||
|
||
# Read conflict content from files
|
||
conflict_contents = []
|
||
for file_path in conflict_files[:5]: # Limit to 5 files
|
||
try:
|
||
content = GitOperations.get_conflict_diff(file_path)
|
||
if content:
|
||
# Limit content size
|
||
if len(content) > 2000:
|
||
content = content[:2000] + "\n... (truncated)"
|
||
conflict_contents.append(f"=== {file_path} ===\n{content}")
|
||
except Exception:
|
||
pass
|
||
|
||
if not conflict_contents:
|
||
return "Could not read conflict content from files."
|
||
|
||
conflicts_text = "\n".join(conflict_contents)
|
||
prompt = f"""You are analyzing a git cherry-pick conflict for the Milvus vector database project.
|
||
|
||
Original PR being cherry-picked: {original_pr_title}
|
||
|
||
The following files have merge conflicts:
|
||
|
||
{conflicts_text}
|
||
|
||
Please analyze:
|
||
1. What is causing the conflict (e.g., code structure changes, variable renames, etc.)
|
||
2. Which version should likely be kept (HEAD or the cherry-picked commit)
|
||
3. Specific suggestions for resolving each conflict
|
||
|
||
Keep the response concise and actionable. Focus on the most important conflicts first.
|
||
"""
|
||
|
||
# Try available AI providers
|
||
try:
|
||
if self.has_claude_cli:
|
||
result = subprocess.run(
|
||
["claude", "-p", prompt], capture_output=True, text=True, timeout=60
|
||
)
|
||
if result.returncode == 0:
|
||
return result.stdout.strip()
|
||
|
||
if self.gemini_key:
|
||
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={self.gemini_key}"
|
||
data = {
|
||
"contents": [{"parts": [{"text": prompt}]}],
|
||
"generationConfig": {"temperature": 0.3, "maxOutputTokens": 2048},
|
||
}
|
||
req = urllib.request.Request(
|
||
url,
|
||
data=json.dumps(data).encode("utf-8"),
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
with urllib.request.urlopen(req, timeout=30) as response:
|
||
result = json.loads(response.read().decode("utf-8"))
|
||
return result["candidates"][0]["content"]["parts"][0]["text"]
|
||
|
||
if self.anthropic_key:
|
||
url = "https://api.anthropic.com/v1/messages"
|
||
data = {
|
||
"model": "claude-3-5-sonnet-20241022",
|
||
"max_tokens": 2048,
|
||
"messages": [{"role": "user", "content": prompt}],
|
||
}
|
||
req = urllib.request.Request(
|
||
url,
|
||
data=json.dumps(data).encode("utf-8"),
|
||
headers={
|
||
"x-api-key": self.anthropic_key,
|
||
"anthropic-version": "2023-06-01",
|
||
"content-type": "application/json",
|
||
},
|
||
)
|
||
with urllib.request.urlopen(req, timeout=30) as response:
|
||
result = json.loads(response.read().decode("utf-8"))
|
||
return result["content"][0]["text"]
|
||
|
||
if self.openai_key:
|
||
url = "https://api.openai.com/v1/chat/completions"
|
||
data = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": prompt}],
|
||
"temperature": 0.3,
|
||
}
|
||
req = urllib.request.Request(
|
||
url,
|
||
data=json.dumps(data).encode("utf-8"),
|
||
headers={
|
||
"Authorization": f"Bearer {self.openai_key}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
)
|
||
with urllib.request.urlopen(req, timeout=30) as response:
|
||
result = json.loads(response.read().decode("utf-8"))
|
||
return result["choices"][0]["message"]["content"]
|
||
|
||
except Exception as e:
|
||
return f"AI analysis failed: {e}"
|
||
|
||
return "No AI provider available for conflict analysis."
|
||
|
||
def validate_design_doc(
|
||
self, design_doc_url: str, diff: str, files: List[str], stats: str
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Validate that design doc matches the code changes using AI
|
||
|
||
Args:
|
||
design_doc_url: URL to the design doc (GitHub blob URL)
|
||
diff: Code diff
|
||
files: List of changed files
|
||
stats: Diff statistics
|
||
|
||
Returns:
|
||
{
|
||
'valid': True/False,
|
||
'score': 0-100 (match score),
|
||
'summary': 'Brief summary of the validation',
|
||
'concerns': ['list of concerns if any'],
|
||
'suggestions': ['list of suggestions if any']
|
||
}
|
||
"""
|
||
if not self.has_api_key:
|
||
return {
|
||
"valid": True,
|
||
"score": -1,
|
||
"summary": "AI not available for validation, skipping.",
|
||
"concerns": [],
|
||
"suggestions": [],
|
||
}
|
||
|
||
# Fetch design doc content
|
||
try:
|
||
design_doc_content = self._fetch_design_doc(design_doc_url)
|
||
except Exception as e:
|
||
return {
|
||
"valid": False,
|
||
"score": 0,
|
||
"summary": f"Failed to fetch design doc: {e}",
|
||
"concerns": ["Could not retrieve design document content"],
|
||
"suggestions": ["Verify the design doc URL is accessible"],
|
||
}
|
||
|
||
# Limit diff size
|
||
max_diff_lines = 5000
|
||
diff_lines = diff.splitlines()
|
||
if len(diff_lines) > max_diff_lines:
|
||
truncated_diff = "\n".join(diff_lines[:max_diff_lines])
|
||
truncated_diff += (
|
||
f"\n\n... (truncated {len(diff_lines) - max_diff_lines} lines)"
|
||
)
|
||
else:
|
||
truncated_diff = diff
|
||
|
||
# Limit design doc size
|
||
max_doc_chars = 15000
|
||
if len(design_doc_content) > max_doc_chars:
|
||
design_doc_content = (
|
||
design_doc_content[:max_doc_chars] + "\n\n... (truncated)"
|
||
)
|
||
|
||
files_list = "\n".join(f" - {f}" for f in files[:50]) # Limit files
|
||
|
||
prompt = f"""You are a code reviewer for the Milvus vector database project.
|
||
Your task is to validate whether the code changes match the design document.
|
||
|
||
## Design Document:
|
||
{design_doc_content}
|
||
|
||
## Changed Files:
|
||
{files_list}
|
||
|
||
## Statistics: {stats}
|
||
|
||
## Code Diff:
|
||
{truncated_diff}
|
||
|
||
## Your Task:
|
||
Analyze whether the code changes implement what's described in the design document.
|
||
|
||
Return a JSON object with this exact format:
|
||
{{
|
||
"valid": true/false,
|
||
"score": 0-100,
|
||
"summary": "Brief 1-2 sentence summary of the validation result",
|
||
"concerns": ["list of specific concerns if the code doesn't match the design"],
|
||
"suggestions": ["list of suggestions for improvement"]
|
||
}}
|
||
|
||
Scoring guidelines:
|
||
- 90-100: Code closely matches design, all major components implemented
|
||
- 70-89: Code mostly matches design, minor discrepancies
|
||
- 50-69: Code partially matches design, some components missing or different
|
||
- 30-49: Code has significant differences from design
|
||
- 0-29: Code doesn't match design or design doc is for different feature
|
||
|
||
Set "valid" to true if score >= 70, false otherwise.
|
||
Keep concerns and suggestions concise and actionable.
|
||
"""
|
||
|
||
try:
|
||
result = self._call_ai_for_validation(prompt)
|
||
return result
|
||
except Exception as e:
|
||
return {
|
||
"valid": False,
|
||
"score": -1,
|
||
"summary": f"Validation failed: {e}",
|
||
"concerns": ["AI validation failed"],
|
||
"suggestions": ["Check API keys or network connectivity"],
|
||
}
|
||
|
||
def _fetch_design_doc(self, url: str) -> str:
|
||
"""Fetch design doc content from GitHub URL"""
|
||
# Convert blob URL to raw URL
|
||
# https://github.com/milvus-io/milvus-design-docs/blob/main/design_docs/xxx.md
|
||
# -> https://raw.githubusercontent.com/milvus-io/milvus-design-docs/main/design_docs/xxx.md
|
||
raw_url = url.replace("github.com", "raw.githubusercontent.com").replace(
|
||
"/blob/", "/"
|
||
)
|
||
|
||
req = urllib.request.Request(
|
||
raw_url,
|
||
headers={"User-Agent": "mgit/1.0"},
|
||
)
|
||
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=15) as response:
|
||
return response.read().decode("utf-8")
|
||
except urllib.error.HTTPError as e:
|
||
raise Exception(f"HTTP {e.code}: Could not fetch design doc")
|
||
except Exception as e:
|
||
raise Exception(f"Failed to fetch: {e}")
|
||
|
||
def _call_ai_for_validation(self, prompt: str) -> Dict[str, Any]:
|
||
"""Call AI for design doc validation"""
|
||
content = None
|
||
|
||
errors = []
|
||
|
||
# Try AI providers in order
|
||
if self.has_claude_cli:
|
||
try:
|
||
result = subprocess.run(
|
||
["claude", "-p", prompt], capture_output=True, text=True, timeout=90
|
||
)
|
||
if result.returncode == 0:
|
||
content = result.stdout.strip()
|
||
except Exception as e:
|
||
errors.append(f"Claude CLI: {e}")
|
||
|
||
if content is None and self.gemini_key:
|
||
try:
|
||
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={self.gemini_key}"
|
||
data = {
|
||
"contents": [{"parts": [{"text": prompt}]}],
|
||
"generationConfig": {"temperature": 0.3, "maxOutputTokens": 2048},
|
||
}
|
||
req = urllib.request.Request(
|
||
url,
|
||
data=json.dumps(data).encode("utf-8"),
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
with urllib.request.urlopen(req, timeout=60) as response:
|
||
result = json.loads(response.read().decode("utf-8"))
|
||
content = result["candidates"][0]["content"]["parts"][0]["text"]
|
||
except Exception as e:
|
||
errors.append(f"Gemini: {e}")
|
||
|
||
if content is None and self.anthropic_key:
|
||
try:
|
||
url = "https://api.anthropic.com/v1/messages"
|
||
data = {
|
||
"model": "claude-3-5-sonnet-20241022",
|
||
"max_tokens": 2048,
|
||
"messages": [{"role": "user", "content": prompt}],
|
||
}
|
||
req = urllib.request.Request(
|
||
url,
|
||
data=json.dumps(data).encode("utf-8"),
|
||
headers={
|
||
"x-api-key": self.anthropic_key,
|
||
"anthropic-version": "2023-06-01",
|
||
"content-type": "application/json",
|
||
},
|
||
)
|
||
with urllib.request.urlopen(req, timeout=60) as response:
|
||
result = json.loads(response.read().decode("utf-8"))
|
||
content = result["content"][0]["text"]
|
||
except Exception as e:
|
||
errors.append(f"Anthropic: {e}")
|
||
|
||
if content is None and self.openai_key:
|
||
try:
|
||
url = "https://api.openai.com/v1/chat/completions"
|
||
data = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": prompt}],
|
||
"temperature": 0.3,
|
||
}
|
||
req = urllib.request.Request(
|
||
url,
|
||
data=json.dumps(data).encode("utf-8"),
|
||
headers={
|
||
"Authorization": f"Bearer {self.openai_key}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
)
|
||
with urllib.request.urlopen(req, timeout=60) as response:
|
||
result = json.loads(response.read().decode("utf-8"))
|
||
content = result["choices"][0]["message"]["content"]
|
||
except Exception as e:
|
||
errors.append(f"OpenAI: {e}")
|
||
|
||
if content is None:
|
||
error_details = "; ".join(errors) if errors else "No providers configured"
|
||
raise Exception(f"All AI providers failed: {error_details}")
|
||
|
||
# Parse response
|
||
content = self._extract_json(content)
|
||
try:
|
||
data = json.loads(content)
|
||
# Ensure required fields
|
||
return {
|
||
"valid": data.get("valid", False),
|
||
"score": data.get("score", 0),
|
||
"summary": data.get("summary", ""),
|
||
"concerns": data.get("concerns", []),
|
||
"suggestions": data.get("suggestions", []),
|
||
}
|
||
except json.JSONDecodeError:
|
||
raise Exception(f"Failed to parse validation response: {content[:200]}")
|
||
|
||
|
||
# ============================================================================
|
||
# GitHub Module - GitHub CLI operations
|
||
# ============================================================================
|
||
|
||
|
||
class GitHubOperations:
|
||
"""Handles GitHub CLI operations"""
|
||
|
||
@staticmethod
|
||
def check_gh_cli():
|
||
"""Check if gh CLI is installed and authenticated"""
|
||
try:
|
||
subprocess.run(["gh", "--version"], check=True, capture_output=True)
|
||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||
raise Exception(
|
||
"GitHub CLI (gh) is not installed. Install from: https://cli.github.com/"
|
||
)
|
||
|
||
# Check authentication
|
||
try:
|
||
subprocess.run(["gh", "auth", "status"], check=True, capture_output=True)
|
||
except subprocess.CalledProcessError:
|
||
raise Exception("GitHub CLI not authenticated. Run: gh auth login")
|
||
|
||
@staticmethod
|
||
def create_issue(title: str, body: str, issue_type: str) -> str:
|
||
"""
|
||
Create issue in milvus-io/milvus repo
|
||
Returns: issue number
|
||
"""
|
||
label = f"kind/{issue_type}"
|
||
|
||
cmd = [
|
||
"gh",
|
||
"issue",
|
||
"create",
|
||
"--repo",
|
||
"milvus-io/milvus",
|
||
"--title",
|
||
title,
|
||
"--body",
|
||
body,
|
||
"--label",
|
||
label,
|
||
]
|
||
|
||
output = GitOperations.run_command(cmd)
|
||
# Extract issue number from URL
|
||
issue_number = output.split("/")[-1]
|
||
return issue_number
|
||
|
||
@staticmethod
|
||
def create_pr(title: str, body: str, branch: str, issue_type: str) -> str:
|
||
"""
|
||
Create PR from fork to milvus-io/milvus
|
||
Returns: PR URL
|
||
"""
|
||
# Get fork owner from remote URL (more reliable than gh api user)
|
||
_, fork_owner = GitOperations.get_fork_info()
|
||
|
||
label = f"kind/{issue_type}"
|
||
|
||
cmd = [
|
||
"gh",
|
||
"pr",
|
||
"create",
|
||
"--repo",
|
||
"milvus-io/milvus",
|
||
"--head",
|
||
f"{fork_owner}:{branch}",
|
||
"--base",
|
||
"master",
|
||
"--title",
|
||
title,
|
||
"--body",
|
||
body,
|
||
"--label",
|
||
label,
|
||
]
|
||
|
||
return GitOperations.run_command(cmd)
|
||
|
||
@staticmethod
|
||
def add_cherry_pick_comment(pr_url: str, target_branch: str):
|
||
"""Add cherry-pick comment to PR"""
|
||
cmd = ["gh", "pr", "comment", pr_url, "--body", f"/cherry-pick {target_branch}"]
|
||
GitOperations.run_command(cmd)
|
||
|
||
@staticmethod
|
||
def get_existing_pr_for_branch(branch: str) -> Optional[Dict]:
|
||
"""
|
||
Check if a PR already exists for the given branch.
|
||
|
||
Returns: Dict with PR info (url, body, number) or None if no PR exists
|
||
"""
|
||
try:
|
||
_, fork_owner = GitOperations.get_fork_info()
|
||
cmd = [
|
||
"gh",
|
||
"pr",
|
||
"view",
|
||
"--repo",
|
||
"milvus-io/milvus",
|
||
"--head",
|
||
f"{fork_owner}:{branch}",
|
||
"--json",
|
||
"url,body,number,labels",
|
||
]
|
||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||
if result.returncode == 0 and result.stdout.strip():
|
||
return json.loads(result.stdout)
|
||
return None
|
||
except Exception:
|
||
return None
|
||
|
||
@staticmethod
|
||
def get_release_branches() -> List[str]:
|
||
"""Get list of release branches (2.x)"""
|
||
try:
|
||
output = GitOperations.run_command(["git", "branch", "-r"], check=False)
|
||
branches = []
|
||
for line in output.splitlines():
|
||
branch = line.strip().replace("origin/", "")
|
||
if branch.startswith("2."):
|
||
branches.append(branch)
|
||
return sorted(branches, reverse=True)
|
||
except Exception:
|
||
return []
|
||
|
||
@staticmethod
|
||
def search_merged_prs(query: str, limit: int = 20, days: int = 60) -> List[Dict]:
|
||
"""
|
||
Search for merged PRs in milvus-io/milvus master branch
|
||
|
||
Args:
|
||
query: Search keyword or issue number (e.g., "TTL" or "#46716")
|
||
limit: Maximum number of results
|
||
days: Search PRs merged within the last N days (default 60)
|
||
|
||
Returns: List of PR info dicts sorted by relevance
|
||
"""
|
||
try:
|
||
# Check if query is an issue number (must be purely numeric, optionally with #)
|
||
issue_match = re.match(r"^#?(\d+)$", query.strip())
|
||
|
||
if issue_match:
|
||
issue_num = issue_match.group(1)
|
||
# Search for PRs that reference this issue
|
||
cmd = [
|
||
"gh",
|
||
"pr",
|
||
"list",
|
||
"--repo",
|
||
"milvus-io/milvus",
|
||
"--base",
|
||
"master",
|
||
"--state",
|
||
"merged",
|
||
"--search",
|
||
f"issue: #{issue_num}",
|
||
"--limit",
|
||
str(limit),
|
||
"--json",
|
||
"number,title,body,mergeCommit,mergedAt,headRefName",
|
||
]
|
||
output = GitOperations.run_command(cmd)
|
||
if not output:
|
||
return []
|
||
return json.loads(output)
|
||
else:
|
||
# Keyword search using gh pr list with --search flag
|
||
# GitHub search is case-insensitive by default
|
||
from datetime import datetime, timedelta
|
||
|
||
since_date = (datetime.now() - timedelta(days=days)).strftime(
|
||
"%Y-%m-%d"
|
||
)
|
||
|
||
# Build search query with date filter
|
||
search_query = f"{query} merged:>={since_date}"
|
||
|
||
cmd = [
|
||
"gh",
|
||
"pr",
|
||
"list",
|
||
"--repo",
|
||
"milvus-io/milvus",
|
||
"--base",
|
||
"master",
|
||
"--state",
|
||
"merged",
|
||
"--search",
|
||
search_query,
|
||
"--limit",
|
||
str(limit * 2), # Get more results for ranking
|
||
"--json",
|
||
"number,title,body,mergeCommit,mergedAt,headRefName",
|
||
]
|
||
|
||
output = GitOperations.run_command(cmd)
|
||
if not output:
|
||
return []
|
||
|
||
prs = json.loads(output)
|
||
|
||
# Rank results by relevance (title match > body match)
|
||
query_lower = query.strip().lower()
|
||
keywords = query_lower.split()
|
||
|
||
def relevance_score(pr: Dict) -> int:
|
||
"""Calculate relevance score: higher = better match"""
|
||
title = (pr.get("title") or "").lower()
|
||
body = (pr.get("body") or "").lower()
|
||
score = 0
|
||
|
||
for kw in keywords:
|
||
# Exact keyword in title: +10 per match
|
||
if kw in title:
|
||
score += 10
|
||
# Partial keyword in title (e.g., 'mac' in 'macos'): +5
|
||
elif any(kw in word or word in kw for word in title.split()):
|
||
score += 5
|
||
# Keyword in body: +2
|
||
if kw in body:
|
||
score += 2
|
||
|
||
return score
|
||
|
||
# Sort by relevance (highest first), then by PR number (newest first)
|
||
prs_with_score = [(pr, relevance_score(pr)) for pr in prs]
|
||
prs_with_score.sort(key=lambda x: (-x[1], -x[0].get("number", 0)))
|
||
|
||
# Return all results sorted by relevance, let user choose
|
||
# (removed score > 0 filter to allow partial matches like 'mac' finding 'mac14')
|
||
sorted_prs = [pr for pr, score in prs_with_score]
|
||
|
||
return sorted_prs[:limit]
|
||
|
||
except Exception as e:
|
||
print_warning(f"Search failed: {e}")
|
||
return []
|
||
|
||
@staticmethod
|
||
def get_pr_details(pr_number: int) -> Optional[Dict]:
|
||
"""Get detailed PR information"""
|
||
try:
|
||
cmd = [
|
||
"gh",
|
||
"pr",
|
||
"view",
|
||
str(pr_number),
|
||
"--repo",
|
||
"milvus-io/milvus",
|
||
"--json",
|
||
"number,title,body,mergeCommit,mergedAt,headRefName,baseRefName",
|
||
]
|
||
output = GitOperations.run_command(cmd)
|
||
return json.loads(output) if output else None
|
||
except Exception as e:
|
||
print_warning(f"Failed to get PR details: {e}")
|
||
return None
|
||
|
||
@staticmethod
|
||
def search_issues(query: str, limit: int = 100, days: int = 30) -> List[Dict]:
|
||
"""
|
||
Search GitHub issues by keyword using gh CLI
|
||
|
||
Args:
|
||
query: Search keyword
|
||
limit: Maximum number of results
|
||
days: Search issues created within the last N days
|
||
|
||
Returns: List of issue info dicts
|
||
"""
|
||
try:
|
||
from datetime import datetime, timedelta
|
||
|
||
since_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||
|
||
# Use gh search issues for better search capability
|
||
cmd = [
|
||
"gh",
|
||
"search",
|
||
"issues",
|
||
"--repo",
|
||
"milvus-io/milvus",
|
||
"--limit",
|
||
str(limit),
|
||
"--json",
|
||
"number,title,state,createdAt,url",
|
||
"--",
|
||
f"{query} created:>={since_date}",
|
||
]
|
||
|
||
output = GitOperations.run_command(cmd, check=False)
|
||
if not output:
|
||
return []
|
||
return json.loads(output)
|
||
except Exception as e:
|
||
print_warning(f"Issue search failed: {e}")
|
||
return []
|
||
|
||
@staticmethod
|
||
def search_prs(
|
||
query: str, limit: int = 100, days: int = 30, state: str = "all"
|
||
) -> List[Dict]:
|
||
"""
|
||
Search GitHub PRs by keyword using gh CLI
|
||
|
||
Args:
|
||
query: Search keyword
|
||
limit: Maximum number of results
|
||
days: Search PRs created within the last N days
|
||
state: PR state filter ('all', 'open', 'closed', 'merged')
|
||
|
||
Returns: List of PR info dicts
|
||
"""
|
||
try:
|
||
from datetime import datetime, timedelta
|
||
|
||
since_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||
|
||
# Use gh search prs for better search capability
|
||
cmd = [
|
||
"gh",
|
||
"search",
|
||
"prs",
|
||
"--repo",
|
||
"milvus-io/milvus",
|
||
"--limit",
|
||
str(limit),
|
||
"--json",
|
||
"number,title,state,createdAt,url",
|
||
]
|
||
|
||
# Add state filter if not 'all'
|
||
if state != "all":
|
||
cmd.extend(["--state", state])
|
||
|
||
cmd.extend(["--", f"{query} created:>={since_date}"])
|
||
|
||
output = GitOperations.run_command(cmd, check=False)
|
||
if not output:
|
||
return []
|
||
return json.loads(output)
|
||
except Exception as e:
|
||
print_warning(f"PR search failed: {e}")
|
||
return []
|
||
|
||
@staticmethod
|
||
def extract_related_issue(pr_body: str) -> Optional[str]:
|
||
"""Extract related issue number from PR body"""
|
||
if not pr_body:
|
||
return None
|
||
|
||
# Match #123 format (GitHub issue/PR reference)
|
||
# Also match full GitHub URL: https://github.com/.../issues/123
|
||
patterns = [
|
||
r"#(\d+)",
|
||
r"github\.com/[^/]+/[^/]+/issues/(\d+)",
|
||
]
|
||
|
||
for pattern in patterns:
|
||
match = re.search(pattern, pr_body)
|
||
if match:
|
||
return match.group(1)
|
||
|
||
return None
|
||
|
||
@staticmethod
|
||
def create_cherry_pick_pr(
|
||
title: str, body: str, branch: str, target_branch: str
|
||
) -> str:
|
||
"""
|
||
Create cherry-pick PR from fork to milvus-io/milvus release branch
|
||
|
||
Args:
|
||
title: PR title
|
||
body: PR body
|
||
branch: Source branch name (e.g., cp26/46997)
|
||
target_branch: Target branch (e.g., 2.6)
|
||
|
||
Returns: PR URL
|
||
"""
|
||
_, fork_owner = GitOperations.get_fork_info()
|
||
|
||
# Milvus uses plain version numbers as branch names (e.g., "2.6", not "branch-2.6")
|
||
# Strip any "branch-" prefix if present
|
||
if target_branch.startswith("branch-"):
|
||
base = target_branch.replace("branch-", "")
|
||
else:
|
||
base = target_branch
|
||
|
||
cmd = [
|
||
"gh",
|
||
"pr",
|
||
"create",
|
||
"--repo",
|
||
"milvus-io/milvus",
|
||
"--head",
|
||
f"{fork_owner}:{branch}",
|
||
"--base",
|
||
base,
|
||
"--title",
|
||
title,
|
||
"--body",
|
||
body,
|
||
]
|
||
|
||
return GitOperations.run_command(cmd)
|
||
|
||
@staticmethod
|
||
def get_milestones(version_prefix: str = None) -> List[Dict]:
|
||
"""
|
||
Get milestones from milvus-io/milvus repo, optionally filtered by version prefix.
|
||
|
||
Args:
|
||
version_prefix: Version prefix to filter (e.g., "2.6" will match "2.6.9", "2.6.10")
|
||
|
||
Returns: List of milestone dicts with 'number' and 'title'
|
||
"""
|
||
try:
|
||
output = GitOperations.run_command(
|
||
[
|
||
"gh",
|
||
"api",
|
||
"repos/milvus-io/milvus/milestones",
|
||
"--jq",
|
||
"[.[] | {number: .number, title: .title}]",
|
||
]
|
||
)
|
||
|
||
milestones = json.loads(output) if output.strip() else []
|
||
|
||
# Filter by version prefix if provided
|
||
if version_prefix:
|
||
filtered = []
|
||
for m in milestones:
|
||
title = m.get("title", "")
|
||
# Match milestones that start with the version prefix
|
||
# e.g., "2.6" matches "2.6.9", "2.6.10", etc.
|
||
if title.startswith(version_prefix):
|
||
filtered.append(m)
|
||
return sorted(filtered, key=lambda x: x.get("title", ""))
|
||
|
||
return sorted(milestones, key=lambda x: x.get("title", ""))
|
||
|
||
except Exception as e:
|
||
print_warning(f"Failed to fetch milestones: {e}")
|
||
return []
|
||
|
||
@staticmethod
|
||
def set_pr_milestone(pr_number: str, milestone_number: int):
|
||
"""
|
||
Set milestone on a PR.
|
||
|
||
Args:
|
||
pr_number: PR number (extracted from URL or direct number)
|
||
milestone_number: Milestone number from GitHub
|
||
"""
|
||
# Extract PR number if URL is provided
|
||
if "/" in str(pr_number):
|
||
pr_number = pr_number.rstrip("/").split("/")[-1]
|
||
|
||
cmd = [
|
||
"gh",
|
||
"pr",
|
||
"edit",
|
||
pr_number,
|
||
"--repo",
|
||
"milvus-io/milvus",
|
||
"--milestone",
|
||
str(milestone_number),
|
||
]
|
||
GitOperations.run_command(cmd)
|
||
|
||
|
||
# ============================================================================
|
||
# Interaction Module - User prompts and input
|
||
# ============================================================================
|
||
|
||
|
||
class UserInteraction:
|
||
"""Handles user interaction and prompts"""
|
||
|
||
@staticmethod
|
||
def confirm(prompt: str, default: bool = False) -> bool:
|
||
"""Ask yes/no question"""
|
||
suffix = " [Y/n]: " if default else " [y/N]: "
|
||
response = input(prompt + suffix).strip().lower()
|
||
|
||
if not response:
|
||
return default
|
||
return response in ["y", "yes"]
|
||
|
||
@staticmethod
|
||
def prompt(question: str) -> str:
|
||
"""Ask for single-line input"""
|
||
return input(question + " ").strip()
|
||
|
||
@staticmethod
|
||
def prompt_multiline(prompt: str) -> str:
|
||
"""Ask for multi-line input"""
|
||
print(prompt)
|
||
print("(Enter empty line to finish)")
|
||
lines = []
|
||
while True:
|
||
line = input()
|
||
if not line:
|
||
break
|
||
lines.append(line)
|
||
return "\n".join(lines)
|
||
|
||
@staticmethod
|
||
def choose_files(files: List[str]) -> List[str]:
|
||
"""Interactive file selection"""
|
||
print("\nFiles to stage:")
|
||
for i, f in enumerate(files):
|
||
print(f" {i}: {f}")
|
||
|
||
print("\nEnter numbers (comma-separated) or 'all':")
|
||
choice = input("> ").strip()
|
||
|
||
if choice == "all":
|
||
return files
|
||
|
||
try:
|
||
indices = [int(x.strip()) for x in choice.split(",")]
|
||
return [files[i] for i in indices if 0 <= i < len(files)]
|
||
except Exception:
|
||
print_error("Invalid selection")
|
||
return []
|
||
|
||
@staticmethod
|
||
def select_option(prompt: str, options: List[Tuple[str, str]]) -> str:
|
||
"""
|
||
Present multiple choice options
|
||
|
||
Args:
|
||
prompt: Question to ask
|
||
options: List of (key, description) tuples
|
||
|
||
Returns: Selected key
|
||
"""
|
||
print(f"\n{prompt}")
|
||
for key, desc in options:
|
||
print(f" [{key}] {desc}")
|
||
|
||
while True:
|
||
choice = input("\nChoice: ").strip().lower()
|
||
valid_keys = [k for k, _ in options]
|
||
if choice in valid_keys:
|
||
return choice
|
||
print_error(f"Invalid choice. Please select from: {', '.join(valid_keys)}")
|
||
|
||
|
||
# ============================================================================
|
||
# Workflow Functions
|
||
# ============================================================================
|
||
|
||
|
||
def generate_branch_name(branch_type: str, description: str) -> str:
|
||
"""Generate a branch name from type and description"""
|
||
# Clean up description: remove special chars, convert to lowercase, limit length
|
||
clean_desc = re.sub(r"[^a-zA-Z0-9\s-]", "", description)
|
||
clean_desc = re.sub(r"\s+", "-", clean_desc.strip().lower())
|
||
clean_desc = clean_desc[:30] # Limit length
|
||
|
||
# Add timestamp suffix to ensure uniqueness
|
||
timestamp = int(time.time()) % 10000
|
||
|
||
return f"{branch_type}/{clean_desc}-{timestamp}"
|
||
|
||
|
||
def ensure_feature_branch():
|
||
"""Ensure we're not on master branch, create new branch if needed"""
|
||
current_branch = GitOperations.get_current_branch()
|
||
|
||
if current_branch != "master":
|
||
print_success(f"Working on branch: {current_branch}")
|
||
return current_branch
|
||
|
||
# On master branch - need to create a new branch
|
||
print_warning("You are on the 'master' branch!")
|
||
print_info("Creating a new feature branch is required.")
|
||
|
||
choice = UserInteraction.select_option(
|
||
"Choose branch type:",
|
||
[
|
||
("fix", "Bug fix"),
|
||
("feat", "New feature"),
|
||
("enhance", "Enhancement"),
|
||
("refactor", "Refactoring"),
|
||
("test", "Test changes"),
|
||
("docs", "Documentation"),
|
||
("chore", "Chore/maintenance"),
|
||
],
|
||
)
|
||
|
||
description = UserInteraction.prompt("Brief description (will be sanitized):")
|
||
if not description:
|
||
description = "update"
|
||
|
||
branch_name = generate_branch_name(choice, description)
|
||
|
||
# Check if branch already exists
|
||
if GitOperations.branch_exists(branch_name):
|
||
print_warning(f"Branch {branch_name} already exists")
|
||
branch_name = generate_branch_name(choice, description + "-alt")
|
||
|
||
print_info(f"Creating branch: {branch_name}")
|
||
try:
|
||
GitOperations.create_branch(branch_name)
|
||
print_success(f"Created and switched to branch: {branch_name}")
|
||
return branch_name
|
||
except Exception as e:
|
||
print_error(f"Failed to create branch: {e}")
|
||
sys.exit(1)
|
||
|
||
|
||
def workflow_rebase():
|
||
"""Rebase and squash workflow"""
|
||
print_header("🔄 Rebase & Squash Workflow")
|
||
|
||
# 1. Check current branch
|
||
branch = GitOperations.get_current_branch()
|
||
if branch == "master":
|
||
print_error("Cannot rebase on master branch")
|
||
return False
|
||
|
||
print_success(f"Current branch: {branch}")
|
||
|
||
# 2. Check for uncommitted changes
|
||
staged, unstaged = GitOperations.get_status()
|
||
if staged or unstaged:
|
||
print_error("You have uncommitted changes. Please commit or stash them first.")
|
||
return False
|
||
|
||
# 3. Check if rebase is already in progress
|
||
if GitOperations.is_rebase_in_progress():
|
||
print_warning("A rebase is already in progress!")
|
||
choice = UserInteraction.select_option(
|
||
"What do you want to do?",
|
||
[
|
||
("c", "Continue rebase (after resolving conflicts)"),
|
||
("a", "Abort rebase"),
|
||
],
|
||
)
|
||
if choice == "a":
|
||
GitOperations.rebase_abort()
|
||
print_success("Rebase aborted")
|
||
return False
|
||
else:
|
||
print_info("Please resolve conflicts and run mgit --rebase again")
|
||
return False
|
||
|
||
# 4. Fetch latest master from upstream (milvus-io/milvus)
|
||
upstream_remote = GitOperations.get_upstream_remote()
|
||
upstream_master = GitOperations.get_upstream_master()
|
||
print_info(f"Fetching latest master from {upstream_remote}...")
|
||
try:
|
||
GitOperations.fetch(upstream_remote, "master")
|
||
print_success(f"Fetched {upstream_master}")
|
||
except Exception as e:
|
||
print_error(f"Failed to fetch: {e}")
|
||
return False
|
||
|
||
# 5. Check commit count
|
||
commit_count = GitOperations.get_commit_count_from_ref(upstream_master)
|
||
if commit_count == 0:
|
||
print_warning("No commits ahead of master. Nothing to do.")
|
||
return True
|
||
|
||
print_info(f"Found {commit_count} commit(s) ahead of master")
|
||
|
||
# Show existing commits
|
||
print("\nExisting commits:")
|
||
commit_log = GitOperations.run_command(
|
||
["git", "log", "--oneline", f"{upstream_master}..HEAD"]
|
||
)
|
||
for line in commit_log.splitlines():
|
||
print(f" {line}")
|
||
|
||
# 6. Rebase onto upstream master (milvus-io/milvus)
|
||
print_info(f"\nRebasing onto {upstream_master}...")
|
||
success, error = GitOperations.rebase(upstream_master)
|
||
|
||
if not success:
|
||
print_error("Rebase failed - conflicts detected!")
|
||
print_info("\nTo resolve:")
|
||
print_info(" 1. Fix conflicts in the listed files")
|
||
print_info(" 2. Run: git add <fixed-files>")
|
||
print_info(" 3. Run: git rebase --continue")
|
||
print_info(" 4. Run: mgit --rebase again")
|
||
print_info("\nOr run: git rebase --abort to cancel")
|
||
return False
|
||
|
||
print_success("Rebase completed successfully")
|
||
|
||
# 7. Check if squash is needed
|
||
new_commit_count = GitOperations.get_commit_count("master")
|
||
|
||
if new_commit_count <= 1:
|
||
print_info("Only one commit - no squash needed")
|
||
if new_commit_count == 1:
|
||
# Ask about force push
|
||
fork_remote, fork_owner = GitOperations.get_fork_info()
|
||
if UserInteraction.confirm(f"\nForce push to {fork_remote}?", default=True):
|
||
try:
|
||
GitOperations.push(branch, force=True, remote=fork_remote)
|
||
print_success(f"Force pushed to {fork_remote}/{branch}")
|
||
except Exception as e:
|
||
print_error(f"Push failed: {e}")
|
||
return True
|
||
|
||
# 8. Squash commits
|
||
print_header(f"\n📦 Squashing {new_commit_count} commits into one")
|
||
|
||
if not UserInteraction.confirm("Proceed with squash?", default=True):
|
||
print_warning("Squash cancelled")
|
||
return True
|
||
|
||
# Get all changes for AI analysis
|
||
print_info("Analyzing all changes...")
|
||
diff = GitOperations.get_all_changes_diff(upstream_master)
|
||
stats = GitOperations.get_all_changes_stat(upstream_master)
|
||
|
||
# Get list of changed files
|
||
changed_files_output = GitOperations.run_command(
|
||
["git", "diff", "--name-only", upstream_master]
|
||
)
|
||
changed_files = [f for f in changed_files_output.splitlines() if f]
|
||
|
||
print(f"\n{Colors.BLUE}Changes to be squashed:{Colors.RESET}")
|
||
print(stats)
|
||
|
||
# 9. Generate new commit message with AI
|
||
ai_service = AIService()
|
||
full_message = None
|
||
|
||
if not ai_service.has_api_key:
|
||
print_warning("No AI API key found.")
|
||
full_message = UserInteraction.prompt_multiline(
|
||
"Enter commit message for squashed commit:"
|
||
)
|
||
else:
|
||
print_info("\nGenerating commit message with AI...")
|
||
try:
|
||
msg_data = ai_service.generate_commit_message(diff, changed_files, stats)
|
||
full_message = f"{msg_data['type']}: {msg_data['title']}"
|
||
if msg_data["body"]:
|
||
full_message += f"\n\n{msg_data['body']}"
|
||
except Exception as e:
|
||
print_error(f"AI generation failed: {e}")
|
||
full_message = UserInteraction.prompt_multiline(
|
||
"Enter commit message manually:"
|
||
)
|
||
|
||
# 10. Show and confirm message
|
||
print(f"\n{Colors.BOLD}{'=' * 60}{Colors.RESET}")
|
||
print(f"{Colors.BOLD}Squashed Commit Message:{Colors.RESET}\n")
|
||
print(full_message)
|
||
print(f"\n{Colors.BOLD}{'=' * 60}{Colors.RESET}")
|
||
|
||
choice = UserInteraction.select_option(
|
||
"Options:",
|
||
[
|
||
("y", "Accept and squash"),
|
||
("e", "Edit message"),
|
||
("r", "Regenerate with AI"),
|
||
("m", "Enter manually"),
|
||
("n", "Cancel (keep multiple commits)"),
|
||
],
|
||
)
|
||
|
||
if choice == "n":
|
||
print_warning("Squash cancelled - keeping multiple commits")
|
||
return True
|
||
elif choice == "e":
|
||
temp_file = create_local_temp_file(full_message, ".txt")
|
||
editor = os.getenv("EDITOR", "vi")
|
||
subprocess.run([editor, temp_file])
|
||
with open(temp_file, "r") as f:
|
||
full_message = f.read().strip()
|
||
remove_local_temp_file(temp_file)
|
||
elif choice == "r":
|
||
return workflow_rebase() # Restart
|
||
elif choice == "m":
|
||
full_message = UserInteraction.prompt_multiline("Enter commit message:")
|
||
|
||
# 11. Perform squash (soft reset + recommit)
|
||
print_info("Squashing commits...")
|
||
try:
|
||
GitOperations.reset_soft(upstream_master)
|
||
GitOperations.commit(full_message)
|
||
commit_hash = GitOperations.get_commit_hash()
|
||
print_success(f"Squashed into single commit: {commit_hash}")
|
||
except Exception as e:
|
||
print_error(f"Squash failed: {e}")
|
||
return False
|
||
|
||
# 12. Force push
|
||
fork_remote, fork_owner = GitOperations.get_fork_info()
|
||
if UserInteraction.confirm(f"\nForce push to {fork_remote}?", default=True):
|
||
try:
|
||
GitOperations.push(branch, force=True, remote=fork_remote)
|
||
print_success(f"Force pushed to {fork_remote}/{branch}")
|
||
except Exception as e:
|
||
print_error(f"Push failed: {e}")
|
||
|
||
print_success("\n✅ Rebase and squash completed!")
|
||
return True
|
||
|
||
|
||
def workflow_commit():
|
||
"""Smart commit workflow"""
|
||
print_header("📝 Smart Commit Workflow")
|
||
|
||
# 0. Ensure we're on a feature branch (not master)
|
||
ensure_feature_branch()
|
||
|
||
# 1. Check git status
|
||
print_info("Checking git status...")
|
||
staged, unstaged = GitOperations.get_status()
|
||
|
||
if not staged and not unstaged:
|
||
print_warning("No changes to commit")
|
||
return None
|
||
|
||
# 2. Handle unstaged files
|
||
if unstaged:
|
||
print(f"\n{Colors.YELLOW}Unstaged files ({len(unstaged)}):{Colors.RESET}")
|
||
for f in unstaged[:10]: # Show first 10
|
||
print(f" {f}")
|
||
if len(unstaged) > 10:
|
||
print(f" ... and {len(unstaged) - 10} more")
|
||
|
||
choice = UserInteraction.select_option(
|
||
"Stage files?",
|
||
[
|
||
("a", "Stage all files"),
|
||
("s", "Select files"),
|
||
("c", "Cancel (only commit staged files)"),
|
||
],
|
||
)
|
||
|
||
if choice == "a":
|
||
GitOperations.stage_all()
|
||
print_success("All files staged")
|
||
elif choice == "s":
|
||
selected = UserInteraction.choose_files(unstaged)
|
||
if selected:
|
||
GitOperations.stage_files(selected)
|
||
print_success(f"Staged {len(selected)} file(s)")
|
||
else:
|
||
print_warning("No files selected for staging")
|
||
# choice == 'c': continue with only staged files
|
||
|
||
# Re-check staged files
|
||
staged, _ = GitOperations.get_status()
|
||
if not staged:
|
||
print_warning("No staged changes to commit")
|
||
return None
|
||
|
||
# 3. Run format tools (optional)
|
||
print_header("\n🛠️ Code Formatting")
|
||
|
||
# Detect which languages are being modified
|
||
staged_files = staged
|
||
has_go_files = any(f.endswith(".go") for f in staged_files)
|
||
# Include .c extension for C code in internal/core
|
||
has_cpp_files = any(
|
||
f.endswith((".cpp", ".cc", ".c", ".h", ".hpp")) for f in staged_files
|
||
)
|
||
has_python_files = any(f.endswith(".py") for f in staged_files)
|
||
has_shell_files = any(f.endswith(".sh") for f in staged_files)
|
||
|
||
# Get repository root directory
|
||
repo_root = GitOperations.run_command(["git", "rev-parse", "--show-toplevel"])
|
||
|
||
# Track if any formatting was performed
|
||
formatting_ran = False
|
||
|
||
# Go formatting
|
||
if has_go_files:
|
||
if UserInteraction.confirm("Run Go formatting (make fmt)?", default=True):
|
||
print_info("Running make fmt...")
|
||
formatting_ran = True
|
||
try:
|
||
result = subprocess.run(
|
||
["make", "fmt"],
|
||
cwd=repo_root,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=120,
|
||
)
|
||
if result.returncode == 0:
|
||
print_success("Go formatting completed")
|
||
else:
|
||
print_warning(
|
||
f"Go formatting completed with warnings:\n{result.stderr}"
|
||
)
|
||
except subprocess.TimeoutExpired:
|
||
print_error("Go formatting timed out (>120s)")
|
||
if not UserInteraction.confirm("Continue without Go formatting?"):
|
||
return None
|
||
except Exception as e:
|
||
print_warning(f"Go formatting failed: {e}")
|
||
if not UserInteraction.confirm("Continue without Go formatting?"):
|
||
return None
|
||
|
||
# C++ formatting
|
||
if has_cpp_files:
|
||
if UserInteraction.confirm("Run C++ formatting (clang-format)?", default=True):
|
||
print_info("Running C++ formatting...")
|
||
formatting_ran = True
|
||
try:
|
||
# Run clang-format via the existing script
|
||
cpp_core_dir = os.path.join(repo_root, "internal", "core")
|
||
result = subprocess.run(
|
||
["./run_clang_format.sh", "."],
|
||
cwd=cpp_core_dir,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=120,
|
||
)
|
||
if result.returncode == 0:
|
||
print_success("C++ formatting completed")
|
||
else:
|
||
print_warning(
|
||
f"C++ formatting completed with warnings:\n{result.stderr}"
|
||
)
|
||
except subprocess.TimeoutExpired:
|
||
print_error("C++ formatting timed out (>120s)")
|
||
if not UserInteraction.confirm("Continue without C++ formatting?"):
|
||
return None
|
||
except FileNotFoundError:
|
||
print_warning(
|
||
"C++ formatting script not found, trying clang-format directly..."
|
||
)
|
||
try:
|
||
# Fallback: run clang-format directly on staged C++ files
|
||
cpp_files = [
|
||
f
|
||
for f in staged_files
|
||
if f.endswith((".cpp", ".cc", ".c", ".h", ".hpp"))
|
||
]
|
||
for cpp_file in cpp_files:
|
||
full_path = os.path.join(repo_root, cpp_file)
|
||
if os.path.exists(full_path):
|
||
subprocess.run(
|
||
["clang-format", "-i", full_path],
|
||
capture_output=True,
|
||
timeout=30,
|
||
check=True,
|
||
)
|
||
print_success("C++ formatting completed (direct)")
|
||
except FileNotFoundError:
|
||
print_warning(
|
||
"clang-format is not installed. Install with: brew install clang-format"
|
||
)
|
||
if not UserInteraction.confirm("Continue without C++ formatting?"):
|
||
return None
|
||
except subprocess.CalledProcessError as e:
|
||
print_warning(f"clang-format failed on a file: {e}")
|
||
if not UserInteraction.confirm("Continue without C++ formatting?"):
|
||
return None
|
||
except Exception as e:
|
||
print_warning(f"C++ formatting failed: {e}")
|
||
if not UserInteraction.confirm("Continue without C++ formatting?"):
|
||
return None
|
||
except Exception as e:
|
||
print_warning(f"C++ formatting failed: {e}")
|
||
if not UserInteraction.confirm("Continue without C++ formatting?"):
|
||
return None
|
||
|
||
# Python formatting with ruff
|
||
if has_python_files:
|
||
if UserInteraction.confirm("Run Python formatting (ruff)?", default=True):
|
||
print_info("Running Python formatting...")
|
||
formatting_ran = True
|
||
py_files = [f for f in staged_files if f.endswith(".py")]
|
||
try:
|
||
# First run ruff format
|
||
for py_file in py_files:
|
||
full_path = os.path.join(repo_root, py_file)
|
||
if os.path.exists(full_path):
|
||
subprocess.run(
|
||
["ruff", "format", full_path],
|
||
capture_output=True,
|
||
timeout=30,
|
||
check=True,
|
||
)
|
||
# Then run ruff check with auto-fix
|
||
for py_file in py_files:
|
||
full_path = os.path.join(repo_root, py_file)
|
||
if os.path.exists(full_path):
|
||
subprocess.run(
|
||
["ruff", "check", "--fix", full_path],
|
||
capture_output=True,
|
||
timeout=30,
|
||
check=False, # ruff check may return non-zero for unfixable issues
|
||
)
|
||
print_success("Python formatting completed")
|
||
except FileNotFoundError:
|
||
print_warning("ruff is not installed. Install with: pip install ruff")
|
||
if not UserInteraction.confirm("Continue without Python formatting?"):
|
||
return None
|
||
except subprocess.TimeoutExpired:
|
||
print_error("Python formatting timed out")
|
||
if not UserInteraction.confirm("Continue without Python formatting?"):
|
||
return None
|
||
except Exception as e:
|
||
print_warning(f"Python formatting failed: {e}")
|
||
if not UserInteraction.confirm("Continue without Python formatting?"):
|
||
return None
|
||
|
||
# Shell script linting with shellcheck
|
||
if has_shell_files:
|
||
if UserInteraction.confirm("Run Shell linting (shellcheck)?", default=True):
|
||
print_info("Running shellcheck...")
|
||
sh_files = [f for f in staged_files if f.endswith(".sh")]
|
||
all_passed = True
|
||
issues_found = []
|
||
try:
|
||
for sh_file in sh_files:
|
||
full_path = os.path.join(repo_root, sh_file)
|
||
if os.path.exists(full_path):
|
||
result = subprocess.run(
|
||
["shellcheck", "-f", "gcc", full_path],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=30,
|
||
)
|
||
if result.returncode != 0:
|
||
all_passed = False
|
||
issues_found.append(f"{sh_file}:\n{result.stdout}")
|
||
if all_passed:
|
||
print_success("Shell linting passed")
|
||
else:
|
||
print_warning("Shellcheck found issues:")
|
||
for issue in issues_found[:3]: # Show first 3 files with issues
|
||
print(issue)
|
||
if len(issues_found) > 3:
|
||
print(f" ... and {len(issues_found) - 3} more file(s)")
|
||
if not UserInteraction.confirm(
|
||
"Continue with shellcheck warnings?", default=True
|
||
):
|
||
return None
|
||
except FileNotFoundError:
|
||
print_warning(
|
||
"shellcheck is not installed. Install with: brew install shellcheck"
|
||
)
|
||
if not UserInteraction.confirm("Continue without shell linting?"):
|
||
return None
|
||
except subprocess.TimeoutExpired:
|
||
print_error("Shell linting timed out")
|
||
if not UserInteraction.confirm("Continue without shell linting?"):
|
||
return None
|
||
except Exception as e:
|
||
print_warning(f"Shell linting failed: {e}")
|
||
if not UserInteraction.confirm("Continue without shell linting?"):
|
||
return None
|
||
|
||
# Check if formatting made changes and stage them (only if formatting was run)
|
||
if formatting_ran:
|
||
# Get files that were modified by formatting (intersection of staged files and unstaged changes)
|
||
_, unstaged_after_fmt = GitOperations.get_status()
|
||
# Only consider files that we originally staged (to avoid picking up unrelated changes)
|
||
formatting_changes = [f for f in unstaged_after_fmt if f in staged_files]
|
||
if formatting_changes:
|
||
print_warning(f"Formatting modified {len(formatting_changes)} file(s)")
|
||
if UserInteraction.confirm("Stage formatting changes?", default=True):
|
||
GitOperations.stage_files(formatting_changes)
|
||
print_success("Formatting changes staged")
|
||
|
||
# 4. Collect diff information
|
||
print_info("Analyzing changes...")
|
||
diff = GitOperations.get_staged_diff()
|
||
stats = GitOperations.get_staged_diff_stat()
|
||
|
||
print(f"\n{Colors.BLUE}Changes to be committed:{Colors.RESET}")
|
||
print(stats)
|
||
|
||
# 4. Generate commit message with AI
|
||
full_message = None
|
||
ai_service = AIService()
|
||
|
||
if not ai_service.has_api_key:
|
||
print_warning("No AI API key found.")
|
||
print_info("To enable AI-powered commit messages, set one of:")
|
||
print_info(" export GEMINI_API_KEY='your-key' (recommended)")
|
||
print_info(" export ANTHROPIC_API_KEY='your-key'")
|
||
print_info(" export OPENAI_API_KEY='your-key'")
|
||
print("")
|
||
full_message = UserInteraction.prompt_multiline(
|
||
"Enter commit message manually:"
|
||
)
|
||
else:
|
||
print_info("\nGenerating commit message with AI...")
|
||
try:
|
||
msg_data = ai_service.generate_commit_message(diff, staged, stats)
|
||
|
||
# Build full commit message
|
||
full_message = f"{msg_data['type']}: {msg_data['title']}"
|
||
if msg_data["body"]:
|
||
full_message += f"\n\n{msg_data['body']}"
|
||
|
||
except Exception as e:
|
||
print_error(f"AI generation failed: {e}")
|
||
print_info("Falling back to manual input")
|
||
full_message = UserInteraction.prompt_multiline("Enter commit message:")
|
||
|
||
# 5. Review and confirm
|
||
print(f"\n{Colors.BOLD}{'=' * 60}{Colors.RESET}")
|
||
message_title = (
|
||
"Commit Message:" if not ai_service.has_api_key else "Generated Commit Message:"
|
||
)
|
||
print(f"{Colors.BOLD}{message_title}{Colors.RESET}\n")
|
||
print(full_message)
|
||
print(f"\n{Colors.BOLD}{'=' * 60}{Colors.RESET}")
|
||
|
||
choice = UserInteraction.select_option(
|
||
"Options:",
|
||
[
|
||
("y", "Accept and commit"),
|
||
("e", "Edit message"),
|
||
("r", "Regenerate with AI"),
|
||
("m", "Enter manually"),
|
||
("n", "Cancel"),
|
||
],
|
||
)
|
||
|
||
if choice == "n":
|
||
print_warning("Commit cancelled")
|
||
return None
|
||
elif choice == "e":
|
||
print_info("Opening editor...")
|
||
# Create local temp file with message
|
||
temp_file = create_local_temp_file(full_message, ".txt")
|
||
|
||
# Open in editor
|
||
editor = os.getenv("EDITOR", "vi")
|
||
subprocess.run([editor, temp_file])
|
||
|
||
# Read edited message
|
||
with open(temp_file, "r") as f:
|
||
full_message = f.read().strip()
|
||
remove_local_temp_file(temp_file)
|
||
elif choice == "r":
|
||
print_info("Regenerating...")
|
||
return workflow_commit() # Recursive call
|
||
elif choice == "m":
|
||
full_message = UserInteraction.prompt_multiline("Enter commit message:")
|
||
|
||
# 6. Create commit
|
||
# Final verification that files are staged before attempting commit
|
||
staged_final, _ = GitOperations.get_status()
|
||
if not staged_final:
|
||
print_error("No files staged for commit. Please stage files first.")
|
||
if UserInteraction.confirm("Stage all changes now?", default=True):
|
||
GitOperations.stage_all()
|
||
print_success("All files staged")
|
||
else:
|
||
print_warning("Commit cancelled - no files staged")
|
||
return None
|
||
|
||
try:
|
||
GitOperations.commit(full_message)
|
||
commit_hash = GitOperations.get_commit_hash()
|
||
print_success(f"Commit created: {commit_hash}")
|
||
|
||
# 7. Optional code review
|
||
print_header("\n🔍 Code Review")
|
||
if UserInteraction.confirm("Run code review with Claude Code?", default=False):
|
||
# Check if claude CLI is available (cross-platform)
|
||
if shutil.which("claude") is None:
|
||
print_warning(
|
||
"Claude Code CLI not found. Install from: https://claude.com/code"
|
||
)
|
||
else:
|
||
try:
|
||
print_info("Running code review with Claude Code...")
|
||
|
||
# Get the commit patch for context
|
||
patch = GitOperations.run_command(["git", "show", "-1", "--patch"])
|
||
|
||
# Limit patch size to avoid token limits
|
||
max_patch_lines = 10000
|
||
patch_lines = patch.splitlines()
|
||
if len(patch_lines) > max_patch_lines:
|
||
print_warning(
|
||
f"Patch has {len(patch_lines)} lines (>{max_patch_lines}). Consider splitting into smaller PRs."
|
||
)
|
||
truncated_patch = "\n".join(patch_lines[:max_patch_lines])
|
||
truncated_patch += f"\n\n... (truncated {len(patch_lines) - max_patch_lines} lines)"
|
||
else:
|
||
truncated_patch = patch
|
||
|
||
# Build review prompt with the actual diff
|
||
review_prompt = f"""Review the following commit for potential issues, bugs, or improvements.
|
||
Focus on code quality, security, and best practices.
|
||
|
||
Commit diff:
|
||
{truncated_patch}
|
||
"""
|
||
|
||
result = subprocess.run(
|
||
["claude", "-p", review_prompt],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=120, # Increase timeout since we're sending more data
|
||
)
|
||
|
||
if result.returncode == 0:
|
||
print_success("Review completed")
|
||
print(f"\n{Colors.BLUE}Review Results:{Colors.RESET}")
|
||
print(result.stdout)
|
||
else:
|
||
print_warning(
|
||
f"Review completed with warnings:\n{result.stderr}"
|
||
)
|
||
|
||
except subprocess.TimeoutExpired:
|
||
print_error("Review timed out (>60s)")
|
||
except Exception as e:
|
||
print_warning(f"Review failed: {e}")
|
||
|
||
return commit_hash
|
||
except Exception as e:
|
||
print_error(f"Commit failed: {e}")
|
||
return None
|
||
|
||
|
||
def workflow_pr():
|
||
"""PR creation workflow"""
|
||
print_header("📤 PR Creation Workflow")
|
||
|
||
# 1. Pre-flight checks
|
||
print_info("Running pre-flight checks...")
|
||
|
||
try:
|
||
GitHubOperations.check_gh_cli()
|
||
except Exception as e:
|
||
print_error(str(e))
|
||
sys.exit(1)
|
||
|
||
branch = GitOperations.get_current_branch()
|
||
if branch == "master":
|
||
print_error("Cannot create PR from master branch")
|
||
sys.exit(1)
|
||
|
||
print_success(f"Current branch: {branch}")
|
||
|
||
# Check for commits
|
||
commit_count = GitOperations.get_commit_count()
|
||
if commit_count == 0:
|
||
print_error("No commits to push")
|
||
sys.exit(1)
|
||
|
||
if commit_count > 1:
|
||
upstream_master = GitOperations.get_upstream_master()
|
||
print_warning(f"You have {commit_count} commits on this branch")
|
||
print_info("Milvus typically requires a single squashed commit")
|
||
print_info(f"Consider running: git rebase -i {upstream_master}")
|
||
if not UserInteraction.confirm("Continue anyway?"):
|
||
sys.exit(1)
|
||
|
||
# Check DCO
|
||
last_msg = GitOperations.get_last_commit_message()
|
||
if "Signed-off-by:" not in last_msg:
|
||
print_error("Last commit missing DCO signature")
|
||
print_info("Run: git commit --amend -s")
|
||
if not UserInteraction.confirm("Continue without DCO?"):
|
||
sys.exit(1)
|
||
|
||
# 2. Push to fork
|
||
fork_remote, fork_owner = GitOperations.get_fork_info()
|
||
print_info(f"Pushing {branch} to {fork_remote} ({fork_owner})...")
|
||
try:
|
||
GitOperations.push(branch, remote=fork_remote)
|
||
print_success(f"Pushed to {fork_remote} ({fork_owner}/{branch})")
|
||
except Exception as e:
|
||
print_warning(f"Push failed: {e}")
|
||
if UserInteraction.confirm("Force push?"):
|
||
GitOperations.push(branch, force=True, remote=fork_remote)
|
||
print_success(f"Force pushed to {fork_remote}")
|
||
else:
|
||
sys.exit(1)
|
||
|
||
# 3. Handle issue
|
||
print_header("\n📋 GitHub Issue")
|
||
|
||
issue_number = None
|
||
issue_type = "feature"
|
||
choice = None
|
||
|
||
# Check if there's an existing PR with a linked issue
|
||
existing_pr = GitHubOperations.get_existing_pr_for_branch(branch)
|
||
if existing_pr:
|
||
pr_body = existing_pr.get("body", "")
|
||
linked_issue = GitHubOperations.extract_related_issue(pr_body)
|
||
if linked_issue:
|
||
issue_number = linked_issue
|
||
# Try to extract issue type from PR labels
|
||
labels = existing_pr.get("labels", [])
|
||
for label in labels:
|
||
label_name = label.get("name", "") if isinstance(label, dict) else label
|
||
if label_name.startswith("kind/"):
|
||
issue_type = label_name.replace("kind/", "")
|
||
break
|
||
print_success(
|
||
f"PR already linked to issue #{issue_number}, skipping issue creation"
|
||
)
|
||
|
||
if not issue_number:
|
||
print_warning("Milvus requires all PRs to reference an issue!")
|
||
|
||
choice = UserInteraction.select_option(
|
||
"Issue management:",
|
||
[
|
||
("c", "Create new issue"),
|
||
("e", "Use existing issue number"),
|
||
],
|
||
)
|
||
|
||
if not issue_number and choice == "c":
|
||
# Create new issue - select type first
|
||
issue_type = UserInteraction.select_option(
|
||
"Issue type:",
|
||
[
|
||
("bug", "[Bug] Bug fix"),
|
||
("feature", "[Feature] New feature"),
|
||
("enhancement", "[Enhancement] Enhancement"),
|
||
("benchmark", "[Benchmark] Performance/Benchmark"),
|
||
],
|
||
)
|
||
|
||
# Get diff for AI analysis (compare against upstream milvus-io/milvus)
|
||
upstream_master = GitOperations.get_upstream_master()
|
||
diff = GitOperations.get_all_changes_diff(upstream_master)
|
||
stats = GitOperations.get_all_changes_stat(upstream_master)
|
||
changed_files = GitOperations.run_command(
|
||
["git", "diff", "--name-only", upstream_master]
|
||
).splitlines()
|
||
|
||
# Generate issue content with AI
|
||
ai_service = AIService()
|
||
issue_title = None
|
||
issue_body = None
|
||
|
||
if ai_service.has_api_key:
|
||
print_info("Generating issue content with AI...")
|
||
try:
|
||
issue_data = ai_service.generate_issue_content(
|
||
diff, changed_files, stats, issue_type
|
||
)
|
||
issue_title = issue_data["title"]
|
||
issue_body = issue_data["body"]
|
||
except Exception as e:
|
||
print_warning(f"AI generation failed: {e}")
|
||
|
||
# Show and confirm issue content
|
||
if issue_title and issue_body:
|
||
print(f"\n{Colors.BOLD}{'=' * 60}{Colors.RESET}")
|
||
print(f"{Colors.BOLD}Generated Issue:{Colors.RESET}\n")
|
||
print(f"{Colors.BLUE}Title:{Colors.RESET} {issue_title}")
|
||
print(f"\n{Colors.BLUE}Description:{Colors.RESET}\n{issue_body}")
|
||
print(f"\n{Colors.BOLD}{'=' * 60}{Colors.RESET}")
|
||
|
||
issue_choice = UserInteraction.select_option(
|
||
"Options:",
|
||
[
|
||
("y", "Accept and create issue"),
|
||
("e", "Edit content"),
|
||
("r", "Regenerate with AI"),
|
||
("m", "Enter manually"),
|
||
],
|
||
)
|
||
|
||
if issue_choice == "e":
|
||
content = f"Title: {issue_title}\n\n---\n\n{issue_body}"
|
||
temp_file = create_local_temp_file(content, ".md")
|
||
editor = os.getenv("EDITOR", "vi")
|
||
subprocess.run([editor, temp_file])
|
||
with open(temp_file, "r") as f:
|
||
edited = f.read()
|
||
remove_local_temp_file(temp_file)
|
||
# Parse edited content
|
||
if "---" in edited:
|
||
parts = edited.split("---", 1)
|
||
issue_title = parts[0].replace("Title:", "").strip()
|
||
issue_body = parts[1].strip()
|
||
else:
|
||
lines = edited.strip().split("\n", 1)
|
||
issue_title = lines[0].replace("Title:", "").strip()
|
||
issue_body = lines[1].strip() if len(lines) > 1 else ""
|
||
elif issue_choice == "r":
|
||
# Regenerate - recursive call will handle it
|
||
print_info("Regenerating...")
|
||
try:
|
||
issue_data = ai_service.generate_issue_content(
|
||
diff, changed_files, stats, issue_type
|
||
)
|
||
issue_title = issue_data["title"]
|
||
issue_body = issue_data["body"]
|
||
except Exception as e:
|
||
print_error(f"Regeneration failed: {e}")
|
||
issue_title = UserInteraction.prompt("Issue title:")
|
||
issue_body = UserInteraction.prompt_multiline("Issue description:")
|
||
elif issue_choice == "m":
|
||
issue_title = UserInteraction.prompt("Issue title:")
|
||
issue_body = UserInteraction.prompt_multiline("Issue description:")
|
||
else:
|
||
# No AI available or failed - manual input
|
||
issue_title = UserInteraction.prompt("Issue title:")
|
||
issue_body = UserInteraction.prompt_multiline("Issue description:")
|
||
|
||
print_info("Creating issue in milvus-io/milvus...")
|
||
try:
|
||
issue_number = GitHubOperations.create_issue(
|
||
issue_title, issue_body, issue_type
|
||
)
|
||
print_success(f"Issue created: #{issue_number}")
|
||
except Exception as e:
|
||
print_error(f"Failed to create issue: {e}")
|
||
sys.exit(1)
|
||
|
||
elif not issue_number and choice == "e":
|
||
issue_number = UserInteraction.prompt("Enter issue number:")
|
||
issue_type = UserInteraction.select_option(
|
||
"Issue type:",
|
||
[
|
||
("bug", "[Bug] Bug fix"),
|
||
("feature", "[Feature] New feature"),
|
||
("enhancement", "[Enhancement] Enhancement"),
|
||
("benchmark", "[Benchmark] Performance/Benchmark"),
|
||
],
|
||
)
|
||
print_success(f"Using issue #{issue_number}")
|
||
|
||
# 4. Create PR
|
||
print_header("\n🔀 Pull Request")
|
||
|
||
# Get PR title from last commit
|
||
default_title = last_msg.split("\n")[0]
|
||
print(f"Default title: {default_title}")
|
||
|
||
if UserInteraction.confirm("Use this title?", default=True):
|
||
pr_title = default_title
|
||
else:
|
||
pr_title = UserInteraction.prompt("PR title:")
|
||
|
||
# Build PR body
|
||
pr_body = UserInteraction.prompt_multiline("PR description (optional):")
|
||
|
||
# Add required issue reference (Milvus requirement)
|
||
if not pr_body:
|
||
pr_body = ""
|
||
pr_body += f"\n\nissue: #{issue_number}"
|
||
|
||
# Require design doc for feature PRs
|
||
if issue_type == "feature":
|
||
print_header("\n📄 Design Document")
|
||
print_warning(
|
||
"Feature PRs require a design document in milvus-io/milvus-design-docs"
|
||
)
|
||
print_info("Design doc repo: https://github.com/milvus-io/milvus-design-docs")
|
||
|
||
# Get diff for validation
|
||
upstream_master = GitOperations.get_upstream_master()
|
||
diff = GitOperations.get_all_changes_diff(upstream_master)
|
||
stats = GitOperations.get_all_changes_stat(upstream_master)
|
||
changed_files = GitOperations.run_command(
|
||
["git", "diff", "--name-only", upstream_master]
|
||
).splitlines()
|
||
|
||
ai_service = AIService()
|
||
|
||
while True:
|
||
design_doc_url = UserInteraction.prompt(
|
||
"Design doc URL (enter 'skip' to skip, or 'cancel' to abort):"
|
||
)
|
||
|
||
# Allow skipping or canceling
|
||
if design_doc_url.lower() == "cancel":
|
||
print_error("PR creation cancelled")
|
||
sys.exit(1)
|
||
|
||
if design_doc_url.lower() == "skip":
|
||
print_warning("Skipping design doc validation")
|
||
break
|
||
|
||
# Validate design doc URL
|
||
if not design_doc_url:
|
||
print_error("Design doc URL is required for feature PRs (enter 'skip' to skip)")
|
||
continue
|
||
|
||
if "milvus-io/milvus-design-docs" not in design_doc_url:
|
||
print_error(
|
||
"Design doc must be in milvus-io/milvus-design-docs repository"
|
||
)
|
||
if not UserInteraction.confirm("Continue anyway?"):
|
||
continue
|
||
|
||
# Validate design doc matches code changes using AI
|
||
if ai_service.has_api_key:
|
||
print_info("Validating design doc matches code changes...")
|
||
validation = ai_service.validate_design_doc(
|
||
design_doc_url, diff, changed_files, stats
|
||
)
|
||
|
||
score = validation.get("score", -1)
|
||
if score >= 0:
|
||
# Show validation result
|
||
if score >= 70:
|
||
print_success(
|
||
f"Design doc validation passed (score: {score}/100)"
|
||
)
|
||
elif score >= 50:
|
||
print_warning(
|
||
f"Design doc validation: moderate match (score: {score}/100)"
|
||
)
|
||
else:
|
||
print_error(
|
||
f"Design doc validation: poor match (score: {score}/100)"
|
||
)
|
||
|
||
print(
|
||
f"\n{Colors.BOLD}Summary:{Colors.RESET} {validation.get('summary', 'N/A')}"
|
||
)
|
||
|
||
if validation.get("concerns"):
|
||
print(f"\n{Colors.YELLOW}Concerns:{Colors.RESET}")
|
||
for concern in validation["concerns"]:
|
||
print(f" - {concern}")
|
||
|
||
if validation.get("suggestions"):
|
||
print(f"\n{Colors.BLUE}Suggestions:{Colors.RESET}")
|
||
for suggestion in validation["suggestions"]:
|
||
print(f" - {suggestion}")
|
||
|
||
# If score is too low, ask for confirmation
|
||
if score < 50:
|
||
print("")
|
||
if not UserInteraction.confirm(
|
||
"Design doc may not match code changes. Continue anyway?"
|
||
):
|
||
continue
|
||
elif score < 70:
|
||
print("")
|
||
if not UserInteraction.confirm(
|
||
"Continue with this design doc?", default=True
|
||
):
|
||
continue
|
||
else:
|
||
print_info("AI not available, skipping design doc validation")
|
||
|
||
# Add design doc to PR body
|
||
pr_body = f"design doc: {design_doc_url}\n{pr_body}"
|
||
print_success(f"Design doc linked: {design_doc_url}")
|
||
break
|
||
|
||
print_info("Creating PR in milvus-io/milvus...")
|
||
try:
|
||
pr_url = GitHubOperations.create_pr(pr_title, pr_body, branch, issue_type)
|
||
print_success(f"PR created: {pr_url}")
|
||
except Exception as e:
|
||
print_error(f"Failed to create PR: {e}")
|
||
sys.exit(1)
|
||
|
||
# 5. Cherry-pick
|
||
print_header("\n🍒 Cherry-pick")
|
||
|
||
if UserInteraction.confirm("Cherry-pick to release branches?"):
|
||
branches = GitHubOperations.get_release_branches()
|
||
|
||
if branches:
|
||
print("\nAvailable release branches:")
|
||
for i, b in enumerate(branches):
|
||
print(f" {i}: {b}")
|
||
|
||
selection = UserInteraction.prompt(
|
||
"Enter numbers (comma-separated) or Enter to skip:"
|
||
)
|
||
|
||
if selection:
|
||
try:
|
||
indices = [int(x.strip()) for x in selection.split(",")]
|
||
for idx in indices:
|
||
if 0 <= idx < len(branches):
|
||
target = branches[idx]
|
||
print_info(f"Requesting cherry-pick to {target}...")
|
||
GitHubOperations.add_cherry_pick_comment(pr_url, target)
|
||
print_success("Cherry-pick requests added")
|
||
except Exception as e:
|
||
print_warning(f"Cherry-pick failed: {e}")
|
||
else:
|
||
print_warning("No release branches found")
|
||
|
||
# Done
|
||
print_header("\n✅ Complete!")
|
||
if issue_number:
|
||
print(f" Issue: #{issue_number}")
|
||
print(f" PR: {pr_url}")
|
||
|
||
|
||
def workflow_all():
|
||
"""Complete workflow: rebase + commit + PR"""
|
||
# Check if there are changes to commit first
|
||
staged, unstaged = GitOperations.get_status()
|
||
|
||
if staged or unstaged:
|
||
# Has uncommitted changes - do commit first
|
||
commit_hash = workflow_commit()
|
||
if not commit_hash:
|
||
print_warning("Commit workflow cancelled")
|
||
return
|
||
print("")
|
||
|
||
# Now check if we need to rebase (have commits ahead of master)
|
||
branch = GitOperations.get_current_branch()
|
||
if branch != "master":
|
||
# Always compare against upstream (milvus-io/milvus), not origin fork
|
||
upstream_remote = GitOperations.get_upstream_remote()
|
||
upstream_master = GitOperations.get_upstream_master()
|
||
commit_count = GitOperations.get_commit_count("master")
|
||
if commit_count > 0:
|
||
print_info(f"Found {commit_count} commit(s) - checking if rebase needed...")
|
||
|
||
# Fetch from upstream to check if we're behind
|
||
try:
|
||
GitOperations.fetch(upstream_remote, "master")
|
||
except Exception:
|
||
pass
|
||
|
||
# Check if there are upstream changes
|
||
behind_count = GitOperations.run_command(
|
||
["git", "rev-list", "--count", f"HEAD..{upstream_master}"], check=False
|
||
)
|
||
|
||
if behind_count and int(behind_count) > 0:
|
||
print_warning(
|
||
f"Branch is {behind_count} commit(s) behind {upstream_master}"
|
||
)
|
||
if UserInteraction.confirm("Run rebase workflow first?", default=True):
|
||
if not workflow_rebase():
|
||
print_warning("Rebase workflow failed or cancelled")
|
||
if not UserInteraction.confirm("Continue to PR anyway?"):
|
||
return
|
||
print("")
|
||
elif commit_count > 1:
|
||
print_warning(
|
||
f"You have {commit_count} commits (Milvus prefers single commit)"
|
||
)
|
||
if UserInteraction.confirm("Squash commits?", default=True):
|
||
if not workflow_rebase():
|
||
print_warning("Squash workflow failed or cancelled")
|
||
if not UserInteraction.confirm("Continue to PR anyway?"):
|
||
return
|
||
print("")
|
||
|
||
# Proceed to PR
|
||
workflow_pr()
|
||
|
||
|
||
def workflow_search():
|
||
"""Search GitHub issues and PRs by keyword"""
|
||
print_header("🔍 GitHub Search")
|
||
|
||
# Get search parameters
|
||
query = UserInteraction.prompt("Enter search keyword:")
|
||
if not query:
|
||
print_error("Search keyword is required")
|
||
return
|
||
|
||
# Ask for search type
|
||
search_type = UserInteraction.select_option(
|
||
"What do you want to search?",
|
||
[
|
||
("i", "Issues only"),
|
||
("p", "PRs only"),
|
||
("b", "Both issues and PRs"),
|
||
],
|
||
)
|
||
|
||
# Ask for time range
|
||
days_input = UserInteraction.prompt("Search within last N days (default: 30):")
|
||
try:
|
||
days = int(days_input) if days_input else 30
|
||
except ValueError:
|
||
days = 30
|
||
|
||
# Ask for limit
|
||
limit_input = UserInteraction.prompt("Max results (default: 100):")
|
||
try:
|
||
limit = int(limit_input) if limit_input else 100
|
||
except ValueError:
|
||
limit = 100
|
||
|
||
print_info(f"Searching for: '{query}' (last {days} days, limit {limit})")
|
||
|
||
# Execute search
|
||
if search_type in ["i", "b"]:
|
||
print_header("\n📋 Issues")
|
||
issues = GitHubOperations.search_issues(query, limit=limit, days=days)
|
||
if issues:
|
||
for issue in issues:
|
||
created = issue.get("createdAt", "")[:10]
|
||
state = issue.get("state", "UNKNOWN")
|
||
state_color = Colors.GREEN if state == "OPEN" else Colors.RED
|
||
print(
|
||
f" #{issue['number']} [{state_color}{state}{Colors.RESET}] {issue['title']} ({created})"
|
||
)
|
||
print(f"\n Total: {len(issues)} issues")
|
||
else:
|
||
print_warning("No issues found")
|
||
|
||
if search_type in ["p", "b"]:
|
||
print_header("\n🔀 Pull Requests")
|
||
prs = GitHubOperations.search_prs(query, limit=limit, days=days)
|
||
if prs:
|
||
for pr in prs:
|
||
created = pr.get("createdAt", "")[:10]
|
||
state = pr.get("state", "UNKNOWN")
|
||
if state == "MERGED":
|
||
state_color = Colors.BLUE
|
||
elif state == "OPEN":
|
||
state_color = Colors.GREEN
|
||
else:
|
||
state_color = Colors.RED
|
||
print(
|
||
f" #{pr['number']} [{state_color}{state}{Colors.RESET}] {pr['title']} ({created})"
|
||
)
|
||
print(f"\n Total: {len(prs)} PRs")
|
||
else:
|
||
print_warning("No PRs found")
|
||
|
||
print_success("\nSearch complete!")
|
||
|
||
|
||
# ============================================================================
|
||
# Cherry-Pick Workflow Helpers
|
||
# ============================================================================
|
||
|
||
|
||
@dataclass
|
||
class CherryPickContext:
|
||
"""Context object for cherry-pick workflow state"""
|
||
|
||
original_branch: str = ""
|
||
pr_details: Optional[Dict] = None
|
||
commit_sha: str = ""
|
||
related_issue: Optional[str] = None
|
||
target_branch: str = ""
|
||
version_num: str = ""
|
||
cp_branch: str = ""
|
||
upstream_remote: str = ""
|
||
fork_remote: str = ""
|
||
fork_owner: str = ""
|
||
pr_url: str = ""
|
||
|
||
|
||
def _cp_preflight_checks() -> str:
|
||
"""Run pre-flight checks for cherry-pick workflow.
|
||
|
||
Returns: Original branch name
|
||
Raises: SystemExit on failure
|
||
"""
|
||
print_info("Running pre-flight checks...")
|
||
|
||
try:
|
||
GitHubOperations.check_gh_cli()
|
||
except Exception as e:
|
||
print_error(str(e))
|
||
sys.exit(1)
|
||
|
||
staged, unstaged = GitOperations.get_status()
|
||
if staged or unstaged:
|
||
print_error("You have uncommitted changes. Please commit or stash them first.")
|
||
sys.exit(1)
|
||
|
||
original_branch = GitOperations.get_current_branch()
|
||
print_success(f"Current branch: {original_branch}")
|
||
return original_branch
|
||
|
||
|
||
def _cp_select_pr() -> Tuple[Dict, str, Optional[str]]:
|
||
"""Select PR to cherry-pick.
|
||
|
||
Returns: (pr_details, commit_sha, related_issue)
|
||
Raises: SystemExit on failure
|
||
"""
|
||
print_header("\n🔍 Select PR to cherry-pick")
|
||
|
||
pr_input = UserInteraction.prompt(
|
||
"Enter PR number or issue number (e.g., '46716' or '#46716'):"
|
||
)
|
||
if not pr_input:
|
||
print_error("PR or issue number is required")
|
||
print_info("Tip: Use 'mgit --search' to find PRs by keyword first")
|
||
sys.exit(1)
|
||
|
||
num_match = re.match(r"^#?(\d+)$", pr_input.strip())
|
||
if not num_match:
|
||
print_error(
|
||
"Invalid input. Please enter a valid PR or issue number (e.g., '46716' or '#46716')."
|
||
)
|
||
print_info("Tip: Use 'mgit --search' to find PRs by keyword first")
|
||
sys.exit(1)
|
||
|
||
input_num = int(num_match.group(1))
|
||
|
||
print_info(f"Looking up #{input_num}...")
|
||
pr_details = GitHubOperations.get_pr_details(input_num)
|
||
|
||
if not pr_details:
|
||
print_info(
|
||
f"#{input_num} is not a PR, searching for PRs related to issue #{input_num}..."
|
||
)
|
||
prs = GitHubOperations.search_merged_prs(f"#{input_num}")
|
||
if prs:
|
||
print(f"\n{Colors.BOLD}PRs related to issue #{input_num}:{Colors.RESET}")
|
||
for i, pr in enumerate(prs):
|
||
merged_at = (
|
||
pr.get("mergedAt", "Unknown")[:10]
|
||
if pr.get("mergedAt")
|
||
else "Unknown"
|
||
)
|
||
print(f" [{i}] #{pr['number']} {pr['title']} (Merged: {merged_at})")
|
||
|
||
selection = UserInteraction.prompt("\nSelect PR index (0, 1, ...):")
|
||
try:
|
||
idx = int(selection)
|
||
if idx < 0 or idx >= len(prs):
|
||
print_error("Invalid selection")
|
||
sys.exit(1)
|
||
pr_details = GitHubOperations.get_pr_details(prs[idx]["number"])
|
||
except ValueError:
|
||
print_error("Invalid input")
|
||
sys.exit(1)
|
||
else:
|
||
print_error(f"No merged PRs found related to issue #{input_num}")
|
||
sys.exit(1)
|
||
|
||
if not pr_details:
|
||
print_error("Failed to get PR details")
|
||
sys.exit(1)
|
||
|
||
merge_commit = pr_details.get("mergeCommit", {})
|
||
commit_sha = merge_commit.get("oid") if merge_commit else None
|
||
|
||
if not commit_sha:
|
||
print_error("Could not find merge commit SHA for this PR")
|
||
sys.exit(1)
|
||
|
||
print_success(f"Selected: #{pr_details['number']} - {pr_details['title']}")
|
||
print_info(f"Merge commit: {commit_sha[:12]}")
|
||
|
||
related_issue = GitHubOperations.extract_related_issue(pr_details.get("body", ""))
|
||
if related_issue:
|
||
print_info(f"Related issue: #{related_issue}")
|
||
|
||
return pr_details, commit_sha, related_issue
|
||
|
||
|
||
def _cp_get_target_branch() -> Tuple[str, str, str]:
|
||
"""Get and validate target branch.
|
||
|
||
Returns: (target_branch, version_num, upstream_remote)
|
||
Raises: SystemExit on failure
|
||
"""
|
||
print_header("\n🎯 Target Branch")
|
||
|
||
target_version = UserInteraction.prompt("Enter target version (e.g., '2.6'):")
|
||
if not target_version:
|
||
print_error("Target version is required")
|
||
sys.exit(1)
|
||
|
||
if target_version.startswith("branch-"):
|
||
target_branch = target_version.replace("branch-", "")
|
||
version_num = target_branch
|
||
else:
|
||
target_branch = target_version
|
||
version_num = target_version
|
||
|
||
upstream_remote = GitOperations.get_upstream_remote()
|
||
print_info(f"Fetching {upstream_remote}/{target_branch}...")
|
||
|
||
try:
|
||
GitOperations.fetch(upstream_remote, target_branch)
|
||
except Exception as e:
|
||
print_error(f"Failed to fetch target branch: {e}")
|
||
print_info(f"Make sure branch '{target_branch}' exists in milvus-io/milvus")
|
||
sys.exit(1)
|
||
|
||
try:
|
||
GitOperations.fetch(upstream_remote, "master")
|
||
except Exception:
|
||
pass
|
||
|
||
print_success(f"Target branch: {target_branch}")
|
||
return target_branch, version_num, upstream_remote
|
||
|
||
|
||
def _cp_setup_branch(ctx: CherryPickContext) -> str:
|
||
"""Create or switch to cherry-pick branch.
|
||
|
||
Returns: Cherry-pick branch name
|
||
Raises: SystemExit on failure or cancel
|
||
"""
|
||
cp_branch = f"cp{ctx.version_num.replace('.', '')}/{ctx.pr_details['number']}"
|
||
print_info(f"Cherry-pick branch: {cp_branch}")
|
||
|
||
if GitOperations.branch_exists(cp_branch):
|
||
print_warning(f"Branch {cp_branch} already exists")
|
||
choice = UserInteraction.select_option(
|
||
"What do you want to do?",
|
||
[
|
||
("d", "Delete and recreate"),
|
||
("u", "Use existing branch"),
|
||
("c", "Cancel"),
|
||
],
|
||
)
|
||
if choice == "c":
|
||
print_warning("Cherry-pick cancelled")
|
||
sys.exit(0)
|
||
elif choice == "d":
|
||
GitOperations.delete_branch(cp_branch, force=True)
|
||
print_success(f"Deleted branch {cp_branch}")
|
||
|
||
if not GitOperations.branch_exists(cp_branch):
|
||
print_info(
|
||
f"Creating branch {cp_branch} from {ctx.upstream_remote}/{ctx.target_branch}..."
|
||
)
|
||
try:
|
||
GitOperations.checkout_remote_branch(
|
||
ctx.upstream_remote, ctx.target_branch, cp_branch
|
||
)
|
||
print_success(f"Created and switched to branch: {cp_branch}")
|
||
except Exception as e:
|
||
print_error(f"Failed to create branch: {e}")
|
||
sys.exit(1)
|
||
else:
|
||
GitOperations.checkout_branch(cp_branch)
|
||
print_success(f"Switched to existing branch: {cp_branch}")
|
||
|
||
return cp_branch
|
||
|
||
|
||
def _cp_execute_cherry_pick(ctx: CherryPickContext) -> bool:
|
||
"""Execute cherry-pick operation and handle conflicts.
|
||
|
||
Returns: True if successful, exits on abort or wait
|
||
"""
|
||
print_header("\n🍒 Executing Cherry-Pick")
|
||
|
||
print_info(f"Cherry-picking commit {ctx.commit_sha[:12]}...")
|
||
success, error_msg, conflict_files = GitOperations.cherry_pick(ctx.commit_sha)
|
||
|
||
if not success:
|
||
print_warning("Cherry-pick encountered conflicts!")
|
||
print(f"\n{Colors.RED}Conflicting files:{Colors.RESET}")
|
||
for f in conflict_files:
|
||
print(f" - {f}")
|
||
|
||
print_header("\n🤖 AI Conflict Analysis")
|
||
ai_service = AIService()
|
||
if ai_service.has_api_key:
|
||
print_info("Analyzing conflicts with AI...")
|
||
analysis = ai_service.analyze_conflict(
|
||
conflict_files, ctx.pr_details["title"]
|
||
)
|
||
print(f"\n{Colors.BLUE}Analysis:{Colors.RESET}")
|
||
print(analysis)
|
||
else:
|
||
print_warning("No AI available for conflict analysis")
|
||
|
||
print_header("\n⚠️ Conflict Resolution Required")
|
||
print_info("Please resolve the conflicts manually:")
|
||
print(" 1. Edit the conflicting files to resolve conflicts")
|
||
print(" 2. Run: git add <resolved-files>")
|
||
print(" 3. Run: git cherry-pick --continue")
|
||
print("")
|
||
print("Or run: git cherry-pick --abort to cancel")
|
||
|
||
choice = UserInteraction.select_option(
|
||
"Options:",
|
||
[
|
||
("w", "Wait - I will resolve conflicts and continue later"),
|
||
("a", "Abort cherry-pick and return to original branch"),
|
||
],
|
||
)
|
||
|
||
if choice == "a":
|
||
GitOperations.cherry_pick_abort()
|
||
GitOperations.checkout_branch(ctx.original_branch)
|
||
GitOperations.delete_branch(ctx.cp_branch, force=True)
|
||
print_warning("Cherry-pick aborted")
|
||
sys.exit(1)
|
||
else:
|
||
print_info("\nAfter resolving conflicts, run:")
|
||
print(" git add <files>")
|
||
print(" git cherry-pick --continue")
|
||
print(
|
||
" python3 tools/mgit.py --cherry-pick # To continue with PR creation"
|
||
)
|
||
sys.exit(0)
|
||
|
||
print_success("Cherry-pick successful!")
|
||
|
||
diff_stat = GitOperations.run_command(["git", "diff", "--stat", "HEAD~1"])
|
||
print(f"\n{Colors.BLUE}Changes:{Colors.RESET}")
|
||
print(diff_stat)
|
||
return True
|
||
|
||
|
||
def _cp_push_to_fork(ctx: CherryPickContext) -> Tuple[str, str]:
|
||
"""Push cherry-pick branch to fork.
|
||
|
||
Returns: (fork_remote, fork_owner)
|
||
Raises: SystemExit on failure or cancel
|
||
"""
|
||
print_header("\n📤 Push to Fork")
|
||
|
||
if not UserInteraction.confirm("Push to your fork?", default=True):
|
||
print_warning("Push cancelled. You can push manually later.")
|
||
print(f" Branch: {ctx.cp_branch}")
|
||
sys.exit(0)
|
||
|
||
fork_remote, fork_owner = GitOperations.get_fork_info()
|
||
print_info(f"Pushing {ctx.cp_branch} to {fork_remote}...")
|
||
|
||
try:
|
||
GitOperations.push(ctx.cp_branch, force=False, remote=fork_remote)
|
||
print_success(f"Pushed to {fork_remote}/{ctx.cp_branch}")
|
||
except Exception as e:
|
||
print_warning(f"Push failed: {e}")
|
||
if UserInteraction.confirm("Force push?"):
|
||
GitOperations.push(ctx.cp_branch, force=True, remote=fork_remote)
|
||
print_success(f"Force pushed to {fork_remote}/{ctx.cp_branch}")
|
||
else:
|
||
print_warning("Push cancelled")
|
||
sys.exit(1)
|
||
|
||
return fork_remote, fork_owner
|
||
|
||
|
||
def _cp_create_pr(ctx: CherryPickContext) -> str:
|
||
"""Create cherry-pick PR.
|
||
|
||
Returns: PR URL
|
||
Raises: SystemExit on failure or cancel
|
||
"""
|
||
print_header("\n🔀 Create Cherry-Pick PR")
|
||
|
||
original_title = ctx.pr_details["title"]
|
||
type_match = re.match(r"^(\w+):\s*(.+)$", original_title)
|
||
if type_match:
|
||
pr_type = type_match.group(1)
|
||
title_rest = type_match.group(2)
|
||
else:
|
||
pr_type = "fix"
|
||
title_rest = original_title
|
||
|
||
cp_pr_title = (
|
||
f"{pr_type}: [{ctx.version_num}] {title_rest} (#{ctx.pr_details['number']})"
|
||
)
|
||
|
||
cp_pr_body = f"""Cherry-pick from master
|
||
pr: #{ctx.pr_details["number"]}"""
|
||
|
||
if ctx.related_issue:
|
||
cp_pr_body += f"\nRelated to #{ctx.related_issue}"
|
||
|
||
original_body = ctx.pr_details.get("body", "")
|
||
if original_body:
|
||
cleaned_body = re.sub(r"[Ii]ssue:\s*#\d+\s*\n?", "", original_body)
|
||
cleaned_body = cleaned_body.strip()
|
||
if cleaned_body:
|
||
if len(cleaned_body) > 1000:
|
||
cleaned_body = cleaned_body[:1000] + "\n\n... (truncated)"
|
||
cp_pr_body += f"\n\n{cleaned_body}"
|
||
|
||
print(f"\n{Colors.BOLD}{'=' * 60}{Colors.RESET}")
|
||
print(f"{Colors.BOLD}PR Title:{Colors.RESET} {cp_pr_title}")
|
||
print(f"\n{Colors.BOLD}PR Body:{Colors.RESET}")
|
||
print(cp_pr_body)
|
||
print(f"\n{Colors.BOLD}{'=' * 60}{Colors.RESET}")
|
||
|
||
choice = UserInteraction.select_option(
|
||
"Options:",
|
||
[
|
||
("y", "Create PR"),
|
||
("e", "Edit title/body"),
|
||
("n", "Cancel (branch already pushed)"),
|
||
],
|
||
)
|
||
|
||
if choice == "n":
|
||
print_warning("PR creation cancelled")
|
||
print(f"Branch {ctx.cp_branch} has been pushed. You can create PR manually.")
|
||
sys.exit(0)
|
||
elif choice == "e":
|
||
cp_pr_title = (
|
||
UserInteraction.prompt(f"PR title [{cp_pr_title}]:") or cp_pr_title
|
||
)
|
||
print("Enter PR body (Enter empty line to finish):")
|
||
new_body = UserInteraction.prompt_multiline("PR body:")
|
||
if new_body:
|
||
cp_pr_body = new_body
|
||
|
||
print_info("Creating cherry-pick PR...")
|
||
try:
|
||
pr_url = GitHubOperations.create_cherry_pick_pr(
|
||
cp_pr_title, cp_pr_body, ctx.cp_branch, ctx.target_branch
|
||
)
|
||
print_success(f"PR created: {pr_url}")
|
||
return pr_url
|
||
except Exception as e:
|
||
print_error(f"Failed to create PR: {e}")
|
||
print_info(f"You can create PR manually from branch: {ctx.cp_branch}")
|
||
sys.exit(1)
|
||
|
||
|
||
def _cp_set_milestone(ctx: CherryPickContext):
|
||
"""Set milestone for the cherry-pick PR."""
|
||
print_header("\n🎯 Set Milestone (Roadmap)")
|
||
|
||
milestones = GitHubOperations.get_milestones(ctx.version_num)
|
||
|
||
if not milestones:
|
||
print_warning(f"No milestones found for version {ctx.version_num}")
|
||
return
|
||
|
||
print("Available milestones:")
|
||
for i, m in enumerate(milestones):
|
||
print(f" [{i}] {m['title']}")
|
||
print(" [s] Skip - don't set milestone")
|
||
|
||
selection = UserInteraction.prompt("\nSelect milestone index:")
|
||
|
||
if selection.lower() == "s" or not selection.strip():
|
||
print_info("Skipping milestone")
|
||
return
|
||
|
||
try:
|
||
idx = int(selection)
|
||
if 0 <= idx < len(milestones):
|
||
selected_milestone = milestones[idx]
|
||
print_info(f"Setting milestone to {selected_milestone['title']}...")
|
||
try:
|
||
GitHubOperations.set_pr_milestone(
|
||
ctx.pr_url, selected_milestone["number"]
|
||
)
|
||
print_success(f"Milestone set: {selected_milestone['title']}")
|
||
except Exception as e:
|
||
print_warning(f"Failed to set milestone: {e}")
|
||
else:
|
||
print_warning("Invalid selection, skipping milestone")
|
||
except ValueError:
|
||
print_warning("Invalid input, skipping milestone")
|
||
|
||
|
||
def workflow_backport():
|
||
"""Backport workflow: Submit current PR to a historical release branch with auto-rebase"""
|
||
print_header("📦 Backport Workflow")
|
||
|
||
# 1. Pre-flight checks
|
||
print_info("Running pre-flight checks...")
|
||
|
||
try:
|
||
GitHubOperations.check_gh_cli()
|
||
except Exception as e:
|
||
print_error(str(e))
|
||
sys.exit(1)
|
||
|
||
current_branch = GitOperations.get_current_branch()
|
||
if current_branch == "master":
|
||
print_error(
|
||
"Cannot backport from master branch. Please work on a feature branch first."
|
||
)
|
||
sys.exit(1)
|
||
|
||
print_success(f"Current branch: {current_branch}")
|
||
|
||
# Check for commits
|
||
upstream_master = GitOperations.get_upstream_master()
|
||
commit_count = GitOperations.get_commit_count("master")
|
||
if commit_count == 0:
|
||
print_error("No commits to backport")
|
||
sys.exit(1)
|
||
|
||
print_info(f"Found {commit_count} commit(s) to backport")
|
||
|
||
# 2. Get the commit(s) to backport
|
||
# Get the commit range from upstream/master to HEAD
|
||
commits = GitOperations.run_command(
|
||
["git", "log", f"{upstream_master}..HEAD", "--pretty=%H", "--reverse"]
|
||
).splitlines()
|
||
|
||
if not commits:
|
||
print_error("No commits found to backport")
|
||
sys.exit(1)
|
||
|
||
print_info("Commits to backport:")
|
||
for commit in commits[:5]: # Show first 5
|
||
msg = GitOperations.run_command(["git", "log", "-1", "--pretty=%s", commit])
|
||
print(f" {commit[:8]}: {msg}")
|
||
if len(commits) > 5:
|
||
print(f" ... and {len(commits) - 5} more")
|
||
|
||
# 3. Select target release branch
|
||
print_header("\n🎯 Select Target Branch")
|
||
|
||
branches = GitHubOperations.get_release_branches()
|
||
|
||
if not branches:
|
||
print_error("No release branches found")
|
||
sys.exit(1)
|
||
|
||
print("\nAvailable release branches:")
|
||
for i, b in enumerate(branches):
|
||
# First branch is the most recent (sorted in reverse order)
|
||
label = " (latest)" if i == 0 else ""
|
||
print(f" {i}: {b}{label}")
|
||
|
||
selection = UserInteraction.prompt("Enter branch number (default: 0 for latest):")
|
||
if not selection.strip():
|
||
selection = "0"
|
||
try:
|
||
idx = int(selection)
|
||
if 0 <= idx < len(branches):
|
||
target_branch = branches[idx]
|
||
else:
|
||
print_error("Invalid selection")
|
||
sys.exit(1)
|
||
except ValueError:
|
||
print_error("Invalid input")
|
||
sys.exit(1)
|
||
|
||
print_success(f"Target branch: {target_branch}")
|
||
|
||
# 4. Create backport branch
|
||
print_header("\n🌿 Creating Backport Branch")
|
||
|
||
# Extract version number (e.g., "2.5" from "origin/2.5")
|
||
version_num = target_branch.split("/")[-1]
|
||
backport_branch = f"backport-{version_num}-{current_branch}"
|
||
|
||
# Check if branch already exists
|
||
if GitOperations.branch_exists(backport_branch):
|
||
if UserInteraction.confirm(
|
||
f"Branch {backport_branch} already exists. Delete and recreate?"
|
||
):
|
||
GitOperations.delete_branch(backport_branch, force=True)
|
||
else:
|
||
print_error("Cannot continue with existing branch")
|
||
sys.exit(1)
|
||
|
||
# Fetch and create branch from release branch
|
||
upstream_remote = GitOperations.get_upstream_remote()
|
||
print_info(f"Fetching {target_branch}...")
|
||
try:
|
||
GitOperations.fetch(upstream_remote, version_num)
|
||
except Exception as e:
|
||
print_warning(f"Fetch warning: {e}")
|
||
|
||
# Create branch from release branch
|
||
print_info(f"Creating branch {backport_branch} from {target_branch}...")
|
||
try:
|
||
GitOperations.run_command(
|
||
["git", "checkout", "-b", backport_branch, target_branch]
|
||
)
|
||
print_success(f"Created branch: {backport_branch}")
|
||
except Exception as e:
|
||
print_error(f"Failed to create branch: {e}")
|
||
GitOperations.checkout_branch(current_branch)
|
||
sys.exit(1)
|
||
|
||
# 5. Cherry-pick commits
|
||
print_header("\n🍒 Cherry-picking Commits")
|
||
|
||
cherry_pick_failed = False
|
||
commits_applied = 0
|
||
commits_skipped = 0
|
||
for i, commit in enumerate(commits):
|
||
msg = GitOperations.run_command(["git", "log", "-1", "--pretty=%s", commit])
|
||
print_info(f"Cherry-picking [{i + 1}/{len(commits)}]: {msg[:60]}...")
|
||
|
||
success, error_msg, conflict_files = GitOperations.cherry_pick(commit)
|
||
|
||
if not success:
|
||
print_error(f"Cherry-pick failed: {error_msg}")
|
||
cherry_pick_failed = True
|
||
|
||
if conflict_files:
|
||
print_warning(f"Conflicts in: {', '.join(conflict_files)}")
|
||
|
||
# Use AI to analyze conflicts if available
|
||
ai_service = AIService()
|
||
if ai_service.has_api_key:
|
||
print_info("Analyzing conflicts with AI...")
|
||
try:
|
||
analysis = ai_service.analyze_conflict(conflict_files, msg)
|
||
print(f"\n{Colors.BLUE}AI Analysis:{Colors.RESET}")
|
||
print(analysis)
|
||
except Exception:
|
||
pass
|
||
|
||
choice = UserInteraction.select_option(
|
||
"Options:",
|
||
[
|
||
("r", "Resolve conflicts manually, then continue"),
|
||
("s", "Skip this commit"),
|
||
("a", "Abort backport"),
|
||
],
|
||
)
|
||
|
||
if choice == "a":
|
||
print_info("Aborting backport...")
|
||
GitOperations.cherry_pick_abort()
|
||
GitOperations.checkout_branch(current_branch)
|
||
GitOperations.delete_branch(backport_branch, force=True)
|
||
print_warning("Backport aborted, returned to original branch")
|
||
sys.exit(1)
|
||
elif choice == "s":
|
||
print_info("Skipping this commit...")
|
||
GitOperations.cherry_pick_abort()
|
||
commits_skipped += 1
|
||
continue
|
||
elif choice == "r":
|
||
print_info(
|
||
"Please resolve conflicts, stage changes, then press Enter..."
|
||
)
|
||
input()
|
||
try:
|
||
GitOperations.cherry_pick_continue()
|
||
print_success("Cherry-pick continued")
|
||
commits_applied += 1
|
||
except Exception as e:
|
||
print_error(f"Failed to continue: {e}")
|
||
sys.exit(1)
|
||
else:
|
||
print_success(f"Cherry-picked: {commit[:8]}")
|
||
commits_applied += 1
|
||
|
||
# Report cherry-pick results
|
||
if commits_applied == len(commits):
|
||
print_success("All commits cherry-picked successfully!")
|
||
elif commits_applied > 0:
|
||
print_warning(
|
||
f"Cherry-picked {commits_applied}/{len(commits)} commits ({commits_skipped} skipped)"
|
||
)
|
||
else:
|
||
print_error("No commits were cherry-picked")
|
||
if UserInteraction.confirm("Abort backport?", default=True):
|
||
GitOperations.checkout_branch(current_branch)
|
||
GitOperations.delete_branch(backport_branch, force=True)
|
||
print_warning("Backport aborted, returned to original branch")
|
||
sys.exit(1)
|
||
|
||
# 6. Push to fork
|
||
print_header("\n📤 Pushing to Fork")
|
||
|
||
fork_remote, fork_owner = GitOperations.get_fork_info()
|
||
print_info(f"Pushing {backport_branch} to {fork_remote} ({fork_owner})...")
|
||
|
||
try:
|
||
GitOperations.push(backport_branch, remote=fork_remote)
|
||
print_success(f"Pushed to {fork_remote}")
|
||
except Exception as e:
|
||
print_warning(f"Push failed: {e}")
|
||
if UserInteraction.confirm("Force push?"):
|
||
GitOperations.push(backport_branch, force=True, remote=fork_remote)
|
||
print_success("Force pushed")
|
||
else:
|
||
sys.exit(1)
|
||
|
||
# 7. Create PR
|
||
print_header("\n📝 Creating Backport PR")
|
||
|
||
# Get original PR title if exists, or use commit message
|
||
original_title = GitOperations.run_command(
|
||
["git", "log", "-1", "--pretty=%s", commits[-1]]
|
||
)
|
||
|
||
# Create backport PR title
|
||
pr_title = f"[{version_num}] {original_title}"
|
||
if len(pr_title) > 80:
|
||
pr_title = pr_title[:77] + "..."
|
||
|
||
print_info(f"PR Title: {pr_title}")
|
||
|
||
# Build PR body
|
||
pr_body = f"""## Backport to {version_num}
|
||
|
||
This PR backports the following commits from `{current_branch}` to `{version_num}`:
|
||
|
||
"""
|
||
for commit in commits:
|
||
msg = GitOperations.run_command(["git", "log", "-1", "--pretty=%s", commit])
|
||
pr_body += f"- {commit[:8]}: {msg}\n"
|
||
|
||
# Allow editing
|
||
if UserInteraction.confirm("Edit PR title/description?"):
|
||
new_title = UserInteraction.prompt(f"PR title [{pr_title}]:")
|
||
if new_title:
|
||
pr_title = new_title
|
||
new_body = UserInteraction.prompt_multiline(
|
||
"PR description (or Enter to keep default):"
|
||
)
|
||
if new_body:
|
||
pr_body = new_body
|
||
|
||
print_info("Creating PR...")
|
||
try:
|
||
pr_url = GitHubOperations.create_cherry_pick_pr(
|
||
pr_title, pr_body, backport_branch, version_num
|
||
)
|
||
print_success(f"PR created: {pr_url}")
|
||
except Exception as e:
|
||
print_error(f"Failed to create PR: {e}")
|
||
print_info(
|
||
f"You can create the PR manually from: {fork_owner}/{backport_branch} → milvus-io/milvus:{version_num}"
|
||
)
|
||
sys.exit(1)
|
||
|
||
# 8. Done
|
||
print_header("\n✅ Backport Complete!")
|
||
print(f" Source: {current_branch}")
|
||
print(f" Target: {version_num}")
|
||
print(f" Branch: {backport_branch}")
|
||
print(f" PR: {pr_url}")
|
||
|
||
# Return to original branch
|
||
if UserInteraction.confirm(
|
||
f"\nReturn to original branch ({current_branch})?", default=True
|
||
):
|
||
GitOperations.checkout_branch(current_branch)
|
||
print_success(f"Switched back to {current_branch}")
|
||
|
||
|
||
def workflow_cherry_pick():
|
||
"""Cherry-pick workflow: PR/issue number → cherry-pick → create PR"""
|
||
print_header("🍒 Cherry-Pick Workflow")
|
||
|
||
# Initialize context
|
||
ctx = CherryPickContext()
|
||
|
||
# Step 1: Pre-flight checks
|
||
ctx.original_branch = _cp_preflight_checks()
|
||
|
||
# Step 2: Select PR to cherry-pick
|
||
ctx.pr_details, ctx.commit_sha, ctx.related_issue = _cp_select_pr()
|
||
|
||
# Step 3: Get target branch
|
||
ctx.target_branch, ctx.version_num, ctx.upstream_remote = _cp_get_target_branch()
|
||
|
||
# Step 4: Setup cherry-pick branch
|
||
ctx.cp_branch = _cp_setup_branch(ctx)
|
||
|
||
# Step 5: Execute cherry-pick
|
||
_cp_execute_cherry_pick(ctx)
|
||
|
||
# Step 6: Push to fork
|
||
ctx.fork_remote, ctx.fork_owner = _cp_push_to_fork(ctx)
|
||
|
||
# Step 7: Create PR
|
||
ctx.pr_url = _cp_create_pr(ctx)
|
||
|
||
# Step 8: Set milestone
|
||
_cp_set_milestone(ctx)
|
||
|
||
# Done!
|
||
print_header("\n✅ Cherry-Pick Complete!")
|
||
print(f" Original PR: #{ctx.pr_details['number']}")
|
||
print(f" Target: {ctx.target_branch}")
|
||
print(f" Branch: {ctx.cp_branch}")
|
||
print(f" PR: {ctx.pr_url}")
|
||
|
||
# Optionally return to original branch
|
||
if UserInteraction.confirm(
|
||
f"\nReturn to original branch ({ctx.original_branch})?", default=True
|
||
):
|
||
GitOperations.checkout_branch(ctx.original_branch)
|
||
print_success(f"Switched back to {ctx.original_branch}")
|
||
|
||
|
||
# ============================================================================
|
||
# Main Entry Point
|
||
# ============================================================================
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description="mgit - Intelligent Git Workflow Tool for Milvus",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
Examples:
|
||
mgit.py --commit Smart commit workflow
|
||
mgit.py --rebase Rebase onto master and squash commits
|
||
mgit.py --pr PR creation workflow
|
||
mgit.py --search Search GitHub issues and PRs by keyword
|
||
mgit.py --cherry-pick Cherry-pick PR to release branch (by PR/issue number)
|
||
mgit.py --backport Backport current branch to release branch
|
||
mgit.py --all Complete workflow (default)
|
||
""",
|
||
)
|
||
|
||
parser.add_argument(
|
||
"--commit", action="store_true", help="Run smart commit workflow"
|
||
)
|
||
|
||
parser.add_argument("--pr", action="store_true", help="Run PR creation workflow")
|
||
|
||
parser.add_argument(
|
||
"--all",
|
||
action="store_true",
|
||
help="Run complete workflow (rebase + commit + PR)",
|
||
)
|
||
|
||
parser.add_argument(
|
||
"--rebase",
|
||
action="store_true",
|
||
help="Run rebase and squash workflow (sync with master, squash commits)",
|
||
)
|
||
|
||
parser.add_argument(
|
||
"--cherry-pick",
|
||
action="store_true",
|
||
dest="cherry_pick",
|
||
help="Cherry-pick a merged PR to a release branch",
|
||
)
|
||
|
||
parser.add_argument(
|
||
"--search", action="store_true", help="Search GitHub issues and PRs by keyword"
|
||
)
|
||
|
||
parser.add_argument(
|
||
"--backport",
|
||
action="store_true",
|
||
help="Backport current branch to a release branch with auto-rebase",
|
||
)
|
||
|
||
args = parser.parse_args()
|
||
|
||
# Default to --all if no arguments (except cherry-pick, search, backport which are standalone)
|
||
if not (
|
||
args.commit
|
||
or args.pr
|
||
or args.all
|
||
or args.rebase
|
||
or args.cherry_pick
|
||
or args.search
|
||
or args.backport
|
||
):
|
||
args.all = True
|
||
|
||
try:
|
||
print(f"{Colors.BOLD}{Colors.BLUE}")
|
||
print("╔═══════════════════════════════════════╗")
|
||
print("║ mgit - Milvus Git Workflow Tool ║")
|
||
print("╚═══════════════════════════════════════╝")
|
||
print(f"{Colors.RESET}")
|
||
|
||
if args.search:
|
||
workflow_search()
|
||
elif args.cherry_pick:
|
||
workflow_cherry_pick()
|
||
elif args.backport:
|
||
workflow_backport()
|
||
elif args.rebase:
|
||
workflow_rebase()
|
||
elif args.commit:
|
||
workflow_commit()
|
||
elif args.pr:
|
||
workflow_pr()
|
||
elif args.all:
|
||
workflow_all()
|
||
|
||
except KeyboardInterrupt:
|
||
print(f"\n\n{Colors.YELLOW}Operation cancelled by user{Colors.RESET}")
|
||
sys.exit(1)
|
||
except Exception as e:
|
||
print_error(f"Unexpected error: {e}")
|
||
import traceback
|
||
|
||
traceback.print_exc()
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|