|
|
"""
|
|
|
Interactive repository selector for migration tool
|
|
|
"""
|
|
|
import sys
|
|
|
import termios
|
|
|
import tty
|
|
|
from typing import List, Set
|
|
|
from colorama import Fore, Style, init
|
|
|
from providers.base import Repository
|
|
|
|
|
|
init()
|
|
|
|
|
|
class InteractiveSelector:
|
|
|
"""Interactive repository selector with keyboard navigation"""
|
|
|
|
|
|
def __init__(self, repositories: List[Repository], username: str):
|
|
|
self.username = username
|
|
|
# Sort repositories: user's repos first, then others, both alphabetically
|
|
|
self.repositories = self._sort_repositories(repositories, username)
|
|
|
# Only select user's own repositories by default
|
|
|
self.selected = set(i for i, repo in enumerate(self.repositories)
|
|
|
if repo.owner == username)
|
|
|
self.current_index = 0
|
|
|
self.page_size = 15 # Number of repos to show per page
|
|
|
self.current_page = 0
|
|
|
|
|
|
def get_key(self):
|
|
|
"""Get a single keypress from stdin"""
|
|
|
fd = sys.stdin.fileno()
|
|
|
old_settings = termios.tcgetattr(fd)
|
|
|
try:
|
|
|
tty.setraw(sys.stdin.fileno())
|
|
|
key = sys.stdin.read(1)
|
|
|
# Handle arrow keys and special keys
|
|
|
if key == '\x1b': # ESC sequence
|
|
|
key += sys.stdin.read(2)
|
|
|
finally:
|
|
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
|
return key
|
|
|
|
|
|
def display_page(self):
|
|
|
"""Display current page of repositories"""
|
|
|
# Clear screen
|
|
|
print('\033[2J\033[H', end='')
|
|
|
|
|
|
# Header
|
|
|
print(f"{Fore.CYAN}╔═══════════════════════════════════════════════════════════════╗")
|
|
|
print(f"║ 📋 SELECT REPOSITORIES ║")
|
|
|
print(f"║ ║")
|
|
|
print(f"║ 👤 = Your repos (selected by default) 👥 = Others' repos ║")
|
|
|
print(f"║ ↑↓ navigate, SPACE toggle, A all, N none, ENTER confirm ║")
|
|
|
print(f"╚═══════════════════════════════════════════════════════════════╝{Style.RESET_ALL}")
|
|
|
print()
|
|
|
|
|
|
# Calculate pagination
|
|
|
start_idx = self.current_page * self.page_size
|
|
|
end_idx = min(start_idx + self.page_size, len(self.repositories))
|
|
|
|
|
|
# Show page info
|
|
|
total_pages = (len(self.repositories) + self.page_size - 1) // self.page_size
|
|
|
selected_count = len(self.selected)
|
|
|
total_count = len(self.repositories)
|
|
|
|
|
|
print(f"{Fore.YELLOW}📊 Page {self.current_page + 1}/{total_pages} | "
|
|
|
f"Selected: {selected_count}/{total_count} repositories{Style.RESET_ALL}")
|
|
|
print()
|
|
|
|
|
|
# Display repositories for current page
|
|
|
last_owner_type = None # Track if we're switching from user repos to others
|
|
|
|
|
|
for i in range(start_idx, end_idx):
|
|
|
repo = self.repositories[i]
|
|
|
is_selected = i in self.selected
|
|
|
is_current = i == self.current_index
|
|
|
|
|
|
# Check if we need to add a separator
|
|
|
owner = repo.owner
|
|
|
is_own_repo = owner == self.username
|
|
|
current_owner_type = "own" if is_own_repo else "others"
|
|
|
|
|
|
# Add separator when transitioning from own repos to others
|
|
|
if last_owner_type == "own" and current_owner_type == "others":
|
|
|
print(f" {Fore.LIGHTBLACK_EX}{'─' * 50} Autres repositories {'─' * 10}{Style.RESET_ALL}")
|
|
|
|
|
|
last_owner_type = current_owner_type
|
|
|
|
|
|
# Checkbox
|
|
|
checkbox = "☑️ " if is_selected else "☐ "
|
|
|
|
|
|
# Repository info
|
|
|
name = repo.name
|
|
|
private = "🔒" if repo.private else "🌐"
|
|
|
ownership_indicator = "👤" if is_own_repo else "👥"
|
|
|
description = (repo.description or 'No description')[:45]
|
|
|
if len(repo.description or '') > 45:
|
|
|
description += "..."
|
|
|
|
|
|
# Highlight current selection
|
|
|
if is_current:
|
|
|
line = f"{Fore.BLACK}{Style.BRIGHT}> {checkbox}{ownership_indicator} {Fore.BLUE}{owner}/{name}{Style.RESET_ALL}"
|
|
|
line += f"{Fore.BLACK}{Style.BRIGHT} {private} - {description}{Style.RESET_ALL}"
|
|
|
else:
|
|
|
if is_own_repo:
|
|
|
color = Fore.GREEN if is_selected else Fore.WHITE
|
|
|
else:
|
|
|
color = Fore.YELLOW if is_selected else Fore.LIGHTBLACK_EX
|
|
|
line = f" {checkbox}{ownership_indicator} {color}{owner}/{name}{Style.RESET_ALL}"
|
|
|
line += f" {private} - {Fore.LIGHTBLACK_EX}{description}{Style.RESET_ALL}"
|
|
|
|
|
|
print(line)
|
|
|
|
|
|
# Navigation help at bottom
|
|
|
print()
|
|
|
nav_help = []
|
|
|
if self.current_page > 0:
|
|
|
nav_help.append("← PREV PAGE")
|
|
|
if self.current_page < total_pages - 1:
|
|
|
nav_help.append("→ NEXT PAGE")
|
|
|
|
|
|
if nav_help:
|
|
|
print(f"{Fore.CYAN}Navigation: {' | '.join(nav_help)}{Style.RESET_ALL}")
|
|
|
|
|
|
print(f"\n{Fore.GREEN}Press ENTER to continue with selected repositories{Style.RESET_ALL}")
|
|
|
print(f"{Fore.RED}Press Q to quit{Style.RESET_ALL}")
|
|
|
|
|
|
def move_up(self):
|
|
|
"""Move selection up"""
|
|
|
if self.current_index > 0:
|
|
|
self.current_index -= 1
|
|
|
# Check if we need to go to previous page
|
|
|
if self.current_index < self.current_page * self.page_size:
|
|
|
self.current_page = max(0, self.current_page - 1)
|
|
|
|
|
|
def move_down(self):
|
|
|
"""Move selection down"""
|
|
|
if self.current_index < len(self.repositories) - 1:
|
|
|
self.current_index += 1
|
|
|
# Check if we need to go to next page
|
|
|
total_pages = (len(self.repositories) + self.page_size - 1) // self.page_size
|
|
|
if self.current_index >= (self.current_page + 1) * self.page_size:
|
|
|
self.current_page = min(total_pages - 1, self.current_page + 1)
|
|
|
|
|
|
def toggle_current(self):
|
|
|
"""Toggle selection of current repository"""
|
|
|
if self.current_index in self.selected:
|
|
|
self.selected.remove(self.current_index)
|
|
|
else:
|
|
|
self.selected.add(self.current_index)
|
|
|
|
|
|
def select_all(self):
|
|
|
"""Select all repositories"""
|
|
|
self.selected = set(range(len(self.repositories)))
|
|
|
|
|
|
def select_none(self):
|
|
|
"""Deselect all repositories"""
|
|
|
self.selected.clear()
|
|
|
|
|
|
def prev_page(self):
|
|
|
"""Go to previous page"""
|
|
|
if self.current_page > 0:
|
|
|
self.current_page -= 1
|
|
|
self.current_index = self.current_page * self.page_size
|
|
|
|
|
|
def next_page(self):
|
|
|
"""Go to next page"""
|
|
|
total_pages = (len(self.repositories) + self.page_size - 1) // self.page_size
|
|
|
if self.current_page < total_pages - 1:
|
|
|
self.current_page += 1
|
|
|
self.current_index = self.current_page * self.page_size
|
|
|
|
|
|
def _sort_repositories(self, repositories: List[Repository], username: str) -> List[Repository]:
|
|
|
"""Sort repositories: user's repos first, then others, both alphabetically"""
|
|
|
def sort_key(repo):
|
|
|
owner = repo.owner
|
|
|
name = repo.name.lower() # Case-insensitive sorting
|
|
|
is_user_repo = owner == username
|
|
|
|
|
|
# Return tuple: (is_not_user_repo, owner.lower(), name)
|
|
|
# This will sort user repos first (False < True), then alphabetically
|
|
|
return (not is_user_repo, owner.lower(), name)
|
|
|
|
|
|
return sorted(repositories, key=sort_key)
|
|
|
|
|
|
def _rename_repositories_interface(self, selected_repos: List[Repository]) -> List[Repository]:
|
|
|
"""Interface for renaming selected repositories"""
|
|
|
print('\033[2J\033[H', end='') # Clear screen
|
|
|
|
|
|
print(f"{Fore.CYAN}╔═══════════════════════════════════════════════════════════════╗")
|
|
|
print(f"║ ✏️ RENAME REPOSITORIES ║")
|
|
|
print(f"║ ║")
|
|
|
print(f"║ Press ENTER to keep current name, or type new name ║")
|
|
|
print(f"║ Repository names should be valid GitHub repo names ║")
|
|
|
print(f"╚═══════════════════════════════════════════════════════════════╝{Style.RESET_ALL}")
|
|
|
print()
|
|
|
|
|
|
renamed_repos = []
|
|
|
|
|
|
for i, repo in enumerate(selected_repos, 1):
|
|
|
owner = repo.owner
|
|
|
original_name = repo.name
|
|
|
private = "🔒" if repo.private else "🌐"
|
|
|
|
|
|
print(f"{Fore.YELLOW}📦 Repository {i}/{len(selected_repos)}:{Style.RESET_ALL}")
|
|
|
print(f" Source: {Fore.BLUE}{owner}/{original_name}{Style.RESET_ALL} {private}")
|
|
|
|
|
|
# Get new name from user
|
|
|
new_name = input(f" GitHub name [{Fore.GREEN}{original_name}{Style.RESET_ALL}]: ").strip()
|
|
|
|
|
|
# Validate and use new name
|
|
|
if new_name:
|
|
|
# Basic validation
|
|
|
if not self._is_valid_repo_name(new_name):
|
|
|
print(f" {Fore.RED}⚠️ Invalid repository name. Using original name: {original_name}{Style.RESET_ALL}")
|
|
|
new_name = original_name
|
|
|
else:
|
|
|
print(f" {Fore.GREEN}✅ Will rename to: {new_name}{Style.RESET_ALL}")
|
|
|
else:
|
|
|
new_name = original_name
|
|
|
print(f" {Fore.CYAN}ℹ️ Keeping original name: {original_name}{Style.RESET_ALL}")
|
|
|
|
|
|
# Update repository with new GitHub name
|
|
|
repo.github_name = new_name
|
|
|
renamed_repos.append(repo)
|
|
|
print()
|
|
|
|
|
|
# Summary
|
|
|
print(f"{Fore.GREEN}✅ Repository renaming complete!{Style.RESET_ALL}")
|
|
|
print(f"\n{Fore.CYAN}📋 Migration summary:{Style.RESET_ALL}")
|
|
|
|
|
|
for repo in renamed_repos:
|
|
|
owner = repo.owner
|
|
|
original_name = repo.name
|
|
|
github_name = repo.github_name
|
|
|
private = "🔒" if repo.private else "🌐"
|
|
|
|
|
|
if original_name != github_name:
|
|
|
print(f" • {Fore.BLUE}{owner}/{original_name}{Style.RESET_ALL} → {Fore.GREEN}{github_name}{Style.RESET_ALL} {private}")
|
|
|
else:
|
|
|
print(f" • {Fore.BLUE}{owner}/{original_name}{Style.RESET_ALL} {private}")
|
|
|
|
|
|
input(f"\n{Fore.YELLOW}Press ENTER to continue...{Style.RESET_ALL}")
|
|
|
return renamed_repos
|
|
|
|
|
|
def _is_valid_repo_name(self, name: str) -> bool:
|
|
|
"""Validate GitHub repository name"""
|
|
|
if not name:
|
|
|
return False
|
|
|
|
|
|
# GitHub repo name rules (simplified)
|
|
|
if len(name) > 100:
|
|
|
return False
|
|
|
|
|
|
# Should not start or end with special characters
|
|
|
if name.startswith('.') or name.startswith('-') or name.endswith('.'):
|
|
|
return False
|
|
|
|
|
|
# Should contain only alphanumeric, hyphens, underscores, and dots
|
|
|
import re
|
|
|
return bool(re.match(r'^[a-zA-Z0-9._-]+$', name))
|
|
|
|
|
|
def run(self) -> List[Repository]:
|
|
|
"""Run interactive selection and return selected repositories"""
|
|
|
if not self.repositories:
|
|
|
print(f"{Fore.YELLOW}⚠️ No repositories found.{Style.RESET_ALL}")
|
|
|
return []
|
|
|
|
|
|
try:
|
|
|
while True:
|
|
|
self.display_page()
|
|
|
key = self.get_key()
|
|
|
|
|
|
if key == '\x1b[A': # Up arrow
|
|
|
self.move_up()
|
|
|
elif key == '\x1b[B': # Down arrow
|
|
|
self.move_down()
|
|
|
elif key == '\x1b[D': # Left arrow (previous page)
|
|
|
self.prev_page()
|
|
|
elif key == '\x1b[C': # Right arrow (next page)
|
|
|
self.next_page()
|
|
|
elif key == ' ': # Space - toggle selection
|
|
|
self.toggle_current()
|
|
|
elif key.lower() == 'a': # Select all
|
|
|
self.select_all()
|
|
|
elif key.lower() == 'n': # Select none
|
|
|
self.select_none()
|
|
|
elif key == '\r' or key == '\n': # Enter - confirm
|
|
|
break
|
|
|
elif key.lower() == 'q': # Quit
|
|
|
print(f"\n{Fore.YELLOW}🚪 Migration cancelled by user.{Style.RESET_ALL}")
|
|
|
sys.exit(0)
|
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
print(f"\n{Fore.YELLOW}🚪 Migration cancelled by user.{Style.RESET_ALL}")
|
|
|
sys.exit(0)
|
|
|
|
|
|
# Return selected repositories
|
|
|
selected_repos = [self.repositories[i] for i in sorted(self.selected)]
|
|
|
|
|
|
# Clear screen and show summary
|
|
|
print('\033[2J\033[H', end='')
|
|
|
print(f"{Fore.GREEN}✅ Selected {len(selected_repos)} repositories for migration:{Style.RESET_ALL}\n")
|
|
|
|
|
|
for repo in selected_repos:
|
|
|
owner = repo.owner
|
|
|
name = repo.name
|
|
|
private = "🔒" if repo.private else "🌐"
|
|
|
print(f" • {Fore.BLUE}{owner}/{name}{Style.RESET_ALL} {private}")
|
|
|
|
|
|
# Ask if user wants to rename repositories
|
|
|
print(f"\n{Fore.YELLOW}📝 Voulez-vous changer le nom de certains repos sur GitHub ?{Style.RESET_ALL}")
|
|
|
print(f"{Fore.CYAN}[Y/y] Oui - Interface de renommage{Style.RESET_ALL}")
|
|
|
print(f"{Fore.CYAN}[N/n ou ENTER] Non - Conserver les noms actuels{Style.RESET_ALL}")
|
|
|
|
|
|
choice = input(f"\n{Fore.YELLOW}Votre choix: {Style.RESET_ALL}").strip().lower()
|
|
|
|
|
|
if choice == 'y' or choice == 'yes' or choice == 'oui':
|
|
|
selected_repos = self._rename_repositories_interface(selected_repos)
|
|
|
|
|
|
print(f"\n{Fore.CYAN}🚀 Starting migration...{Style.RESET_ALL}\n")
|
|
|
|
|
|
return selected_repos
|
|
|
|
|
|
|
|
|
def select_repositories_interactive(repositories: List[Repository], username: str) -> List[Repository]:
|
|
|
"""
|
|
|
Interactive repository selection interface
|
|
|
|
|
|
Args:
|
|
|
repositories: List of Repository objects from source provider
|
|
|
username: Current user's username to distinguish own repos
|
|
|
|
|
|
Returns:
|
|
|
List of selected repositories
|
|
|
"""
|
|
|
selector = InteractiveSelector(repositories, username)
|
|
|
return selector.run() |