diff --git a/README.md b/README.md index 8f59dc4..0b0db59 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,89 @@ # ubounty + Enable maintainers to clear their backlog with one command. Turn "I'll fix this someday" into "Done in 5 minutes." + +## Installation + +```bash +# Install from source +pip install -e . + +# Or with pipx (recommended) +pipx install . +``` + +## Usage + +### Browse Bounties + +Discover open bounties from GitHub: + +```bash +# List all bounties +ubounty browse + +# Filter by programming language +ubounty browse --language python + +# Filter by minimum amount +ubounty browse --min-amount 100 + +# Filter by difficulty +ubounty browse --difficulty easy + +# Combine filters +ubounty browse --language javascript --min-amount 50 --max-amount 500 + +# Pagination +ubounty browse --page 2 --per-page 20 +``` + +### Wallet Management + +Connect your wallet to receive bounty payouts: + +```bash +# Connect a wallet (interactive) +ubounty wallet connect + +# Connect with flags +ubounty wallet connect --address 0x123... --type ethereum + +# Show connected wallet +ubounty wallet show + +# Disconnect wallet +ubounty wallet disconnect +``` + +## Features + +### Browse Command +- 🔍 Search GitHub for bounty-labeled issues +- 🏷️ Filter by language, amount, and difficulty +- 📊 Clean table output with rich formatting +- 📄 Pagination support for large result sets + +### Wallet Command +- 🔐 Secure local storage (~/.ubounty/wallet.json) +- ✅ Address format validation +- 🔗 Support for Ethereum, Solana, Bitcoin, and custom wallets +- ⚠️ Overwrite protection with confirmation prompts + +## Development + +```bash +# Clone the repo +git clone https://github.com/ubounty-app/ubounty.git +cd ubounty + +# Install in development mode +pip install -e ".[dev]" + +# Run the CLI +ubounty --help +``` + +## License + +MIT diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..07b37cd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "ubounty" +version = "0.1.0" +description = "Enable maintainers to clear their backlog with one command" +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.8" +dependencies = [ + "click>=8.0.0", + "requests>=2.28.0", + "rich>=13.0.0", +] + +[project.scripts] +ubounty = "ubounty.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["ubounty"] diff --git a/ubounty/__init__.py b/ubounty/__init__.py new file mode 100644 index 0000000..82a01f7 --- /dev/null +++ b/ubounty/__init__.py @@ -0,0 +1,3 @@ +"""Ubounty CLI - Enable maintainers to clear their backlog with one command.""" + +__version__ = "0.1.0" diff --git a/ubounty/browse.py b/ubounty/browse.py new file mode 100644 index 0000000..6065a92 --- /dev/null +++ b/ubounty/browse.py @@ -0,0 +1,185 @@ +"""Browse command for discovering bounties.""" + +from typing import Optional, List, Dict + +import click +import requests +from rich.console import Console +from rich.table import Table +from rich.panel import Panel +from rich.text import Text + +console = Console() + +GITHUB_API_BASE = "https://api.github.com" + + +def search_bounties( + language: Optional[str] = None, + min_amount: Optional[int] = None, + max_amount: Optional[int] = None, + difficulty: Optional[str] = None, + page: int = 1, + per_page: int = 10, +) -> List[Dict]: + """Search GitHub for bounty-labeled issues.""" + # Build search query + query_parts = ["label:bounty", "state:open"] + + if language: + query_parts.append(f"language:{language}") + + query = " ".join(query_parts) + + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + params = { + "q": query, + "sort": "updated", + "order": "desc", + "per_page": per_page, + "page": page, + } + + try: + response = requests.get( + f"{GITHUB_API_BASE}/search/issues", + headers=headers, + params=params, + timeout=10, + ) + response.raise_for_status() + data = response.json() + + items = data.get("items", []) + + # Filter by amount if specified + filtered_items = [] + for item in items: + amount = extract_amount(item) + + if min_amount and amount and amount < min_amount: + continue + if max_amount and amount and amount > max_amount: + continue + if difficulty: + item_difficulty = extract_difficulty(item) + if item_difficulty and item_difficulty.lower() != difficulty.lower(): + continue + + item["_parsed_amount"] = amount + filtered_items.append(item) + + return filtered_items + + except requests.RequestException as e: + console.print(f"[red]Error fetching bounties: {e}[/red]") + return [] + + +def extract_amount(issue: dict) -> Optional[int]: + """Extract bounty amount from issue title or labels.""" + # Check labels for amount patterns + for label in issue.get("labels", []): + name = label.get("name", "").lower() + # Match patterns like "$100", "$1000", "100 usdc", etc. + import re + match = re.search(r"\$?(\d+)\s*(usdc|usd|usdt)?", name, re.IGNORECASE) + if match: + return int(match.group(1)) + + # Check title + title = issue.get("title", "") + import re + match = re.search(r"\$(\d+)|(\d+)\s*(usdc|usd)", title, re.IGNORECASE) + if match: + return int(match.group(1) or match.group(2)) + + return None + + +def extract_difficulty(issue: dict) -> Optional[str]: + """Extract difficulty from issue labels.""" + for label in issue.get("labels", []): + name = label.get("name", "").lower() + if any(d in name for d in ["easy", "beginner", "good first"]): + return "easy" + if any(d in name for d in ["medium", "intermediate"]): + return "medium" + if any(d in name for d in ["hard", "advanced", "expert"]): + return "hard" + return None + + +def display_bounties(bounties: List[Dict], page: int, per_page: int) -> None: + """Display bounties in a clean table format.""" + if not bounties: + console.print(Panel("[yellow]No bounties found matching your criteria.[/yellow]")) + return + + table = Table( + title=f"🎯 Open Bounties (Page {page})", + show_header=True, + header_style="bold magenta", + ) + + table.add_column("Amount", style="green", width=10) + table.add_column("Title", style="cyan", max_width=50) + table.add_column("Repo", style="blue", width=30) + table.add_column("Updated", style="dim", width=12) + + for bounty in bounties: + amount = bounty.get("_parsed_amount") + amount_str = f"${amount}" if amount else "TBD" + + title = bounty.get("title", "")[:47] + if len(bounty.get("title", "")) > 47: + title += "..." + + repo_url = bounty.get("repository_url", "") + repo = repo_url.replace("https://api.github.com/repos/", "") if repo_url else "N/A" + + updated = bounty.get("updated_at", "")[:10] + + table.add_row(amount_str, title, repo, updated) + + console.print(table) + console.print(f"\n[dim]Showing {len(bounties)} results. Use --page for more.[/dim]") + + +@click.command() +@click.option("--language", "-l", help="Filter by programming language (e.g., python, javascript)") +@click.option("--min-amount", "-min", type=int, help="Minimum bounty amount in USD") +@click.option("--max-amount", "-max", type=int, help="Maximum bounty amount in USD") +@click.option("--difficulty", "-d", type=click.Choice(["easy", "medium", "hard"], case_sensitive=False), help="Filter by difficulty") +@click.option("--page", "-p", default=1, type=int, help="Page number for pagination") +@click.option("--per-page", default=10, type=int, help="Results per page (max 30)") +def browse(language, min_amount, max_amount, difficulty, page, per_page): + """Browse available bounties from GitHub. + + Discover open bounties with filters for language, amount, and difficulty. + + Examples: + + ubounty browse # List all bounties + ubounty browse --language python # Python bounties only + ubounty browse --min-amount 100 # $100+ bounties + ubounty browse --difficulty easy # Easy bounties + """ + console.print("[bold]🔍 Searching for bounties...[/bold]\n") + + per_page = min(per_page, 30) # GitHub API limit + + bounties = search_bounties( + language=language, + min_amount=min_amount, + max_amount=max_amount, + difficulty=difficulty, + page=page, + per_page=per_page, + ) + + display_bounties(bounties, page, per_page) diff --git a/ubounty/cli.py b/ubounty/cli.py new file mode 100644 index 0000000..15514dc --- /dev/null +++ b/ubounty/cli.py @@ -0,0 +1,21 @@ +"""Main CLI entry point for ubounty.""" + +import click + +from ubounty.browse import browse +from ubounty.wallet import wallet + + +@click.group() +@click.version_option() +def main(): + """Ubounty CLI - Discover and claim bounties from your terminal.""" + pass + + +main.add_command(browse) +main.add_command(wallet) + + +if __name__ == "__main__": + main() diff --git a/ubounty/wallet.py b/ubounty/wallet.py new file mode 100644 index 0000000..10745bf --- /dev/null +++ b/ubounty/wallet.py @@ -0,0 +1,175 @@ +"""Wallet connection flow for CLI.""" + +import os +import json +from typing import Optional, Dict +import click +from pathlib import Path +from rich.console import Console +from rich.panel import Panel +from rich.prompt import Prompt, Confirm + +console = Console() + +# Config directory +CONFIG_DIR = Path.home() / ".ubounty" +WALLET_FILE = CONFIG_DIR / "wallet.json" + + +def get_wallet_config() -> Optional[Dict]: + """Load existing wallet configuration.""" + if not WALLET_FILE.exists(): + return None + + try: + with open(WALLET_FILE, "r") as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return None + + +def save_wallet_config(config: dict) -> bool: + """Save wallet configuration to disk.""" + try: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + with open(WALLET_FILE, "w") as f: + json.dump(config, f, indent=2) + # Secure the file (readable only by owner) + os.chmod(WALLET_FILE, 0o600) + return True + except IOError as e: + console.print(f"[red]Error saving wallet: {e}[/red]") + return False + + +def validate_address(address: str, wallet_type: str) -> bool: + """Basic validation of wallet address format.""" + if wallet_type == "ethereum": + # Ethereum addresses start with 0x and are 42 chars + return address.startswith("0x") and len(address) == 42 + elif wallet_type == "solana": + # Solana addresses are base58, typically 32-44 chars + return len(address) >= 32 and len(address) <= 44 + elif wallet_type == "bitcoin": + # Bitcoin addresses vary, basic check + return len(address) >= 26 and len(address) <= 62 + return True # Accept unknown types + + +@click.group() +def wallet(): + """Manage wallet connection for receiving payouts.""" + pass + + +@wallet.command("connect") +@click.option("--address", "-a", help="Wallet address") +@click.option("--type", "-t", "wallet_type", + type=click.Choice(["ethereum", "solana", "bitcoin", "other"]), + help="Wallet type") +@click.option("--force", "-f", is_flag=True, help="Overwrite existing wallet without confirmation") +def connect(address: Optional[str], wallet_type: Optional[str], force: bool): + """Connect a wallet to receive bounty payouts. + + Your wallet address is stored locally in ~/.ubounty/wallet.json + + Examples: + + ubounty wallet connect + ubounty wallet connect --address 0x123... --type ethereum + """ + existing = get_wallet_config() + + # Check for existing wallet + if existing and not force: + console.print(Panel( + f"[yellow]⚠️ Existing wallet found:[/yellow]\n" + f" Type: {existing.get('type', 'unknown')}\n" + f" Address: {existing.get('address', 'N/A')[:20]}...", + title="Warning" + )) + if not Confirm.ask("Do you want to overwrite this wallet?"): + console.print("[dim]Wallet connection cancelled.[/dim]") + return + + # Get wallet type + if not wallet_type: + wallet_type = Prompt.ask( + "Select wallet type", + choices=["ethereum", "solana", "bitcoin", "other"], + default="ethereum" + ) + + # Get wallet address + if not address: + address = Prompt.ask("Enter your wallet address") + + # Validate address + if not validate_address(address, wallet_type): + console.print(f"[red]⚠️ Warning: Address format doesn't match typical {wallet_type} addresses.[/red]") + if not Confirm.ask("Continue anyway?"): + console.print("[dim]Wallet connection cancelled.[/dim]") + return + + # Save wallet + config = { + "type": wallet_type, + "address": address, + "connected_at": __import__("datetime").datetime.now().isoformat(), + } + + if save_wallet_config(config): + console.print(Panel( + f"[green]✅ Wallet connected successfully![/green]\n\n" + f" Type: {wallet_type}\n" + f" Address: {address[:20]}...{address[-6:]}\n" + f" Stored at: {WALLET_FILE}", + title="Success" + )) + else: + console.print("[red]Failed to save wallet configuration.[/red]") + + +@wallet.command("show") +def show(): + """Display currently connected wallet.""" + config = get_wallet_config() + + if not config: + console.print(Panel( + "[yellow]No wallet connected.[/yellow]\n\n" + "Use [bold]ubounty wallet connect[/bold] to add one.", + title="Wallet Status" + )) + return + + address = config.get("address", "N/A") + console.print(Panel( + f"[green]✅ Wallet connected[/green]\n\n" + f" Type: {config.get('type', 'unknown')}\n" + f" Address: {address}\n" + f" Connected: {config.get('connected_at', 'N/A')[:10]}", + title="Wallet Status" + )) + + +@wallet.command("disconnect") +@click.option("--force", "-f", is_flag=True, help="Skip confirmation") +def disconnect(force: bool): + """Remove connected wallet.""" + config = get_wallet_config() + + if not config: + console.print("[dim]No wallet to disconnect.[/dim]") + return + + if not force: + if not Confirm.ask("Are you sure you want to disconnect your wallet?"): + console.print("[dim]Cancelled.[/dim]") + return + + try: + WALLET_FILE.unlink() + console.print("[green]✅ Wallet disconnected successfully.[/green]") + except IOError as e: + console.print(f"[red]Error disconnecting: {e}[/red]")