diff --git a/.gitignore b/.gitignore index f38d699..82618b1 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ coverage/ # Tribble .tribble/ + +# Crash dumps +**/*.core diff --git a/Cargo.toml b/Cargo.toml index 5fe10b3..c7fa0e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] -#members = ["backpack/cli", "backpack/lib", "backpack/e2e", "xtask"] members = ["backpack/cli", "backpack/lib", "xtask"] +#members = ["backpack/lib", "xtask"] resolver = "2" [workspace.dependencies] @@ -9,5 +9,8 @@ reqwest = { version = "0.13", default-features = false, features = ["blocking", anyhow = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -tokio = { version = "1.48.0", features = ["macros", "process", "rt-multi-thread"] } +tokio = { version = "1.49.0", features = ["macros", "process", "rt-multi-thread"] } tracing = "0.1.44" +once_cell = "1.18.0" +tracing-subscriber = "0.3" +terminal-banner = "0.4.1" diff --git a/README.md b/README.md index c12ac6b..2dec0bc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# github-bot +# GitHub Bot + +![Logo](assets/logo.png) [![Licenses](https://github.com/yonasBSD/github-bot/actions/workflows/licenses.yaml/badge.svg)](https://github.com/yonasBSD/github-bot/actions/workflows/licenses.yaml) [![Linting](https://github.com/yonasBSD/github-bot/actions/workflows/lint.yaml/badge.svg)](https://github.com/yonasBSD/github-bot/actions/workflows/lint.yaml) @@ -7,325 +9,74 @@ [![GitHub Release](https://img.shields.io/github/release/yonasBSD/github-bot.svg)](https://github.com/yonasBSD/github-bot/releases/latest) [![License](https://img.shields.io/github/license/yonasBSD/github-bot.svg)](https://github.com/yonasBSD/github-bot/blob/main/LICENSE.md) -A powerful GitHub bot for your terminal that integrates GitHub client commands with an extensible plugin system. - -## Features - -- **Terminal-First Design**: Interact with GitHub directly from your command line -- **Workspace Architecture**: Organized as a Cargo workspace with separate CLI and library crates -- **Plugin System**: Extensible architecture supporting custom plugins -- **GitHub Integration**: Seamless integration with GitHub's API and features via the octocrab library -- **Git Operations**: Built-in Git functionality for repository management -- **Cross-Platform**: Built in Rust for performance and reliability across platforms -- **Comprehensive CI/CD**: Extensive GitHub Actions workflows for quality assurance - -## Architecture - -The project is organized as a Cargo workspace with two main components: - -### CLI (`backpack/cli`) -The command-line interface that users interact with. It provides: -- Command parsing and execution -- User-facing commands (hello, maintain, merge, wip) -- Integration with the core library - -### Library (`backpack/lib`) -The core functionality library that provides: -- GitHub API integration (`github/` module) -- Git operations (`git/` module) -- Plugin system (`plugins/` module) -- CLI utilities (`cli/` module) - -This separation allows the core functionality to be reusable in other contexts while keeping the CLI focused on user interaction. - -## Installation - -### From Source - -```bash -git clone https://github.com/yonasBSD/github-bot.git -cd github-bot -cargo build --release -``` - -The CLI binary will be available at `target/release/cli`. - -### Using Cargo - -```bash -cargo install --git https://github.com/yonasBSD/github-bot --bin cli -``` - -## Quick Start - -```bash -# Run the bot -./target/release/cli - -# Or if installed via cargo -cli - -# View available commands -cli --help - -# Run specific commands -cli hello -cli merge --help -``` - -## Configuration - -The bot can be configured using environment variables or a configuration file. Create a `.env` file in the project root or set the following environment variables: - -```env -GITHUB_TOKEN=your_github_token -GITHUB_USER=your_username -``` - -### Configuration File - -Create a configuration file (e.g., `config.toml`) with your preferred settings: - -```toml -[github] -token = "your_github_token" -user = "your_username" - -[plugins] -enabled = ["plugin1", "plugin2"] -``` - -## Plugin Development - -github-bot includes a plugin system located in `backpack/lib/src/plugins/`. The plugin architecture supports extensible functionality for GitHub operations. - -### Plugin Structure - -The plugin system is organized in the library crate (`backpack/lib`): - -``` -backpack/lib/src/plugins/ -├── mod.rs # Plugin trait and core functionality -├── color.rs # Color plugin implementation -└── tests.rs # Plugin tests -``` - -### Creating a Plugin - -Plugins implement the core plugin trait defined in the library. Here's a basic example: - -```rust -use github_bot_lib::plugins::Plugin; - -pub struct MyCustomPlugin { - // Plugin state -} - -impl Plugin for MyCustomPlugin { - fn name(&self) -> &str { - "my_custom_plugin" - } - - fn execute(&self, context: &PluginContext) -> Result<(), PluginError> { - // Your plugin logic here - Ok(()) - } -} -``` - -### Available Plugins - -- **Color Plugin** (`color.rs`) - Provides colorized output functionality - -To add your plugin: - -1. Create a new file in `backpack/lib/src/plugins/` -2. Implement the `Plugin` trait -3. Register it in `backpack/lib/src/plugins/mod.rs` -4. Add tests in the plugin's test file - -## Available Commands - -The bot provides several commands for GitHub workflow automation: +A GitHub bot for your terminal. -### Core Commands +Integrates [ghk](https://github.com/bymehul/ghk) with custom commands, and plugins with built-in [script.rs scripting](https://github.com/yonasBSD/script.rs/tree/feat/stdlib). -- **`hello`** - Welcome/introductory command -- **`maintain`** - Repository maintenance operations -- **`merge`** - Pull request merge operations and workflows -- **`wip`** - Work-in-progress management - -For detailed help on each command: - -```bash -github-bot --help -``` - -## Development - -### Prerequisites - -- Rust 1.70 or later -- Cargo -- Git - -### Building - -This is a Cargo workspace project with multiple crates: - -```bash -# Clone the repository -git clone https://github.com/yonasBSD/github-bot.git -cd github-bot - -# Build all workspace members -cargo build - -# Build in release mode -cargo build --release - -# Build specific workspace member -cargo build -p cli - -# Run tests for all workspace members -cargo test - -# Run the CLI -cargo run -p cli - -# Or after building -./target/release/cli -``` - -### Development Tools - -This project uses a comprehensive set of development tools: - -- **Formatting**: `cargo fmt` (configured via `rustfmt.toml`) -- **Linting**: `cargo clippy` (configured via `clippy.toml`) -- **Testing**: `cargo nextest` (configured via `.config/nextest.toml`) -- **Task Automation**: Multiple options available: - - `just` (justfile) - - `cargo-make` (Makefile.toml) - - `task` (Taskfile.dist.yaml) -- **Git Hooks**: - - Lefthook (`.lefthook.toml`) - - Pre-commit (`.pre-commit-config.yaml`) -- **Changelogs**: `git-cliff` (cliff.toml) or `cocogitto` (cog.toml) -- **Version Management**: `mise` (`.mise.toml`) -- **Security Scanning**: `trivy` (trivy.yaml) -- **License Checking**: `cargo-deny` (deny.toml) - -```bash -# Format code -cargo fmt - -# Run clippy -cargo clippy --all-targets --all-features - -# Run tests with nextest -cargo nextest run - -# Run all checks (using just) -just check - -# Or using cargo-make -cargo make check - -# Or using task -task check - -# Install git hooks -lefthook install -# or -pre-commit install -``` - -## CI/CD - -The project includes comprehensive GitHub Actions workflows for: - -- **Code Quality**: Formatting and linting checks -- **Testing**: Automated tests with code coverage -- **Security**: Security audits and vulnerability scanning -- **Packaging**: Release builds and artifact generation -- **Cross-Platform**: Multi-platform build verification - -## Project Structure - -This is a Cargo workspace project with the following structure: - -``` -github-bot/ -├── .cargo/ # Cargo configuration -├── .config/ # Tool configurations (nextest, etc.) -├── .github/ # GitHub Actions workflows -│ └── workflows/ # CI/CD pipeline definitions -├── backpack/ # Main application workspace -│ ├── cli/ # Command-line interface -│ │ └── src/ -│ │ └── commands/ # Command implementations -│ │ ├── hello/ # Hello command -│ │ ├── maintain/ # Maintenance commands -│ │ ├── merge/ # Merge operations -│ │ └── wip/ # Work-in-progress commands -│ └── lib/ # Core library -│ ├── src/ -│ │ ├── cli/ # CLI utilities -│ │ ├── git/ # Git operations -│ │ ├── github/ # GitHub API integration -│ │ └── plugins/ # Plugin system -│ └── tests/ # Integration tests -├── manifests/ # Kubernetes/deployment manifests -├── packaging/ # Package build configurations -│ └── nfpm/ # NFPM (packaging tool) specs -├── xtask/ # Build automation tasks -├── Cargo.toml # Workspace manifest -└── README.md # This file +## Features +- plugin infrastructure + - built-in custom [Rhai scripting](https://github.com/yonasBSD/script.rs/tree/feat/stdlib) + - support for [ruviz scientific graph plotting](https://github.com/Ameyanagi/ruviz) + - High-performance 2D plotting library for Rust combining matplotlib's ease-of-use with Makie's performance + - file system access + - [env.rs](https://github.com/yonasBSD/env.rs) + - automatically read `.env`, `.env.$APP_ENV`, and `.env.local` files + - running shell commands + - working with JSON, YAML, and TOML files +- custom commands + - maintain :: Maintain one or more repositories (cleanup, rerun, or release) + - merge :: Merge Dependabot PRs for a specific repository + - wip :: Work-in-progress commit helper + - prune :: Prune local branches that don't exist remotely +- [ghk](https://github.com/bymehul/ghk) integration + +| Command | Alias | Purpose | Runs... | +|---------|-------|---------|---------| +| `setup` | | Install requirements | (Checks requirements) | +| `init` | | Start tracking folder | `git init` | +| `login` / `logout` | | GitHub auth | `gh auth login` | +| `create` | | Create repo on GitHub | `gh repo create` | +| `push` | `save` | Save changes | `git add -A && git commit && git push` | +| `pull` | `sync` | Download changes | `git pull` | +| `clone ` | `download` | Download repo | `gh repo clone` | +| `status` | | Show status | `git status` | +| `diff` | | Preview changes | `git diff` | +| `history` | `log` | Show recent saves | `git log` | +| `undo` | | Undo last commit | `git reset --soft HEAD~1` | +| `open` | | Open in browser | `gh browse` | +| `branch` | | List/switch branches | `git branch` | +| `ignore` | | Add .gitignore | (Writes .gitignore) | +| `license` | | Add license file | (Writes LICENSE) | +| `config` | | View/edit settings | (Edits config) | +| `user list/switch` | | Manage accounts | (Internal auth) | +| `completions` | | Shell completions | (Generates script) | + +## Help + +```console +GitHub automation bot + +Usage: github-bot [OPTIONS] + +Commands: + maintain Maintain one or more repositories (cleanup, rerun, or release) + merge Merge Dependabot PRs for a specific repository + wip Work-in-progress commit helper. Push all uncommitted changes using the last commit + prune Prune local branches that don't exist remotely + git Simple GitHub helper. Push code without the complexity + hello Ping test + help Print this message or the help of the given subcommand(s) + +Options: + -t, --token Optional GitHub Personal Access Token (PAT) with 'repo' scope. If not provided, the program will look for the `GITHUB_TOKEN` environment variable + -q, --quiet Suppress output (errors still shown) + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + --nocolor Disable colored output + -h, --help Print help + -V, --version Print version ``` -## Contributing - -Contributions are welcome! Please follow these guidelines: - -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/amazing-feature`) -3. Make your changes -4. Run tests and linting (`cargo test && cargo fmt && cargo clippy`) -5. Commit your changes (`git commit -m 'Add amazing feature'`) -6. Push to the branch (`git push origin feature/amazing-feature`) -7. Open a Pull Request - -Please ensure your code follows the project's coding standards and includes appropriate tests. - -## Security - -For security concerns, please review [SECURITY.md](SECURITY.md) and report vulnerabilities responsibly. - -## License - -This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. - -## Acknowledgments - -- Built with [Rust](https://www.rust-lang.org/) -- GitHub API integration powered by [octocrab](https://github.com/XAMPPRocky/octocrab) -- Comprehensive CI/CD workflows -- Inspired by the need for efficient terminal-based GitHub workflows - -## Support - -- **Issues**: [GitHub Issues](https://github.com/yonasBSD/github-bot/issues) -- **Discussions**: [GitHub Discussions](https://github.com/yonasBSD/github-bot/discussions) - -## Roadmap - -See [TODO.md](TODO.md) for planned features and improvements. - -## Related Projects - -- [rust-ci-github-actions-workflow](https://github.com/yonasBSD/rust-ci-github-actions-workflow) - Template used for CI/CD setup - --- Made with ❤️ and Rust diff --git a/TODO.md b/TODO.md index 2d55edb..ef89fa7 100644 --- a/TODO.md +++ b/TODO.md @@ -2,12 +2,14 @@ ## Features - github-bot.rs + - re-run failed workflow runs - report on GitHub API status - modify CLI arguments - add CLI flags - git-init - gitignore - try-rs + - clean build artifacts (https://github.com/tbillington/kondo) - bot.rs - generic bot - script.rs diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..78cb40d Binary files /dev/null and b/assets/logo.png differ diff --git a/backpack/cli/Cargo.toml b/backpack/cli/Cargo.toml index 0d4c88f..9fe47b5 100644 --- a/backpack/cli/Cargo.toml +++ b/backpack/cli/Cargo.toml @@ -11,19 +11,19 @@ categories = ["template"] [dependencies] github-bot-lib = { path = "../lib" } -reqwest.workspace = true -clap.workspace = true -anyhow.workspace = true +reqwest = { workspace = true } +clap = { workspace = true } +anyhow = { workspace = true } human-panic = "2.0.6" colored = "3.0.0" dialoguer = "0.12.0" -tracing.workspace = true +tracing = { workspace = true } tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] } -terminal-banner = { version = "0.4.1", features = ["color"] } -console-subscriber = "0.5.0" -rootcause = "0.11.0" -rootcause-backtrace = "0.11.0" -tokio.workspace = true +terminal-banner = { workspace = true } +rootcause = "0.11.1" +rootcause-backtrace = "0.11.1" +tokio = { workspace = true } +env-rs = { git = "https://github.com/yonasBSD/env.rs", version = "0.1.0" } [[bin]] name = "github-bot" diff --git a/backpack/cli/src/commands/git/mod.rs b/backpack/cli/src/commands/git/mod.rs new file mode 100644 index 0000000..b52bb59 --- /dev/null +++ b/backpack/cli/src/commands/git/mod.rs @@ -0,0 +1,19 @@ +use clap::Parser; +use rootcause::hooks::Hooks; +use rootcause_backtrace::BacktraceCollector; +use tracing::instrument; + +use github_bot_lib::cli::Args; +use github_bot_lib::ghk; + +#[instrument(level = "debug", target = "errors::rootcause", name = "run")] +pub fn run() -> anyhow::Result<()> { + // Capture backtraces for all errors + Hooks::new() + .report_creation_hook(BacktraceCollector::new_from_env()) + .install() + .expect("failed to install hooks"); + + let args = Args::parse(); + ghk::main(args) +} diff --git a/backpack/cli/src/commands/maintain/mod.rs b/backpack/cli/src/commands/maintain/mod.rs index b5ea2c5..68cbe40 100644 --- a/backpack/cli/src/commands/maintain/mod.rs +++ b/backpack/cli/src/commands/maintain/mod.rs @@ -1,11 +1,12 @@ use clap::Parser; -use colored::Colorize; use rootcause::hooks::Hooks; use rootcause_backtrace::BacktraceCollector; use tracing::instrument; +use tracing_subscriber::{EnvFilter, fmt, prelude::*}; use github_bot_lib::cli::Args; use github_bot_lib::github; +use github_bot_lib::log::{LogFormat, Printer, ScreenLogger, SimpleLogger, Verbosity}; #[instrument(level = "debug", target = "errors::rootcause", name = "run")] pub fn run(repo: String, action: &Option) -> anyhow::Result<()> { @@ -15,6 +16,8 @@ pub fn run(repo: String, action: &Option) -> anyhow::Result<()> { .install() .expect("failed to install hooks"); + // Initialize basic CLI output + println!("Starting maintenance for {}", repo); let _cli = Args::parse(); let Ok(client) = github::GitHubClient::new() else { @@ -29,47 +32,50 @@ pub fn run(repo: String, action: &Option) -> anyhow::Result<()> { let is_release_action = *action == Some("release".to_string()); if is_release_action { - println!("{}", "!!! DANGER: 'release' action selected. This will delete all existing releases and tags.".red().bold()); + eprintln!( + "!!! DANGER: 'release' action selected. This will delete all existing releases and tags." + ); // Blocking confirmation prompt - let confirmation = dialoguer::Confirm::new() - .with_prompt("Are you absolutely sure you want to proceed with 'release' cleanup?") - .interact() - .unwrap_or(false); + let confirmation = true; if !confirmation { - println!("{}", "Exiting...".red()); + println!("Exiting..."); return Ok(()); } } - println!( - "{}", - format!("\n--- Starting maintenance for {repo} ---") - .cyan() - .bold() - ); + println!("Deleting branch '{}'.", repo); // Cleanup Repo (Always executed unless 'rerun') github::delete_failed_workflows(&client, &repo); - github::delete_old_container_versions(&client, &repo); + println!("Deleted failed workflows"); - println!("{}", "Deleted failed workflows.".green()); - println!("{}", "Deleted old containers versions.".green()); - println!(); + github::delete_old_container_versions(&client, &repo); + println!("Deleted old containers versions"); // Create new release (only if 'release' action is specified) if is_release_action { - // Delete all releases and tags first - if let Err(e) = github::delete_all_releases(&client, &repo) { - eprintln!( - "{}", - format!("Failed to complete full release cleanup for {repo}: {e}").red() - ); - } else { - // Then create the new release - github::create_release(&client, &repo)?; + println!("Starting full release cleanup"); + + match github::delete_all_releases(&client, &repo) { + Err(e) => { + eprintln!( + "Failed to complete full release cleanup for {}: {}", + repo, e + ); + } + Ok(_) => { + println!("Deleted all releases and tags"); + + // Then create the new release + github::create_release(&client, &repo)?; + + println!("Created new release"); + } } + + println!("Release cleanup complete"); } Ok(()) diff --git a/backpack/cli/src/commands/mod.rs b/backpack/cli/src/commands/mod.rs index f39235b..d929db9 100644 --- a/backpack/cli/src/commands/mod.rs +++ b/backpack/cli/src/commands/mod.rs @@ -1,4 +1,6 @@ +pub mod git; pub mod hello; pub mod maintain; pub mod merge; +pub mod prune; pub mod wip; diff --git a/backpack/cli/src/commands/prune/mod.rs b/backpack/cli/src/commands/prune/mod.rs new file mode 100644 index 0000000..3044758 --- /dev/null +++ b/backpack/cli/src/commands/prune/mod.rs @@ -0,0 +1,16 @@ +use rootcause::hooks::Hooks; +use rootcause_backtrace::BacktraceCollector; +use tracing::instrument; + +use github_bot_lib::git; + +#[instrument(level = "debug", target = "errors::rootcause", name = "run")] +pub fn run(confirm: bool) -> anyhow::Result<()> { + // Capture backtraces for all errors + Hooks::new() + .report_creation_hook(BacktraceCollector::new_from_env()) + .install() + .expect("failed to install hooks"); + + git::prune(confirm) +} diff --git a/backpack/cli/src/commands/rerun/mod.rs b/backpack/cli/src/commands/rerun/mod.rs new file mode 100644 index 0000000..6b80929 --- /dev/null +++ b/backpack/cli/src/commands/rerun/mod.rs @@ -0,0 +1,86 @@ +use rootcause::hooks::Hooks; +use rootcause_backtrace::BacktraceCollector; +use tracing::instrument; + +#[instrument(level = "debug", target = "errors::rootcause", name = "run")] +pub fn run() -> anyhow::Result<()> { + // Capture backtraces for all errors + Hooks::new() + .report_creation_hook(BacktraceCollector::new_from_env()) + .install() + .expect("failed to install hooks"); + + let args = Args::parse(); + + // Get commit SHA + let commit = match args.commit { + Some(c) => c, + None => { + println!("No commit specified, using latest commit..."); + get_latest_commit()? + } + }; + + println!("Using commit: {}", commit); + + // Get repository + let repo = match args.repo { + Some(r) => r, + None => { + println!("No repository specified, detecting from git remote..."); + get_repo_from_git()? + } + }; + + println!("Repository: {}\n", repo); + + // Get workflow runs for the commit + println!("Fetching workflow runs..."); + let runs = get_workflow_runs(&args.token, &repo, &commit).await?; + + if runs.is_empty() { + println!("No workflow runs found for this commit."); + return Ok(()); + } + + // Filter for failed runs + let failed_runs: Vec<_> = runs + .iter() + .filter(|run| { + run.conclusion.as_deref() == Some("failure") + || run.conclusion.as_deref() == Some("timed_out") + || run.conclusion.as_deref() == Some("cancelled") + }) + .collect(); + + if failed_runs.is_empty() { + println!("No failed workflow runs found for this commit."); + println!("\nAll workflows:"); + for run in &runs { + println!(" - {} ({}): {:?}", run.name, run.status, run.conclusion); + } + return Ok(()); + } + + println!("Found {} failed workflow run(s):\n", failed_runs.len()); + + for run in &failed_runs { + println!(" - {} (ID: {})", run.name, run.id); + println!(" Status: {}", run.status); + println!(" Conclusion: {:?}", run.conclusion); + println!(" URL: {}\n", run.html_url); + } + + // Re-run failed workflows + println!("Re-running failed workflows...\n"); + for run in &failed_runs { + print!("Re-running '{}'... ", run.name); + match rerun_workflow(&args.token, &repo, run.id).await { + Ok(_) => println!("✓ Success"), + Err(e) => println!("✗ Failed: {}", e), + } + } + + println!("\nDone!"); + Ok(()) +} diff --git a/backpack/cli/src/main.rs b/backpack/cli/src/main.rs index 069e332..ada2c72 100644 --- a/backpack/cli/src/main.rs +++ b/backpack/cli/src/main.rs @@ -1,7 +1,280 @@ mod commands; use clap::Parser; -use commands::{hello, maintain, merge, wip}; +use commands::{git, hello, maintain, merge, prune, wip}; +use std::env; + +use github_bot_lib::cli::{Args, Commands}; +use github_bot_lib::log::{self, LogFormat, Printer, ScreenLogger, SimpleLogger, Verbosity}; +use github_bot_lib::plugins::{self, Event}; +use terminal_banner::Banner; + +#[cfg(not(target_arch = "wasm32"))] +use human_panic::{metadata, setup_panic}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // + // ──────────────────────────────────────────────────────────────── + // Panic handler (native only) + // ──────────────────────────────────────────────────────────────── + // + #[cfg(not(target_arch = "wasm32"))] + setup_panic!( + metadata!() + .authors("Acme Inc. _verbosity = Verbosity::Quiet, + "-v" => _verbosity = Verbosity::Verbose, + "-vv" => _verbosity = Verbosity::Trace, + "--json" => format = LogFormat::Json, + _ => {} + } + } + + // + // ──────────────────────────────────────────────────────────────── + // Initialize our new logger + // ──────────────────────────────────────────────────────────────── + // + log::init(); + let formatter = SimpleLogger; + let logger = Printer::new(formatter, format); + + logger.debug("Logger initialized"); + logger.trace("Tracing enabled"); + logger.debug("Ready to begin"); + + // + // ──────────────────────────────────────────────────────────────── + // Banner (only when RUST_LOG=debug or trace) + // ──────────────────────────────────────────────────────────────── + // + const PROJECT_NAME: &str = env!("CARGO_PKG_NAME"); + const PROJECT_DESC: &str = env!("CARGO_PKG_DESCRIPTION"); + + if std::env::var("RUST_LOG").is_ok() + && ["debug", "trace"].contains(&std::env::var("RUST_LOG").unwrap().to_lowercase().as_str()) + { + let banner = Banner::new() + .text(format!("Welcome to {PROJECT_NAME}!").into()) + .text(PROJECT_DESC.into()) + .render(); + + println!("{banner}"); + } + + // + // ──────────────────────────────────────────────────────────────── + // Parse CLI using your existing struct + // ──────────────────────────────────────────────────────────────── + // + let cli = Args::parse(); + + logger.trace(&format!( + "Parsed CLI arguments: token={:?}, command={:?}", + cli.token, cli.command + )); + + // + // ──────────────────────────────────────────────────────────────── + // Plugin Initialization Phase + // ──────────────────────────────────────────────────────────────── + // + logger.intro("Initializing plugins"); + + plugins::broadcast_event(&[], Event::PluginRegistrationInit).await; + + let plugins = plugins::discover_plugins()?; + for plugin in &plugins { + plugins::broadcast_event( + &plugins, + plugins::Event::PluginRegistered(plugin.manifest.name.clone()), + ) + .await; + } + + plugins::broadcast_event(&plugins, Event::PluginRegistrationEnd).await; + + logger.outro("Plugin registration complete"); + + // + // ──────────────────────────────────────────────────────────────── + // Command dispatch + // ──────────────────────────────────────────────────────────────── + // + match &cli.command { + Commands::Maintain { repo, action } => { + logger.intro("Running maintain command"); + + plugins::broadcast_event(&plugins, Event::CliCommandExecutionInit).await; + + let target_repo = repo.clone(); + let action_arg = action.clone().unwrap_or_else(|| String::from("none")); + + plugins::broadcast_event( + &plugins, + Event::CliCommandExecutionRun { + command: "maintain".into(), + args: vec![target_repo.clone(), action_arg], + }, + ) + .await; + + maintain::run(target_repo.clone(), action)?; + + plugins::broadcast_event(&plugins, Event::CliCommandExecutionEnd).await; + + logger.outro("Maintain command complete"); + } + + Commands::Merge { repo } => { + logger.intro("Running merge command"); + + plugins::broadcast_event(&plugins, Event::CliCommandExecutionInit).await; + + let target_repo = repo.clone(); + + plugins::broadcast_event( + &plugins, + Event::CliCommandExecutionRun { + command: "merge".into(), + args: vec![target_repo.clone()], + }, + ) + .await; + + merge::run(target_repo.clone())?; + + plugins::broadcast_event(&plugins, Event::CliCommandExecutionEnd).await; + + logger.outro("Merge command complete"); + } + + Commands::Wip { + no_push, + no_diff, + rewind, + } => { + logger.intro("Running wip command"); + + plugins::broadcast_event(&plugins, Event::CliCommandExecutionInit).await; + + plugins::broadcast_event( + &plugins, + Event::CliCommandExecutionRun { + command: "wip".into(), + args: vec![ + no_push.to_string(), + no_diff.to_string(), + format!("{:#?}", rewind), + ], + }, + ) + .await; + + wip::run(*no_push, *no_diff, *rewind); + + plugins::broadcast_event(&plugins, Event::CliCommandExecutionEnd).await; + + logger.outro("Wip command complete"); + } + + Commands::Prune { yes } => { + logger.intro("Running prune command"); + + plugins::broadcast_event(&plugins, Event::CliCommandExecutionInit).await; + + plugins::broadcast_event( + &plugins, + Event::CliCommandExecutionRun { + command: "prune".into(), + args: vec![yes.to_string()], + }, + ) + .await; + + if let Err(e) = prune::run(*yes) { + logger.err(&format!("{e}")); + } + + plugins::broadcast_event(&plugins, Event::CliCommandExecutionEnd).await; + + logger.outro("Prune command complete"); + } + + Commands::Git { command } => { + logger.intro("Running git command"); + + plugins::broadcast_event(&plugins, Event::CliCommandExecutionInit).await; + + plugins::broadcast_event( + &plugins, + Event::CliCommandExecutionRun { + command: "git".into(), + args: vec![command.to_string()], + }, + ) + .await; + + git::run()?; + + plugins::broadcast_event(&plugins, Event::CliCommandExecutionEnd).await; + + logger.outro("Git command complete"); + } + + Commands::Hello {} => { + logger.intro("Running hello command"); + + plugins::broadcast_event(&plugins, Event::CliCommandExecutionInit).await; + + plugins::broadcast_event( + &plugins, + Event::CliCommandExecutionRun { + command: "hello".into(), + args: vec![], + }, + ) + .await; + + hello::run()?; + + plugins::broadcast_event(&plugins, Event::CliCommandExecutionEnd).await; + + logger.outro("Hello command complete"); + } + } + + logger.ok("All done"); + Ok(()) +} + +/* +mod commands; + +use clap::Parser; +use commands::{git, hello, maintain, merge, prune, wip}; use std::env; use terminal_banner::Banner; //use tracing_subscriber::filter::LevelFilter; @@ -23,6 +296,9 @@ async fn main() -> anyhow::Result<()> { .support("- Open a support request by email to support@example.com") ); + // Load .env, .env.$APP_ENV, and .env.local, respectively + env_rs::init()?; + const PROJECT_NAME: &str = env!("CARGO_PKG_NAME"); const PROJECT_DESC: &str = env!("CARGO_PKG_DESCRIPTION"); @@ -42,6 +318,7 @@ async fn main() -> anyhow::Result<()> { // 2. Define the first filter (Environment variable) let env_filter = EnvFilter::from_default_env(); + // --- This does not work --- // 3. Combine the filters: Apply both the environment filter AND the max level filter. // Note: When chaining filters (env_filter.and(max_level_filter)), the filter that // allows an event to pass is the intersection of both. @@ -75,7 +352,7 @@ async fn main() -> anyhow::Result<()> { // Parse the command-line arguments tracing::trace!( token = cli.token, - verbosity = ?cli.verbosity.log_level(), + verbosity = ?cli.verbosity.as_ref().and_then(|v| v.log_level()), command = ?cli.command, "Parsed command line arguments" ); @@ -155,6 +432,43 @@ async fn main() -> anyhow::Result<()> { // c. End Event plugins::broadcast_event(&plugins, Event::CliCommandExecutionEnd).await; } + Commands::Prune { yes } => { + // a. Init Event + plugins::broadcast_event(&plugins, Event::CliCommandExecutionInit).await; + + // b. Run Event + let run_event = Event::CliCommandExecutionRun { + command: String::from("prune"), + args: vec![yes.to_string()], + }; + + plugins::broadcast_event(&plugins, run_event).await; + + match prune::run(*yes) { + Ok(_) => {} + Err(e) => eprintln!("{}", e), + } + + // c. End Event + plugins::broadcast_event(&plugins, Event::CliCommandExecutionEnd).await; + } + Commands::Git { command } => { + // a. Init Event + plugins::broadcast_event(&plugins, Event::CliCommandExecutionInit).await; + + // b. Run Event + let run_event = Event::CliCommandExecutionRun { + command: String::from("git"), + args: vec![command.to_string()], + }; + + plugins::broadcast_event(&plugins, run_event).await; + + let () = git::run()?; + + // c. End Event + plugins::broadcast_event(&plugins, Event::CliCommandExecutionEnd).await; + } Commands::Hello {} => { // a. Init Event plugins::broadcast_event(&plugins, Event::CliCommandExecutionInit).await; @@ -176,3 +490,4 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +*/ diff --git a/backpack/lib/Cargo.toml b/backpack/lib/Cargo.toml index ba05e17..9313f3c 100644 --- a/backpack/lib/Cargo.toml +++ b/backpack/lib/Cargo.toml @@ -5,7 +5,7 @@ authors = ["test@example.com"] edition = "2024" license = "MIT" description = "Library for github-bot" -repository = "https://github.com/yonasBSD/rust-ci-github-actions-workflow" +repository = "https://github.com/yonasBSD/github-bot" readme = "README.md" keywords = ["template", "rust"] categories = ["template"] @@ -21,6 +21,8 @@ coverage = [] [dev-dependencies] mockito = "1.7.1" wasm-bindgen-test = "0.3.56" +serde_json = "1" +tokio-test = "0.4" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] smol = "2" @@ -33,19 +35,33 @@ wasm-bindgen-futures = "0.4" demonstrate = { git = "https://github.com/yonasBSD/demonstrate" } litmus = "0.5.5" pretty_assertions = "1.4.1" -clap.workspace = true -reqwest.workspace = true -anyhow.workspace = true -serde.workspace = true -serde_json.workspace = true -tracing.workspace = true +clap = { workspace = true } +reqwest = { workspace = true } +anyhow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +once_cell = { workspace = true } colored = "3.0.0" -url = "2.5.7" +url = "2.5.8" tempfile = "3.24.0" clap-verbosity-flag = { version = "3.0.4", features = ["tracing"] } dirs = "6.0.0" -toml = "0.9.10" -tokio.workspace = true +toml = "0.9.11" +tokio = { workspace = true } futures = "0.3.31" rhai = { version = "1.23.6", features = ["serde"] } rhai-http = "0.7.0" +dialoguer = "0.12" +which = "8.0" +thiserror = "2.0" +libc = "0.2" +indicatif = "0.18" +clap_complete = "4.5.65" +strum = { version = "0.27.2", features = ["derive"] } +git2 = { version = "0.20.3", features = ["vendored-libgit2", "vendored-openssl"] } +chrono = "0.4.42" +cliclack = "0.3.7" +terminal-banner = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +auth-git2 = "0.5.8" diff --git a/backpack/lib/src/cli/mod.rs b/backpack/lib/src/cli/mod.rs index 0201bf0..7305eae 100644 --- a/backpack/lib/src/cli/mod.rs +++ b/backpack/lib/src/cli/mod.rs @@ -1,27 +1,77 @@ -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; +use clap_complete::Shell; +use git2::Repository; +use strum::Display; /// Automate merging and maintenance of Dependabot PRs. #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] +#[command(name = "github-bot")] +#[command(about = "GitHub automation bot")] pub struct Args { /// Optional GitHub Personal Access Token (PAT) with 'repo' scope. /// If not provided, the program will look for the `GITHUB_TOKEN` environment variable. #[arg(short, long, global = true)] pub token: Option, + /// Suppress output (errors still shown) + #[arg(short, long, global = true)] + pub quiet: bool, + #[command(flatten)] - pub verbosity: clap_verbosity_flag::Verbosity, + pub verbosity: Option, + + /// Disable colored output + #[arg(long, global = true)] + pub nocolor: bool, #[command(subcommand)] pub command: Commands, } -#[derive(Subcommand, Debug)] +impl Args { + /// The "Smart Default" logic. + /// Priority: 1. CLI Argument, 2. Git Discovery, 3. Hardcoded Fallback + pub fn resolve_repo(provided: &Option) -> String { + if let Some(repo) = provided { + return repo.clone(); + } + + if let Some(detected) = Self::detect_git_repo() { + return detected; + } + + // Final fallback + "yonasBSD/github-rs".to_string() + } + + fn detect_git_repo() -> Option { + let repo = Repository::discover(std::env::current_dir().ok()?).ok()?; + let remote = repo.find_remote("origin").ok()?; + let url = remote.url()?; + + if url.contains("github.com") { + let parts: Vec<&str> = url + .trim_end_matches(".git") + .split(&['/', ':'][..]) + .collect(); + if parts.len() >= 2 { + let repo_name = parts.last()?; + let owner = parts.get(parts.len() - 2)?; + return Some(format!("{}/{}", owner, repo_name)); + } + } + None + } +} + +#[derive(Subcommand, Debug, Display)] +#[strum(serialize_all = "lowercase")] pub enum Commands { /// Maintain one or more repositories (cleanup, rerun, or release) Maintain { - /// The GitHub repository (or repositories) to maintain (e.g., owner/repo). - #[arg(short, long, default_value = "yonasBSD/github-rs")] + /// The GitHub repository (e.g., owner/repo). If omitted, detects from local git origin. + #[arg(short, long)] repo: String, /// Specific action to perform: 'rerun' failed jobs, 'release' (clean and create v0.1.0), or no action for cleanup. @@ -31,8 +81,8 @@ pub enum Commands { /// Merge Dependabot PRs for a specific repository Merge { - /// The GitHub repository (or repositories) to maintain (e.g., owner/repo). - #[arg(short, long, default_value = "yonasBSD/github-rs")] + /// The GitHub repository (e.g., owner/repo). If omitted, detects from local git origin. + #[arg(short, long)] repo: String, }, @@ -50,6 +100,155 @@ pub enum Commands { rewind: Option, }, + /// Prune local branches that don't exist remotely + Prune { + /// Answer yes to all confirmations + #[arg(short, long)] + yes: bool, + }, + + /// Simple GitHub helper. Push code without the complexity. + Git { + #[command(subcommand)] + command: GitCommands, + }, + /// Ping test - Hello {}, + Hello, +} + +#[derive(Subcommand, Debug, Display)] +#[strum(serialize_all = "lowercase")] +pub enum GitCommands { + /// Check and install requirements + Setup, + + /// Start tracking this folder + Init, + + /// Connect to GitHub + Login, + + /// Disconnect from GitHub + Logout, + + /// Manage GitHub accounts + User { + #[command(subcommand)] + command: UserCmd, + }, + + /// Create a repository on GitHub + Create, + + /// Save changes to GitHub + Push, + + /// Alias for push + #[command(hide = true)] + Save, + + /// Download changes from GitHub + Pull, + + /// Alias for pull + #[command(hide = true)] + Sync, + + /// Download a repository + Clone { + /// Repository (owner/name or URL) + repo: Option, + /// Directory to clone into + dir: Option, + }, + + /// Alias for clone + #[command(hide = true)] + Download { + repo: Option, + dir: Option, + }, + + /// Show current status + Status, + + /// Preview changes before saving + Diff, + + /// Undo last commit (keeps changes) + Undo, + + /// Show recent saves + History { + /// Number of commits to show + #[arg(default_value = "10")] + count: Option, + }, + + /// Alias for history + #[command(hide = true)] + Log { + #[arg(default_value = "10")] + count: Option, + }, + + /// Open repository in browser + Open, + + /// View or edit settings + Config { + /// Setting to view/edit + key: Option, + /// New value + value: Option, + }, + + /// Add .gitignore template + Ignore { + /// Template name (node, python, rust, go, etc) + template: Option, + }, + + /// Add a license file + License { + /// License type + #[arg(value_enum)] + kind: Option, + }, + + /// List or switch branches + Branch { + /// Branch to switch to + name: Option, + }, + + /// Generate shell completions + Completions { + /// Shell to generate for + #[arg(value_enum)] + shell: Shell, + }, +} + +#[derive(Subcommand, Debug, Display)] +#[strum(serialize_all = "lowercase")] +pub enum UserCmd { + /// Show logged in accounts + List, + + /// Switch to a different account + Switch { + /// GitHub username to switch to + name: String, + }, +} + +#[derive(Clone, Debug, Display, ValueEnum)] +#[strum(serialize_all = "lowercase")] +pub enum LicenseKind { + Mit, + Apache, + Gpl, + Unlicense, } diff --git a/backpack/lib/src/ghk/app.rs b/backpack/lib/src/ghk/app.rs new file mode 100644 index 0000000..a94e04e --- /dev/null +++ b/backpack/lib/src/ghk/app.rs @@ -0,0 +1,150 @@ +use crate::cli::{Args, Commands, GitCommands}; +use crate::ghk::config; +use anyhow::Result; +use std::env; + +pub fn run(cli: Args) -> Result<()> { + // Remove GITHUB_TOKEN from environment + if env::var("GITHUB_TOKEN").is_ok() { + unsafe { + env::remove_var("GITHUB_TOKEN"); + } + } + + // Set global flags + config::setquiet(cli.quiet); + config::setnocolor(cli.nocolor); + + // First, check for quiet to avoid unnecessary calls to isfirstrun() + if !cli.quiet && config::isfirstrun() { + welcome(); + + // Save config to mark first run complete + let cfg = config::Config::default(); + if let Err(_e) = cfg.save() { + // In production, we might want to log this differently. + #[cfg(debug_assertions)] + eprintln!("Debug: Failed to save config: {}", _e); + } + } + + match cli.command { + Commands::Git { command } => match command { + GitCommands::Init => crate::ghk::commands::init::run(), + GitCommands::Login => crate::ghk::commands::login::run(), + GitCommands::Logout => crate::ghk::commands::logout::run(), + GitCommands::User { command } => crate::ghk::commands::user::run(command), + GitCommands::Create => crate::ghk::commands::create::run(), + GitCommands::Push | GitCommands::Save => crate::ghk::commands::push::run(), + GitCommands::Pull | GitCommands::Sync => crate::ghk::commands::pull::run(), + GitCommands::Clone { repo, dir } | GitCommands::Download { repo, dir } => { + crate::ghk::commands::clone::run(repo, dir) + } + GitCommands::Status => crate::ghk::commands::status::run(), + GitCommands::Setup => crate::ghk::commands::setup::run(), + GitCommands::Undo => crate::ghk::commands::undo::run(), + GitCommands::History { count } | GitCommands::Log { count } => { + crate::ghk::commands::history::run(count) + } + GitCommands::Open => crate::ghk::commands::open::run(), + GitCommands::Diff => crate::ghk::commands::diff::run(), + GitCommands::Config { key, value } => crate::ghk::commands::config::run(key, value), + GitCommands::Ignore { template } => crate::ghk::commands::ignore::run(template), + GitCommands::License { kind } => crate::ghk::commands::license::run(kind), + GitCommands::Branch { name } => crate::ghk::commands::branch::run(name), + GitCommands::Completions { shell } => crate::ghk::commands::completions::run(shell), + }, + _ => Ok(()), + } +} + +#[cfg(not(debug_assertions))] +fn welcome() { + use std::io::{self, Write}; + + // Production-optimized version - fewer prints, no ANSI codes if nocolor is active + if !config::isnocolor() { + println!( + " + Welcome to ghk!" + ); + println!( + " + Simple GitHub helper - push code without the complexity" + ); + println!( + " + Quick start:" + ); + println!(" ghk setup Check requirements"); + println!(" ghk init Start tracking a project"); + println!(" ghk create Create repo on GitHub"); + println!(" ghk push Save your changes"); + println!( + " + Run ghk --help for all commands +" + ); + } else { + // No ANSI codes for --nocolor + println!( + " + Welcome to ghk!" + ); + println!( + " + Simple GitHub helper - push code without the complexity" + ); + println!( + " + Quick start:" + ); + println!(" ghk setup Check requirements"); + println!(" ghk init Start tracking a project"); + println!(" ghk create Create repo on GitHub"); + println!(" ghk push Save your changes"); + println!( + " + Run ghk --help for all commands +" + ); + } + + // Ensure that the output is flushed if necessary + let _ = io::stdout().flush(); +} + +#[cfg(debug_assertions)] +fn welcome() { + use std::io::{self, Write}; + + // More detailed version for development + println!( + " + =========================================" + ); + println!(" Welcome to ghk! (Development Build)"); + println!(" ========================================="); + println!(); + println!(" Simple GitHub helper - push code without the complexity"); + println!(" Build: {}", env!("CARGO_PKG_VERSION")); + println!(); + println!(" Quick start:"); + println!(" ghk setup Check requirements"); + println!(" ghk init Start tracking a project"); + println!(" ghk create Create repo on GitHub"); + println!(" ghk push Save your changes"); + println!(); + println!(" Debug commands:"); + println!(" ghk --verbose Show detailed logs"); + println!(" ghk --dry-run Test commands"); + println!(); + println!(" Run ghk --help for all commands"); + println!( + " ========================================= +" + ); + + // Under development, always flush for debugging + let _ = io::stdout().flush(); +} diff --git a/backpack/lib/src/ghk/commands/branch.rs b/backpack/lib/src/ghk/commands/branch.rs new file mode 100644 index 0000000..f89a4f0 --- /dev/null +++ b/backpack/lib/src/ghk/commands/branch.rs @@ -0,0 +1,57 @@ +use crate::ghk::{git, util}; +use anyhow::{Result, bail}; +use std::process::Command; + +pub fn run(name: Option) -> Result<()> { + if !git::isrepo() { + util::err("Not a git repository"); + bail!("Not a git repository"); + } + + match name { + // Switch to branch + Some(branch) => { + util::info(&format!("Switching to {branch}...")); + + let status = Command::new("git").args(["checkout", &branch]).status()?; + + if status.success() { + util::ok(&format!("Now on {branch}")); + } else { + // Maybe it's a new branch? + util::info("Branch not found, creating it..."); + let status = Command::new("git") + .args(["checkout", "-b", &branch]) + .status()?; + + if status.success() { + util::ok(&format!("Created and switched to {branch}")); + } else { + util::err("Could not switch branch"); + } + } + } + // List branches + None => { + let current = git::currentbranch().unwrap_or_default(); + + let output = Command::new("git").args(["branch", "--list"]).output()?; + + let text = String::from_utf8_lossy(&output.stdout); + + println!(); + util::info("Branches:"); + for line in text.lines() { + let name = line.trim().trim_start_matches("* "); + if name == current { + println!(" \x1b[32m▶ {}\x1b[0m (current)", name); + } else { + util::dim(&format!(" {name}")); + } + } + println!(); + } + } + + Ok(()) +} diff --git a/backpack/lib/src/ghk/commands/clone.rs b/backpack/lib/src/ghk/commands/clone.rs new file mode 100644 index 0000000..a5aaaa2 --- /dev/null +++ b/backpack/lib/src/ghk/commands/clone.rs @@ -0,0 +1,30 @@ +use crate::ghk::{gh, util}; +use anyhow::Result; +use dialoguer::Input; + +pub fn run(repo: Option, dir: Option) -> Result<()> { + // Check if online + if !gh::isonline() { + util::err("Cannot reach GitHub"); + util::dim("Check your internet connection"); + anyhow::bail!("Offline"); + } + + // Get repo name if not provided + let reponame = match repo { + Some(r) => r, + None => Input::new() + .with_prompt("Repository (owner/name or URL)") + .interact_text()?, + }; + + util::info(&format!("Cloning {reponame}...")); + gh::clonerepo(&reponame, dir.as_deref())?; + + let dirname = + dir.unwrap_or_else(|| reponame.split('/').last().unwrap_or(&reponame).to_string()); + + util::ok(&format!("Downloaded to '{dirname}'")); + util::dim(&format!("cd {dirname} to start working")); + Ok(()) +} diff --git a/backpack/lib/src/ghk/commands/completions.rs b/backpack/lib/src/ghk/commands/completions.rs new file mode 100644 index 0000000..7e218f0 --- /dev/null +++ b/backpack/lib/src/ghk/commands/completions.rs @@ -0,0 +1,11 @@ +use crate::cli::Args; +use anyhow::Result; +use clap::CommandFactory; +use clap_complete::{Shell, generate}; +use std::io; + +pub fn run(shell: Shell) -> Result<()> { + let mut cmd = Args::command(); + generate(shell, &mut cmd, "github-bot", &mut io::stdout()); + Ok(()) +} diff --git a/backpack/lib/src/ghk/commands/config.rs b/backpack/lib/src/ghk/commands/config.rs new file mode 100644 index 0000000..16306cc --- /dev/null +++ b/backpack/lib/src/ghk/commands/config.rs @@ -0,0 +1,37 @@ +use crate::ghk::{config::Config, util}; +use anyhow::Result; + +pub fn run(key: Option, value: Option) -> Result<()> { + let mut cfg = Config::load(); + + match (key, value) { + // Show all settings + (None, None) => { + println!(); + util::info("Current settings:"); + util::dim(&format!(" quiet = {quiet}", quiet = cfg.quiet)); + util::dim(&format!(" nocolor = {nocolor}", nocolor = cfg.nocolor)); + util::dim(&format!( + " editor = {editor}", + editor = cfg.editor.as_deref().unwrap_or("(default)") + )); + util::dim(&format!(" org = {org}", org = cfg.org.as_deref().unwrap_or(""))); + println!(); + util::dim(&format!("Config file: {}", Config::path().display())); + println!(); + } + // Show one setting + (Some(k), None) => match cfg.get(&k) { + Some(v) => println!("{}", v), + None => util::warn(&format!("Unknown setting: {k}")), + }, + // Set a value + (Some(k), Some(v)) => { + cfg.set(&k, &v)?; + util::ok(&format!("{k} = {v}")); + } + _ => {} + } + + Ok(()) +} diff --git a/backpack/lib/src/ghk/commands/create.rs b/backpack/lib/src/ghk/commands/create.rs new file mode 100644 index 0000000..841379e --- /dev/null +++ b/backpack/lib/src/ghk/commands/create.rs @@ -0,0 +1,73 @@ +use crate::ghk::{config::Config, gh, git, util}; +use anyhow::{Result, bail}; +use dialoguer::{Confirm, Input}; + +pub fn run() -> Result<()> { + // Check prerequisites + if !git::isrepo() { + util::err("Not a git repository"); + util::dim("Run 'ghk init' first to set up your project"); + bail!("Not a git repository"); + } + + if !gh::loggedin() { + util::err("Not logged in to GitHub"); + util::dim("Run 'ghk login' first to connect your account"); + bail!("Not logged in"); + } + + // Check if remote already exists + if git::hasremote() { + let url = git::remoteurl().unwrap_or_else(|_| "unknown".to_string()); + util::warn("Repository already connected to GitHub"); + util::dim(&format!(" {url}")); + util::dim("Run 'ghk push' to save your changes"); + return Ok(()); + } + + // Get repo name + let defaultname = std::env::current_dir() + .ok() + .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) + .unwrap_or_else(|| "my-project".to_string()); + + let mut name: String = Input::new() + .with_prompt("Repository name") + .default(defaultname) + .interact_text()?; + + let cfg = Config::load(); + if let Some(org) = cfg.org.as_deref() { + name = format!("{org}/{name}"); + } + + let private = Confirm::new() + .with_prompt("Make it private?") + .default(false) + .interact()?; + + // Make sure there's at least one commit + if git::haschanges()? || !hasanycommits() { + util::info("Creating initial save..."); + git::addall()?; + let _ = git::commit("chore: Initial commit."); + } + + util::info("Creating repository on GitHub..."); + gh::createrepo(&name, private)?; + + util::ok(&format!("Repository '{name}' created!")); + util::dim("Security features have been enabled:"); + util::ok(" dependency graph"); + util::ok(" security updates"); + util::dim("Run 'ghk push' to save your changes"); + Ok(()) +} + +fn hasanycommits() -> bool { + std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .map(|out| out.status.success()) + .unwrap_or(false) +} diff --git a/backpack/lib/src/ghk/commands/diff.rs b/backpack/lib/src/ghk/commands/diff.rs new file mode 100644 index 0000000..4ce84cd --- /dev/null +++ b/backpack/lib/src/ghk/commands/diff.rs @@ -0,0 +1,55 @@ +use crate::ghk::{git, util}; +use anyhow::{Result, bail}; +use git2::{DiffOptions, Repository}; + +pub fn run() -> Result<()> { + if !git::isrepo() { + util::err("Not a git repository"); + bail!("Not a git repository"); + } + + let changes = git::haschanges()?; + if !changes { + util::ok("No changes"); + return Ok(()); + } + + // Run git diff with color + let repo = Repository::open(".")?; + + let mut opts = DiffOptions::new(); + let diff = repo.diff_index_to_workdir(None, Some(&mut opts))?; + + diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { + let text = std::str::from_utf8(line.content()).unwrap(); + let origin = line.origin(); + + match origin { + '+' => print!("\x1b[32m{text}\x1b[0m"), // addition + '-' => print!("\x1b[31m{text}\x1b[0m"), // deletion + ' ' => print!("{text}"), // context + + // hunk header + 'H' => print!("\x1b[1;36m{text}\x1b[0m"), + + // file header + 'F' => print!("\x1b[1;35m{text}\x1b[0m"), + + // metadata (index, mode changes, etc.) + 'B' | 'M' => print!("\x1b[33m{text}\x1b[0m"), + + // fallback + _ => print!("{text}"), + } + + true + })?; + + println!(); + + // Show summary + let files = git::changedfiles()?; + util::info(&format!("{} file(s) changed", files.len())); + + Ok(()) +} diff --git a/backpack/lib/src/ghk/commands/history.rs b/backpack/lib/src/ghk/commands/history.rs new file mode 100644 index 0000000..82be2d2 --- /dev/null +++ b/backpack/lib/src/ghk/commands/history.rs @@ -0,0 +1,32 @@ +use crate::ghk::{git, util}; +use anyhow::{Result, bail}; + +pub fn run(count: Option) -> Result<()> { + if !git::isrepo() { + util::err("Not a git repository"); + bail!("Not a git repository"); + } + + let n = count.unwrap_or(10); + let commits = git::history(n)?; + + if commits.is_empty() { + util::warn("No history yet"); + util::dim("Make some changes and run 'ghk push'"); + return Ok(()); + } + + println!(); + util::info("Recent saves:"); + for commit in &commits { + util::dim(&format!(" {commit}")); + } + + if commits.len() == n { + let more_n = n * 2; + util::dim(&format!(" ... use 'ghk history {more_n}' to see more")); + } + println!(); + + Ok(()) +} diff --git a/backpack/lib/src/ghk/commands/ignore.rs b/backpack/lib/src/ghk/commands/ignore.rs new file mode 100644 index 0000000..75fe766 --- /dev/null +++ b/backpack/lib/src/ghk/commands/ignore.rs @@ -0,0 +1,85 @@ +use crate::ghk::{git, util}; +use anyhow::{Result, bail}; +use dialoguer::Select; +use std::fs; + +const BASE_TEMPLATES: &[(&str, &str)] = &[("core-dumps", "*.core/\n")]; + +const TEMPLATES: &[(&str, &str)] = &[ + ("node", "node_modules/\nnpm-debug.log\n.env\ndist/\n"), + ("python", "__pycache__/\n*.py[cod]\n.env\nvenv/\n.venv/\n"), + ("rust", "target/\nCargo.lock\n"), + ("go", "bin/\npkg/\n*.exe\n"), + ("java", "*.class\n*.jar\ntarget/\n.idea/\n"), + ("web", "node_modules/\ndist/\n.env\n*.log\n"), + ("macos", ".DS_Store\n.AppleDouble\n.LSOverride\n"), + ("windows", "Thumbs.db\nehthumbs.db\nDesktop.ini\n"), + ("linux", "*~\n.fuse_hidden*\n.nfs*\n"), + ("ide", ".idea/\n.vscode/\n*.swp\n*.swo\n.project\n"), +]; + +pub fn run(template: Option) -> Result<()> { + if !git::isrepo() { + util::err("Not a git repository"); + util::dim("Run 'ghk init' first"); + bail!("Not a git repository"); + } + + // Pick template name + let name = match template { + Some(t) => t, + None => { + let names: Vec<&str> = TEMPLATES.iter().map(|(n, _)| *n).collect(); + let idx = Select::new() + .with_prompt("Choose template") + .items(&names) + .default(0) + .interact()?; + names[idx].to_string() + } + }; + + // Find main template content + let main = TEMPLATES.iter().find(|(n, _)| *n == name).map(|(_, c)| *c); + + match main { + Some(main_content) => { + let path = ".gitignore"; + let existing = fs::read_to_string(path).unwrap_or_default(); + + // Build final template: main + all base templates + let mut combined = format!("# {}\n{}", name, main_content); + + for (base_name, base_content) in BASE_TEMPLATES { + combined.push_str(&format!("\n# base: {}\n{}", base_name, base_content)); + } + + // If .gitignore already contains the first line of the main template, skip + let first_line = main_content.lines().next().unwrap_or(""); + if existing.contains(first_line) { + util::warn("Already has this template"); + return Ok(()); + } + + // Append or create new file + let new = if existing.trim().is_empty() { + combined + } else { + format!("{}\n{}", existing.trim(), combined) + }; + + fs::write(path, new)?; + util::ok(&format!( + "Added {} template (with base templates) to .gitignore", + name + )); + } + + None => { + util::err(&format!("Unknown template: {}", name)); + util::dim("Available: node, python, rust, go, java, web, macos, windows, linux, ide"); + } + } + + Ok(()) +} diff --git a/backpack/lib/src/ghk/commands/init.rs b/backpack/lib/src/ghk/commands/init.rs new file mode 100644 index 0000000..0abcd78 --- /dev/null +++ b/backpack/lib/src/ghk/commands/init.rs @@ -0,0 +1,14 @@ +use crate::ghk::{git, util}; +use anyhow::Result; + +pub fn run() -> Result<()> { + if git::isrepo() { + util::warn("Already a git repository"); + util::dim("Your project folder is already set up"); + } else { + git::init()?; + util::ok("Project folder ready"); + util::dim("Created .git folder to track your changes"); + } + Ok(()) +} diff --git a/backpack/lib/src/ghk/commands/license.rs b/backpack/lib/src/ghk/commands/license.rs new file mode 100644 index 0000000..267fcc2 --- /dev/null +++ b/backpack/lib/src/ghk/commands/license.rs @@ -0,0 +1,103 @@ +use crate::cli::LicenseKind; +use crate::ghk::{gh, git, util}; +use anyhow::{Result, bail}; +use chrono::Datelike; +use dialoguer::Select; +use std::fs; + +pub fn run(kind: Option) -> Result<()> { + if !git::isrepo() { + util::err("Not a git repository"); + bail!("Not a git repository"); + } + + if std::path::Path::new("LICENSE").exists() { + util::warn("LICENSE file already exists"); + return Ok(()); + } + + let license = match kind { + Some(ref k) => k, + None => { + let options = ["MIT", "Apache 2.0", "GPL 3.0", "Unlicense"]; + let idx = Select::new() + .with_prompt("Choose license") + .items(&options) + .default(0) + .interact()?; + &match idx { + 0 => LicenseKind::Mit, + 1 => LicenseKind::Apache, + 2 => LicenseKind::Gpl, + _ => LicenseKind::Unlicense, + } + } + }; + + let year = chrono::Local::now().year(); + let author = gh::whoami().unwrap_or_else(|_| "Your Name".to_string()); + + let content = match license { + LicenseKind::Mit => format!( + "MIT License\n\nCopyright (c) {} {}\n\n\ + Permission is hereby granted, free of charge, to any person obtaining a copy\n\ + of this software and associated documentation files (the \"Software\"), to deal\n\ + in the Software without restriction, including without limitation the rights\n\ + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n\ + copies of the Software, and to permit persons to whom the Software is\n\ + furnished to do so, subject to the following conditions:\n\n\ + The above copyright notice and this permission notice shall be included in all\n\ + copies or substantial portions of the Software.\n\n\ + THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n\ + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n\ + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n\ + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n\ + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n\ + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n\ + SOFTWARE.\n", + year, author + ), + LicenseKind::Apache => format!( + "Copyright {} {}\n\n\ + Licensed under the Apache License, Version 2.0 (the \"License\");\n\ + you may not use this file except in compliance with the License.\n\ + You may obtain a copy of the License at\n\n\ + http://www.apache.org/licenses/LICENSE-2.0\n\n\ + Unless required by applicable law or agreed to in writing, software\n\ + distributed under the License is distributed on an \"AS IS\" BASIS,\n\ + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n\ + See the License for the specific language governing permissions and\n\ + limitations under the License.\n", + year, author + ), + LicenseKind::Gpl => format!( + "Copyright (C) {} {}\n\n\ + This program is free software: you can redistribute it and/or modify\n\ + it under the terms of the GNU General Public License as published by\n\ + the Free Software Foundation, either version 3 of the License, or\n\ + (at your option) any later version.\n\n\ + This program is distributed in the hope that it will be useful,\n\ + but WITHOUT ANY WARRANTY; without even the implied warranty of\n\ + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n\ + GNU General Public License for more details.\n\n\ + You should have received a copy of the GNU General Public License\n\ + along with this program. If not, see .\n", + year, author + ), + LicenseKind::Unlicense => String::from( + "This is free and unencumbered software released into the public domain.\n\n\ + Anyone is free to copy, modify, publish, use, compile, sell, or\n\ + distribute this software, either in source code form or as a compiled\n\ + binary, for any purpose, commercial or non-commercial, and by any means.\n\n\ + In jurisdictions that recognize copyright laws, the author or authors\n\ + of this software dedicate any and all copyright interest in the\n\ + software to the public domain.\n\n\ + THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND.\n", + ), + }; + + fs::write("LICENSE", content)?; + util::ok("Created LICENSE file"); + + Ok(()) +} diff --git a/backpack/lib/src/ghk/commands/login.rs b/backpack/lib/src/ghk/commands/login.rs new file mode 100644 index 0000000..1ad997c --- /dev/null +++ b/backpack/lib/src/ghk/commands/login.rs @@ -0,0 +1,23 @@ +use crate::ghk::{gh, util}; +use anyhow::Result; + +pub fn run() -> Result<()> { + if gh::loggedin() { + let user = gh::whoami().unwrap_or_else(|_| "unknown".to_string()); + util::ok(&format!("Already logged in as {}", user)); + util::dim("Run 'ghk logout' first if you want to switch accounts"); + return Ok(()); + } + + util::info("Opening GitHub login..."); + gh::login()?; + + if gh::loggedin() { + let user = gh::whoami().unwrap_or_else(|_| "unknown".to_string()); + util::ok(&format!("Connected as {}", user)); + } else { + util::warn("Login was cancelled"); + } + + Ok(()) +} diff --git a/backpack/lib/src/ghk/commands/logout.rs b/backpack/lib/src/ghk/commands/logout.rs new file mode 100644 index 0000000..8800b89 --- /dev/null +++ b/backpack/lib/src/ghk/commands/logout.rs @@ -0,0 +1,17 @@ +use crate::ghk::{gh, util}; +use anyhow::Result; + +pub fn run() -> Result<()> { + if !gh::loggedin() { + util::warn("Not logged in"); + util::dim("Nothing to do"); + return Ok(()); + } + + let user = gh::whoami().unwrap_or_else(|_| "unknown".to_string()); + util::info(&format!("Logging out from {}...", user)); + + gh::logout()?; + util::ok("Logged out"); + Ok(()) +} diff --git a/backpack/lib/src/ghk/commands/mod.rs b/backpack/lib/src/ghk/commands/mod.rs new file mode 100644 index 0000000..59c0b86 --- /dev/null +++ b/backpack/lib/src/ghk/commands/mod.rs @@ -0,0 +1,19 @@ +pub mod branch; +pub mod clone; +pub mod completions; +pub mod config; +pub mod create; +pub mod diff; +pub mod history; +pub mod ignore; +pub mod init; +pub mod license; +pub mod login; +pub mod logout; +pub mod open; +pub mod pull; +pub mod push; +pub mod setup; +pub mod status; +pub mod undo; +pub mod user; diff --git a/backpack/lib/src/ghk/commands/open.rs b/backpack/lib/src/ghk/commands/open.rs new file mode 100644 index 0000000..f5e63fa --- /dev/null +++ b/backpack/lib/src/ghk/commands/open.rs @@ -0,0 +1,20 @@ +use crate::ghk::{gh, git, util}; +use anyhow::{Result, bail}; + +pub fn run() -> Result<()> { + if !git::isrepo() { + util::err("Not a git repository"); + bail!("Not a git repository"); + } + + if !git::hasremote() { + util::err("Not connected to GitHub"); + util::dim("Run 'ghk create' first"); + bail!("No remote configured"); + } + + util::info("Opening in browser..."); + gh::openrepo()?; + util::ok("Opened"); + Ok(()) +} diff --git a/backpack/lib/src/ghk/commands/pull.rs b/backpack/lib/src/ghk/commands/pull.rs new file mode 100644 index 0000000..b9d4031 --- /dev/null +++ b/backpack/lib/src/ghk/commands/pull.rs @@ -0,0 +1,36 @@ +use crate::ghk::{gh, git, util}; +use anyhow::{Result, bail}; + +pub fn run() -> Result<()> { + // Check prerequisites + if !git::isrepo() { + util::err("Not a git repository"); + util::dim("Run 'ghk init' first"); + bail!("Not a git repository"); + } + + if !git::hasremote() { + util::err("Not connected to GitHub"); + util::dim("Run 'ghk create' first"); + bail!("No remote configured"); + } + + // Check if online + if !gh::isonline() { + util::err("Cannot reach GitHub"); + util::dim("Check your internet connection"); + bail!("Offline"); + } + + // Check for local changes that might conflict + if git::haschanges()? { + util::warn("You have unsaved changes"); + util::dim("Consider running 'ghk push' first"); + } + + util::info("Syncing from GitHub..."); + git::pull()?; + + util::ok("Synced!"); + Ok(()) +} diff --git a/backpack/lib/src/ghk/commands/push.rs b/backpack/lib/src/ghk/commands/push.rs new file mode 100644 index 0000000..10bc255 --- /dev/null +++ b/backpack/lib/src/ghk/commands/push.rs @@ -0,0 +1,128 @@ +use crate::ghk::{gh, git, util}; +use anyhow::{Result, bail}; +use dialoguer::Input; + +pub fn run() -> Result<()> { + // Check prerequisites + if !git::isrepo() { + util::err("Not a git repository"); + util::dim("Run 'ghk init' first"); + bail!("Not a git repository"); + } + + if !gh::loggedin() { + util::err("Not logged in to GitHub"); + util::dim("Run 'ghk login' first"); + bail!("Not logged in"); + } + + if !git::hasremote() { + util::err("Not connected to GitHub"); + util::dim("Run 'ghk create' first to create a repository"); + bail!("No remote configured"); + } + + // Check for changes + let changes = git::haschanges()?; + if !changes { + util::ok("Already up to date"); + util::dim("No changes to save"); + return Ok(()); + } + + // Safety check for scary files (sensitive or large temp files) + let files = git::changedfiles()?; + let scarypatterns = [ + ".env", + "node_modules", + "target", + "dist", + "venv", + ".venv", + "env", + "vendor", + ".DS_Store", + "thumbs.db", + "__pycache__", + "desktop.ini", + ".vscode", + ".idea", + "build", + "out", + "bin", + "obj", + "pyc", + "pyo", + "log", + "tmp", + "bak", + "swp", + "exe", + "dll", + "so", + "dylib", + ]; + + let mut foundscary = Vec::new(); + for file in &files { + let filelow = file.to_lowercase(); + for pattern in &scarypatterns { + if filelow.contains(pattern) { + foundscary.push(file.clone()); + break; + } + } + } + + if !foundscary.is_empty() { + util::warn("Wait! Potential sensitive or temporary files detected:"); + for file in foundscary.iter().take(5) { + util::dim(&format!(" {file}")); + } + if foundscary.len() > 5 { + let more = foundscary.len() - 5; + util::dim(&format!(" ... and {more} more")); + } + + if !std::path::Path::new(".gitignore").exists() { + util::info("Tip: You don't have a .gitignore file."); + util::dim("Run 'ghk ignore' to add a template for your project."); + } + + let proceed = dialoguer::Confirm::new() + .with_prompt("Are you sure you want to save these files?") + .default(false) + .interact()?; + + if !proceed { + util::info("Cancelled. Clean up your files or add them to .gitignore."); + return Ok(()); + } + println!(); + } + + // Show what will be saved + util::info("Changes to save:"); + for file in files.iter().take(10) { + util::dim(&format!(" {file}")); + } + if files.len() > 10 { + let more = files.len() - 10; + util::dim(&format!(" ... and {more} more")); + } + + // Get commit message + let msg: String = Input::new() + .with_prompt("What did you change?") + .default("Update".to_string()) + .interact_text()?; + + // Stage, commit, push + util::info("Saving..."); + git::addall()?; + git::commit(&msg)?; + git::push()?; + + util::ok("Saved to GitHub!"); + Ok(()) +} diff --git a/backpack/lib/src/ghk/commands/setup.rs b/backpack/lib/src/ghk/commands/setup.rs new file mode 100644 index 0000000..1092fd0 --- /dev/null +++ b/backpack/lib/src/ghk/commands/setup.rs @@ -0,0 +1,212 @@ +use anyhow::Result; +use dialoguer::Confirm; +use std::process::Command; +use which::which; + +use crate::ghk::{gh, git, util}; + +pub fn run() -> Result<()> { + println!(); + util::info("Checking your setup..."); + println!(); + + // Check git + if which("git").is_ok() { + util::ok("Git installed"); + if let Some(v) = git::version() { + util::dim(&format!(" {v}")); + } + } else { + util::warn("Git not found"); + installtool("git")?; + } + + // Check gh + if which("gh").is_ok() { + util::ok("GitHub CLI installed"); + if let Some(v) = gh::version() { + util::dim(&format!(" {v}")); + } + } else { + util::warn("GitHub CLI not found"); + installtool("gh")?; + } + + // Check login + if gh::loggedin() { + let user = gh::whoami().unwrap_or_else(|_| "unknown".to_string()); + util::ok(&format!("Logged in as {}", user)); + } else { + util::warn("Not logged in to GitHub"); + if Confirm::new() + .with_prompt("Login now?") + .default(true) + .interact()? + { + gh::login()?; + if gh::loggedin() { + util::ok("Connected!"); + } + } else { + util::dim("Skipped - run 'ghk login' later"); + } + } + + // Check SSH + if gh::loggedin() { + if gh::hassshkey() { + util::ok("SSH key configured"); + } else { + util::warn("No SSH key found"); + util::dim("You can still use HTTPS, but SSH is recommended"); + util::dim("Run 'gh ssh-key add' to add your SSH key"); + } + } + + // Check online + if gh::isonline() { + util::ok("Can reach GitHub"); + } else { + util::warn("Cannot reach GitHub"); + util::dim("Check your internet connection"); + } + + println!(); + util::ok("All set!"); + util::dim("Run 'ghk --help' to see available commands"); + println!(); + Ok(()) +} + +/* ---------- helpers ---------- */ + +fn installtool(tool: &str) -> Result<()> { + if !Confirm::new() + .with_prompt(&format!("Install {tool} now?")) + .default(true) + .interact()? + { + util::dim("Skipped"); + return Ok(()); + } + + let os = std::env::consts::OS; + + match os { + "linux" => installonlinux(tool), + "freebsd" => installonfreebsd(tool), + "macos" => runpkg("brew", &["install", tool]), + "windows" => { + let id = match tool { + "git" => "Git.Git", + "gh" => "GitHub.cli", + _ => tool, + }; + runpkg("winget", &["install", "--id", id, "-e"]) + } + _ => { + util::warn(&format!("Please install {tool} manually")); + Ok(()) + } + } +} + +fn installonlinux(tool: &str) -> Result<()> { + let pm = detectpm(); + + match pm.as_deref() { + Some("apt") => runsudo(&["apt", "install", "-y", tool]), + Some("dnf") => runsudo(&["dnf", "install", "-y", tool]), + Some("pacman") => runsudo(&["pacman", "-S", "--noconfirm", tool]), + Some("zypper") => runsudo(&["zypper", "install", "-y", tool]), + _ => { + util::warn("Could not detect package manager"); + util::dim("Please install manually:"); + util::dim(&format!(" sudo apt install {tool}")); + util::dim(&format!(" sudo dnf install {tool}")); + util::dim(&format!(" sudo pacman -S {tool}")); + Ok(()) + } + } +} + +fn detectpm() -> Option { + let managers = ["apt", "dnf", "pacman", "zypper", "apk"]; + for pm in managers { + if which(pm).is_ok() { + return Some(pm.to_string()); + } + } + None +} + +fn installonfreebsd(tool: &str) -> Result<()> { + runsudo(&["pkg", "install", "-y", tool]) +} + +fn runsudo(args: &[&str]) -> Result<()> { + if which("sudo").is_err() { + util::warn("sudo not found"); + util::dim("Please run as root or install sudo"); + util::dim(&format!(" {}", args.join(" "))); + return Ok(()); + } + + // Check if running as root (Unix only) + #[cfg(unix)] + let is_root = unsafe { libc::geteuid() } == 0; + #[cfg(not(unix))] + let is_root = false; + + let status = if is_root { + Command::new(args[0]).args(&args[1..]).status() + } else { + util::dim("This requires admin access..."); + Command::new("sudo").args(args).status() + }; + + match status { + Ok(s) if s.success() => { + let last = args.last().unwrap_or(&""); + util::ok(&format!("{last} installed")); + Ok(()) + } + Ok(_) => { + util::warn("Install failed - try running manually"); + Ok(()) + } + Err(e) => { + util::warn(&format!("Could not run command: {e}")); + Ok(()) + } + } +} + +fn runpkg(cmd: &str, args: &[&str]) -> Result<()> { + if which(cmd).is_err() { + util::warn(&format!("{cmd} not found")); + match cmd { + "brew" => util::dim("Install Homebrew from https://brew.sh"), + "winget" => util::dim("winget should be pre-installed on Windows 10+"), + _ => {} + } + return Ok(()); + } + + let status = Command::new(cmd).args(args).status(); + + match status { + Ok(s) if s.success() => { + util::ok("Installed"); + Ok(()) + } + Ok(_) => { + util::warn("Install failed"); + Ok(()) + } + Err(e) => { + util::warn(&format!("Error: {e}")); + Ok(()) + } + } +} diff --git a/backpack/lib/src/ghk/commands/status.rs b/backpack/lib/src/ghk/commands/status.rs new file mode 100644 index 0000000..99eee20 --- /dev/null +++ b/backpack/lib/src/ghk/commands/status.rs @@ -0,0 +1,62 @@ +use crate::ghk::{gh, git, util}; +use anyhow::Result; + +pub fn run() -> Result<()> { + println!(); + + // Git status + if git::isrepo() { + util::ok("Git: Ready"); + + // Branch info + if let Ok(branch) = git::currentbranch() { + util::dim(&format!("Branch: {branch}")); + } + + // Remote info + if git::hasremote() { + if let Ok(url) = git::remoteurl() { + util::dim(&format!("Remote: {url}")); + } + } else { + util::dim("Remote: Not connected (run 'ghk create')"); + } + + // Changes + match git::haschanges() { + Ok(true) => { + let files = git::changedfiles().unwrap_or_default(); + let files_len = files.len(); + util::warn(&format!("{files_len} unsaved changes")); + for file in files.iter().take(5) { + util::dim(&format!(" {file}")); + } + if files_len > 5 { + let more = files_len - 5; + util::dim(&format!(" ... and {more} more")); + } + } + Ok(false) => { + util::dim("No unsaved changes"); + } + Err(_) => {} + } + } else { + util::warn("Git: Not initialized"); + util::dim("Run 'ghk init' to set up"); + } + + println!(); + + // GitHub status + if gh::loggedin() { + let user = gh::whoami().unwrap_or_else(|_| "unknown".to_string()); + util::ok(&format!("GitHub: Logged in as {user}")); + } else { + util::warn("GitHub: Not logged in"); + util::dim("Run 'ghk login' to connect"); + } + + println!(); + Ok(()) +} diff --git a/backpack/lib/src/ghk/commands/undo.rs b/backpack/lib/src/ghk/commands/undo.rs new file mode 100644 index 0000000..3e20923 --- /dev/null +++ b/backpack/lib/src/ghk/commands/undo.rs @@ -0,0 +1,34 @@ +use crate::ghk::{git, util}; +use anyhow::{Result, bail}; +use dialoguer::Confirm; + +pub fn run() -> Result<()> { + if !git::isrepo() { + util::err("Not a git repository"); + bail!("Not a git repository"); + } + + // Show what will be undone + let history = git::history(1)?; + if history.is_empty() { + util::warn("No commits to undo"); + return Ok(()); + } + + util::info("Last commit:"); + util::dim(&format!(" {0}", history[0])); + + if !Confirm::new() + .with_prompt("Undo this commit? (changes will be kept)") + .default(false) + .interact()? + { + util::dim("Cancelled"); + return Ok(()); + } + + git::undolast()?; + util::ok("Commit undone"); + util::dim("Your changes are still there, just uncommitted"); + Ok(()) +} diff --git a/backpack/lib/src/ghk/commands/user.rs b/backpack/lib/src/ghk/commands/user.rs new file mode 100644 index 0000000..62edb40 --- /dev/null +++ b/backpack/lib/src/ghk/commands/user.rs @@ -0,0 +1,30 @@ +use crate::cli::UserCmd; +use crate::ghk::{gh, util}; +use anyhow::Result; + +pub fn run(cmd: UserCmd) -> Result<()> { + match cmd { + UserCmd::List => { + if !gh::loggedin() { + util::warn("No accounts found"); + util::dim("Run 'ghk login' to connect your GitHub account"); + return Ok(()); + } + + util::ok("Your accounts:"); + gh::listusers()?; + } + UserCmd::Switch { name } => { + if !gh::loggedin() { + util::warn("Not logged in"); + util::dim("Run 'ghk login' first"); + return Ok(()); + } + + util::info(&format!("Switching to {name}...")); + gh::switchuser(&name)?; + util::ok(&format!("Now using {name}")); + } + } + Ok(()) +} diff --git a/backpack/lib/src/ghk/config.rs b/backpack/lib/src/ghk/config.rs new file mode 100644 index 0000000..a0d46bb --- /dev/null +++ b/backpack/lib/src/ghk/config.rs @@ -0,0 +1,107 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +#[derive(Default, Serialize, Deserialize)] +pub struct Config { + pub lastuser: Option, + pub quiet: bool, + pub nocolor: bool, + pub editor: Option, + pub org: Option, +} + +impl Config { + #[must_use] + pub fn path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("github-bot") + .join("config.toml") + } + + #[must_use] + pub fn load() -> Self { + let path = Self::path(); + if path.exists() { + fs::read_to_string(&path) + .ok() + .and_then(|s| toml::from_str(&s).ok()) + .unwrap_or_default() + } else { + Self::default() + } + } + + pub fn save(&self) -> Result<()> { + let path = Self::path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let content = toml::to_string_pretty(self)?; + fs::write(path, content)?; + Ok(()) + } + + #[must_use] + pub fn get(&self, key: &str) -> Option { + match key { + "quiet" => Some(self.quiet.to_string()), + "nocolor" => Some(self.nocolor.to_string()), + "editor" => self.editor.clone(), + "org" => self.org.clone(), + "lastuser" => self.lastuser.clone(), + _ => None, + } + } + + pub fn set(&mut self, key: &str, value: &str) -> Result<()> { + match key { + "quiet" => self.quiet = value == "true" || value == "1", + "nocolor" => self.nocolor = value == "true" || value == "1", + "editor" => self.editor = Some(value.to_string()), + "org" => self.org = Some(value.to_string()), + _ => anyhow::bail!("Unknown setting: {key}"), + } + self.save() + } +} + +// global flags +static mut QUIET: bool = false; +static mut NOCOLOR: bool = false; + +pub fn setquiet(q: bool) { + unsafe { + QUIET = q; + } +} + +#[must_use] +pub fn isquiet() -> bool { + unsafe { QUIET } +} + +pub fn setnocolor(c: bool) { + unsafe { + NOCOLOR = c; + } +} + +#[must_use] +pub fn isnocolor() -> bool { + unsafe { NOCOLOR } +} + +#[must_use] +pub const fn isverbose() -> bool { + // default: false; verbosity may be controlled elsewhere + false +} + +/// Check if this is the first run +#[must_use] +pub fn isfirstrun() -> bool { + !Config::path().exists() +} diff --git a/backpack/lib/src/ghk/error.rs b/backpack/lib/src/ghk/error.rs new file mode 100644 index 0000000..10d2e33 --- /dev/null +++ b/backpack/lib/src/ghk/error.rs @@ -0,0 +1,32 @@ +use thiserror::Error; + +#[allow(dead_code)] +#[derive(Error, Debug)] +pub enum GhkError { + #[error("Git is not installed. Run 'ghk setup' first")] + GitNotInstalled, + + #[error("GitHub CLI (gh) is not installed. Run 'ghk setup' first")] + GhNotInstalled, + + #[error("Not logged in to GitHub. Run 'ghk login' first")] + NotLoggedIn, + + #[error("Not a git repository. Run 'ghk init' first")] + NotARepo, + + #[error("Already a git repository")] + AlreadyARepo, + + #[error("No changes to save")] + NothingToCommit, + + #[error("No remote configured. Run 'ghk create' first")] + NoRemote, + + #[error("{cmd} failed: {reason}")] + CommandFailed { cmd: String, reason: String }, + + #[error("Cancelled by user")] + Cancelled, +} diff --git a/backpack/lib/src/ghk/gh.rs b/backpack/lib/src/ghk/gh.rs new file mode 100644 index 0000000..90d2cda --- /dev/null +++ b/backpack/lib/src/ghk/gh.rs @@ -0,0 +1,345 @@ +use anyhow::{Context, Result, bail}; +use indicatif::{ProgressBar, ProgressStyle}; +use std::process::{Command, Stdio}; +use std::time::Duration; + +/// Login to GitHub via gh CLI +pub fn login() -> Result<()> { + let status = Command::new("gh") + .args(["auth", "login"]) + .status() + .context("Failed to run gh - is it installed?")?; + + if !status.success() { + bail!("Login was cancelled or failed"); + } + Ok(()) +} + +/// Logout from GitHub +pub fn logout() -> Result<()> { + let status = Command::new("gh") + .args(["auth", "logout"]) + .status() + .context("Failed to run gh")?; + + if !status.success() { + bail!("Logout failed"); + } + Ok(()) +} + +/// Check if user is logged in +pub fn loggedin() -> bool { + Command::new("gh") + .args(["auth", "status"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Get current logged in username +pub fn whoami() -> Result { + let output = Command::new("gh") + .args(["api", "user", "-q", ".login"]) + .output() + .context("Failed to get current user")?; + + if !output.status.success() { + bail!("Not logged in"); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// List logged in accounts +pub fn listusers() -> Result<()> { + let status = Command::new("gh") + .args(["auth", "status"]) + .status() + .context("Failed to run gh")?; + + if !status.success() { + bail!("No accounts found"); + } + Ok(()) +} + +/// Switch to a different account +pub fn switchuser(name: &str) -> Result<()> { + let status = Command::new("gh") + .args(["auth", "switch", "-u", name]) + .status() + .context("Failed to switch user")?; + + if !status.success() { + println!("Account '{name}' not found locally. Please log in:"); + return login(); + } + Ok(()) +} + +/// Create a new repository on GitHub with spinner +pub fn createrepo(name: &str, private: bool) -> Result<()> { + let spinner = makespinner("Creating repository on GitHub..."); + + let mut args = vec!["repo", "create", name, "--source=.", "--push"]; + if private { + args.push("--private"); + } else { + args.push("--public"); + } + + let output = Command::new("gh") + .args(&args) + .output() + .context("Failed to create repository")?; + + spinner.finish_and_clear(); + + if !output.status.success() { + bail!("Failed to create repository"); + } + + // Set branch rules + createruleset(name)?; + + // Enable Dependency Graph / Alerts + enable_dep_graph(name)?; + + // Enable Auto-fix PRs + enable_security_updates(name)?; + + Ok(()) +} + +/// Clone a repository by owner/repo name +pub fn clonerepo(repo: &str, dir: Option<&str>) -> Result<()> { + let spinner = makespinner("Downloading repository..."); + + let mut args = vec!["repo", "clone", repo]; + if let Some(d) = dir { + args.push(d); + } + + let output = Command::new("gh") + .args(&args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .context("Failed to clone repository")?; + + spinner.finish_and_clear(); + + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr); + if err.contains("already exists") { + bail!("Directory already exists"); + } + if err.contains("Could not resolve") { + bail!("Repository not found - check the name"); + } + bail!("Clone failed"); + } + Ok(()) +} + +/// Open repository in browser +pub fn openrepo() -> Result<()> { + let status = Command::new("gh") + .args(["repo", "view", "--web"]) + .status() + .context("Failed to open browser")?; + + if !status.success() { + bail!("Could not open in browser"); + } + Ok(()) +} + +/// Get gh CLI version +pub fn version() -> Option { + Command::new("gh").arg("--version").output().ok().map(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .next() + .unwrap_or("") + .to_string() + }) +} + +/// Check if we have SSH key configured +pub fn hassshkey() -> bool { + Command::new("gh") + .args(["ssh-key", "list"]) + .output() + .map(|o| o.status.success() && !o.stdout.is_empty()) + .unwrap_or(false) +} + +/// Check if we can reach GitHub (online check) +pub fn isonline() -> bool { + Command::new("gh") + .args(["api", "rate_limit"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Create a spinner +#[allow(clippy::literal_string_with_formatting_args)] +fn makespinner(msg: &str) -> ProgressBar { + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.cyan} {msg}") + .unwrap(), + ); + pb.set_message(msg.to_string()); + pb.enable_steady_tick(Duration::from_millis(100)); + pb +} + +/// Create default ruleset +pub fn createruleset(name: &str) -> Result<()> { + use anyhow::{Context, Result, bail}; + use std::process::Command; + + let (owner, repo) = name + .split_once('/') + .expect("input must be in the form owner/repo"); + + let endpoint = format!("repos/{owner}/{repo}/rulesets"); + + let body = r#" +{ + "name": "default", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["~DEFAULT_BRANCH"], + "exclude": [] + } + }, + "bypass_actors": [ + { + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + } + ], + "rules": [ + { "type": "required_signatures", "parameters": {} }, + { "type": "pull_request" }, + { "type": "non_fast_forward", "parameters": {} }, + { "type": "deletion", "parameters": {} } + ] +} +"#; + + let mut child = Command::new("gh") + .args([ + "api", + "-X", + "POST", + &endpoint, + "--input", + "-", + "-H", + "Accept: application/vnd.github+json", + "-H", + "X-GitHub-Api-Version: 2022-11-28", + ]) + .stdin(std::process::Stdio::piped()) + .spawn() + .context("Failed to run gh api")?; + + // Write JSON body into stdin AFTER spawning + if let Some(mut stdin) = child.stdin.take() { + use std::io::Write; + stdin.write_all(body.as_bytes()).ok(); + } + + let output = child + .wait_with_output() + .context("Failed to capture gh api output")?; + + if !output.status.success() { + bail!( + "gh api failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + Ok(()) +} + +/// Enable Dependency Graph and Security Analysis +pub fn enable_dep_graph(name: &str) -> Result<()> { + let (owner, repo) = name + .split_once('/') + .context("Repository name must be in the format 'owner/repo'")?; + + // Enable Vulnerability Alerts (this ensures dependency graph is active) + // Documentation: https://docs.github.com/en/rest/vulnerability-alerts/vulnerability-alerts + let endpoint = format!("repos/{owner}/{repo}/vulnerability-alerts"); + + let output = Command::new("gh") + .args([ + "api", + "-X", + "PUT", + &endpoint, + "-H", + "Accept: application/vnd.github+json", + "-H", + "X-GitHub-Api-Version: 2022-11-28", + ]) + .output() + .context("Failed to enable dependency graph via gh api")?; + + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr); + // Note: Some repos (like public ones) might have this enabled by default + // We log the error but you might want to handle "already enabled" silently + bail!("Failed to enable dependency graph: {}", err.trim()); + } + + Ok(()) +} + +/// Enable Dependabot Security Updates +pub fn enable_security_updates(name: &str) -> Result<()> { + let (owner, repo) = name + .split_once('/') + .context("Repository name must be in the format 'owner/repo'")?; + + // Enable automated security fixes + // Documentation: https://docs.github.com/en/rest/vulnerability-alerts/automated-security-fixes + let endpoint = format!("repos/{owner}/{repo}/automated-security-fixes"); + + let output = Command::new("gh") + .args([ + "api", + "-X", + "PUT", + &endpoint, + "-H", + "Accept: application/vnd.github+json", + "-H", + "X-GitHub-Api-Version: 2022-11-28", + ]) + .output() + .context("Failed to enable Dependabot security updates")?; + + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr); + bail!("Failed to enable security updates: {}", err.trim()); + } + + Ok(()) +} diff --git a/backpack/lib/src/ghk/git.rs b/backpack/lib/src/ghk/git.rs new file mode 100644 index 0000000..933c626 --- /dev/null +++ b/backpack/lib/src/ghk/git.rs @@ -0,0 +1,260 @@ +use anyhow::{Context, Result, bail}; +use indicatif::{ProgressBar, ProgressStyle}; +use std::{ + process::{Command, Stdio}, + time::Duration, +}; + +/// Check if current directory is inside a git repo +pub fn isrepo() -> bool { + git2::Repository::discover(".").is_ok() +} + +/// Initialize a new git repository +pub fn init() -> Result<()> { + let status = Command::new("git") + .arg("init") + .status() + .context("Failed to run git")?; + + if !status.success() { + bail!("git init failed"); + } + Ok(()) +} + +/// Check if a remote named 'origin' exists +pub fn hasremote() -> bool { + Command::new("git") + .args(["remote", "get-url", "origin"]) + .output() + .map(|out| out.status.success()) + .unwrap_or(false) +} + +/// Check if there are uncommitted changes +pub fn haschanges() -> Result { + let output = Command::new("git") + .args(["status", "--porcelain"]) + .output() + .context("Failed to run git status")?; + + Ok(!output.stdout.is_empty()) +} + +/// Get list of changed files (for display) +pub fn changedfiles() -> Result> { + let output = Command::new("git") + .args(["status", "--porcelain"]) + .output() + .context("Failed to run git status")?; + + let text = String::from_utf8_lossy(&output.stdout); + Ok(text + .lines() + .map(|line| line.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect()) +} + +/// Stage all changes +pub fn addall() -> Result<()> { + let status = Command::new("git") + .args(["add", "-A"]) + .status() + .context("Failed to run git add")?; + + if !status.success() { + bail!("git add failed"); + } + Ok(()) +} + +/// Commit with message +pub fn commit(msg: &str) -> Result<()> { + let status = Command::new("git") + .args(["commit", "-m", msg]) + .status() + .context("Failed to run git commit")?; + + if !status.success() { + bail!("git commit failed"); + } + Ok(()) +} + +/// Push to origin with spinner +pub fn push() -> Result<()> { + let spinner = makespinner("Pushing to GitHub..."); + + let output = Command::new("git") + .args(["push", "-u", "origin", "HEAD"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .context("Failed to run git push")?; + + spinner.finish_and_clear(); + + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr); + if err.contains("rejected") || err.contains("non-fast-forward") { + bail!("Push rejected - run 'ghk pull' first to sync changes"); + } + bail!("git push failed - check your permissions and try again"); + } + Ok(()) +} + +/// Pull from origin with spinner +pub fn pull() -> Result<()> { + let spinner = makespinner("Syncing from GitHub..."); + + let output = Command::new("git") + .args(["pull", "--rebase"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .context("Failed to run git pull")?; + + spinner.finish_and_clear(); + + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr); + if err.contains("CONFLICT") { + bail!("Merge conflict detected - please resolve manually"); + } + bail!("git pull failed"); + } + Ok(()) +} + +/// Clone a repository with spinner +#[allow(dead_code)] +pub fn clone(url: &str, dir: Option<&str>) -> Result<()> { + let spinner = makespinner("Downloading repository..."); + + let mut args = vec!["clone", "--progress", url]; + if let Some(d) = dir { + args.push(d); + } + + let output = Command::new("git") + .args(&args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .context("Failed to run git clone")?; + + spinner.finish_and_clear(); + + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr); + if err.contains("already exists") { + bail!("Directory already exists"); + } + bail!("Clone failed - check the URL and try again"); + } + Ok(()) +} + +/// Get current branch name +pub fn currentbranch() -> Result { + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .output() + .context("Failed to get current branch")?; + + if !output.status.success() { + bail!("Not on any branch"); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// Get remote URL +pub fn remoteurl() -> Result { + let output = Command::new("git") + .args(["remote", "get-url", "origin"]) + .output() + .context("Failed to get remote URL")?; + + if !output.status.success() { + bail!("No remote configured"); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// Undo last commit (keep changes) +pub fn undolast() -> Result<()> { + let status = Command::new("git") + .args(["reset", "--soft", "HEAD~1"]) + .status() + .context("Failed to undo")?; + + if !status.success() { + bail!("Undo failed - may be no commits to undo"); + } + Ok(()) +} + +/// Get recent commit history +pub fn history(count: usize) -> Result> { + let output = Command::new("git") + .args(["log", "--oneline", "-n", &count.to_string()]) + .output() + .context("Failed to get history")?; + + if !output.status.success() { + return Ok(vec![]); + } + + let text = String::from_utf8_lossy(&output.stdout); + Ok(text.lines().map(std::string::ToString::to_string).collect()) +} + +/// Check if there are unpushed commits +#[allow(dead_code)] +pub fn hasunpushed() -> bool { + Command::new("git") + .args(["log", "@{u}..", "--oneline"]) + .output() + .map(|out| !out.stdout.is_empty()) + .unwrap_or(false) +} + +/// Check if there are unpulled commits +#[allow(dead_code)] +pub fn hasunpulled() -> bool { + // fetch first to check + let _ = Command::new("git").args(["fetch", "--quiet"]).status(); + + Command::new("git") + .args(["log", "..@{u}", "--oneline"]) + .output() + .map(|out| !out.stdout.is_empty()) + .unwrap_or(false) +} + +/// Get git version +pub fn version() -> Option { + Command::new("git") + .arg("--version") + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) +} + +/// Create a spinner +pub fn makespinner(msg: &str) -> ProgressBar { + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.cyan} {msg}") + .unwrap(), + ); + pb.set_message(msg.to_string()); + pb.enable_steady_tick(Duration::from_millis(100)); + pb +} diff --git a/backpack/lib/src/ghk/mod.rs b/backpack/lib/src/ghk/mod.rs new file mode 100644 index 0000000..b805513 --- /dev/null +++ b/backpack/lib/src/ghk/mod.rs @@ -0,0 +1,13 @@ +mod app; +mod commands; +pub mod config; +mod error; +mod gh; +mod git; +mod util; + +use crate::cli::Args; + +pub fn main(cli: Args) -> anyhow::Result<()> { + app::run(cli) +} diff --git a/backpack/lib/src/ghk/util.rs b/backpack/lib/src/ghk/util.rs new file mode 100644 index 0000000..17bb97b --- /dev/null +++ b/backpack/lib/src/ghk/util.rs @@ -0,0 +1,54 @@ +use crate::ghk::config; + +/// Print success message with green checkmark +pub fn ok(m: &str) { + if config::isquiet() { + return; + } + if config::isnocolor() { + println!("+ {m}"); + } else { + println!("\x1b[32m✔\x1b[0m {m}"); + } +} + +/// Print warning message with yellow warning sign +pub fn warn(m: &str) { + if config::isquiet() { + return; + } + if config::isnocolor() { + println!("! {m}"); + } else { + println!("\x1b[33m⚠\x1b[0m {m}"); + } +} + +/// Print error message with red X (always shown) +pub fn err(m: &str) { + if config::isnocolor() { + eprintln!("X {m}"); + } else { + eprintln!("\x1b[31m✗\x1b[0m {m}"); + } +} + +/// Print info message (no prefix) +pub fn info(m: &str) { + if config::isquiet() { + return; + } + println!(" {m}"); +} + +/// Print a dim/muted message +pub fn dim(m: &str) { + if config::isquiet() { + return; + } + if config::isnocolor() { + println!(" {m}"); + } else { + println!("\x1b[90m {m}\x1b[0m"); + } +} diff --git a/backpack/lib/src/git/mod.rs b/backpack/lib/src/git/mod.rs index b98d6e0..ba18b15 100644 --- a/backpack/lib/src/git/mod.rs +++ b/backpack/lib/src/git/mod.rs @@ -1,7 +1,5 @@ -use std::{ - path::Path, - process::{Command, ExitStatus}, -}; +use std::process::{Command, ExitStatus}; +use tracing::{debug, warn}; fn run(cmd: &mut Command) -> anyhow::Result { let status = cmd.status()?; @@ -17,20 +15,59 @@ fn ensure_success(status: ExitStatus, msg: &str) -> anyhow::Result<()> { pub fn wip(no_push: bool, no_diff: bool, rewind: Option) -> anyhow::Result<()> { // Check if working directory is clean + debug!( + command = "git status -s", + "Checking working directory status" + ); let output = Command::new("git").args(["status", "-s"]).output()?; + debug!( + stdout = %String::from_utf8_lossy(&output.stdout), + stderr = %String::from_utf8_lossy(&output.stderr), + "Git status output received" + ); + if output.stdout.is_empty() { - // Nothing to do + debug!("Working directory is clean; nothing to do"); return Ok(()); } // Format code - // Check for treefmt config before running treefmt + let repo = git2::Repository::discover(".")?; + let repo_root = repo + .workdir() + .ok_or_else(|| anyhow::anyhow!("Cannot find repository working directory"))?; + + debug!(repo_root = %repo_root.display(), "Located git repository root"); + let has_treefmt_config = - Path::new("treefmt.toml").exists() || Path::new(".treefmt.toml").exists(); + repo_root.join("treefmt.toml").exists() || repo_root.join(".treefmt.toml").exists(); if has_treefmt_config { - ensure_success(run(&mut Command::new("treefmt"))?, "treefmt failed")?; + debug!("Found treefmt config; executing formatting..."); + let mut cmd = Command::new("treefmt"); + cmd.current_dir(repo_root); + + // We run the command but don't use 'ensure_success' or '?' + // because we don't want check failures to block our WIP commit. + match run(&mut cmd) { + Ok(status) if status.success() => { + debug!("treefmt completed successfully"); + } + Ok(status) => { + // treefmt returns non-zero if a --check fails or files were changed. + // We log this as a debug message and continue to the git add/commit stage. + debug!( + exit_code = ?status.code(), + "treefmt finished with non-zero status (likely due to diffs), continuing to commit..." + ); + } + Err(e) => { + // This triggers if the treefmt binary itself is missing or cannot execute. + // We warn the user but don't crash the program. + warn!(error = %e, "Failed to execute treefmt binary; skipping format stage"); + } + } } // Show diff unless suppressed @@ -47,7 +84,7 @@ pub fn wip(no_push: bool, no_diff: bool, rewind: Option) -> anyhow::Result< // Determine rewind count let rewind = rewind.unwrap_or(1); - let head_arg = format!("HEAD~{}", rewind); + let head_arg = format!("HEAD~{rewind}"); // Soft reset and amend ensure_success( @@ -70,3 +107,118 @@ pub fn wip(no_push: bool, no_diff: bool, rewind: Option) -> anyhow::Result< Ok(()) } + +fn delete_stale_local_branches(confirm: bool) -> anyhow::Result<()> { + // Open repo + let repo = git2::Repository::discover(".")?; + let git_config = git2::Config::open_default()?; + let auth = auth_git2::GitAuthenticator::default(); + + let remote_name = "origin"; + let mut remote = repo.find_remote(remote_name)?; + + // Build the callbacks + let mut callbacks = git2::RemoteCallbacks::new(); + callbacks.credentials(auth.credentials(&git_config)); + + // Build FetchOptions and attach callbacks + let mut fetch_options = git2::FetchOptions::new(); + fetch_options.remote_callbacks(callbacks); + fetch_options.prune(git2::FetchPrune::On); + + // Perform the fetch + remote.fetch( + &["+refs/heads/*:refs/remotes/origin/*"], + Some(&mut fetch_options), + None, + )?; + + let mut branches_to_delete = Vec::new(); + let local_branches = repo.branches(Some(git2::BranchType::Local))?; + + for branch_res in local_branches { + let (branch, _) = branch_res?; + let branch_name = branch.name()?.unwrap_or("unknown").to_string(); + + // Safety: Never delete current branch or main/master + if branch.is_head() || branch_name == "main" || branch_name == "master" { + continue; + } + + // Logic: If there is no 'refs/remotes/origin/', the remote is gone. + // We check this directly instead of relying on the 'upstream' config, + // which can be buggy if the remote was deleted via a Web UI. + let remote_ref_path = format!("refs/remotes/{remote_name}/{branch_name}"); + + if repo.find_reference(&remote_ref_path).is_err() { + // Check if this branch ever even tried to track the remote. + // If it has upstream config OR just matches the remote name, we prune it. + let has_tracking_config = branch.upstream().is_ok(); + + if has_tracking_config { + println!( + " - Branch '{}' tracks a deleted remote branch. Deleting...", + branch_name + ); + branches_to_delete.push(branch_name); + } else { + use std::io::IsTerminal; + + if std::io::stdin().is_terminal() { + //cliclack::log::remark("This branch exists locally but not on origin/main.")?; + let ans = match confirm { + true => true, + false => { + cliclack::confirm(format!( + "Branch '{}' has no remote counterpart. Delete locally?", + branch_name + )) + .initial_value(false) // Default to 'No' + .interact()? + } + }; + + match ans { + true => branches_to_delete.push(branch_name), + false => cliclack::log::remark(format!( + "\x1b[90m - Skipping '{}'.\x1b[0m", + branch_name + ))?, + }; + } else { + match confirm { + true => branches_to_delete.push(branch_name), + false => println!("\x1b[90m - Skipping '{}'.\x1b[0m", branch_name), + }; + } + } + } + } + + for name in branches_to_delete { + let mut b = repo.find_branch(&name, git2::BranchType::Local)?; + + cliclack::log::remark(format!("\x1b[32m✔\x1b[0m Deleting branch '{}'.", name))?; + b.delete()?; + } + + Ok(()) +} + +pub fn prune(confirm: bool) -> anyhow::Result<()> { + cliclack::intro("Cleaning up stale branches")?; + + match delete_stale_local_branches(confirm) { + Ok(_) => cliclack::outro("You're all set!")?, + Err(e) => { + eprintln!("Error managing branches: {}", e); + return Err(e); + } + }; + + Ok(()) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/backpack/lib/src/git/tests.rs b/backpack/lib/src/git/tests.rs new file mode 100644 index 0000000..dfe7041 --- /dev/null +++ b/backpack/lib/src/git/tests.rs @@ -0,0 +1,99 @@ +use super::*; +use git2::Repository; +use std::fs; +use std::process::Command; +use tempfile::TempDir; + +/// Helper to initialize a real git repo in a temp dir +fn setup_repo() -> (TempDir, Repository) { + let dir = TempDir::new().expect("Failed to create temp dir"); + let repo = Repository::init(dir.path()).expect("Failed to init repo"); + + // Git needs a user identity to commit + let mut config = repo.config().unwrap(); + config.set_str("user.name", "Test User").unwrap(); + config.set_str("user.email", "test@example.com").unwrap(); + + (dir, repo) +} + +#[test] +fn test_ensure_success_ok() { + // Create a status that represents success + let status = Command::new("true").status().unwrap(); + assert!(ensure_success(status, "should not fail").is_ok()); +} + +#[test] +fn test_ensure_success_fail() { + let status = Command::new("false").status().unwrap(); + let res = ensure_success(status, "error message"); + assert!(res.is_err()); + assert_eq!(res.unwrap_err().to_string(), "error message"); +} + +#[test] +fn test_wip_no_changes() { + let (dir, _repo) = setup_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + // wip should return Ok(()) immediately if nothing is staged/changed + let result = wip(true, true, None); + assert!(result.is_ok()); +} + +#[test] +fn test_wip_with_changes_and_treefmt() { + let (dir, _repo) = setup_repo(); + let repo_path = dir.path(); + std::env::set_current_dir(repo_path).unwrap(); + + // 1. Create a dummy file to trigger WIP + fs::write(repo_path.join("test.txt"), "hello").unwrap(); + + // 2. Create a dummy treefmt.toml to test the logic path + fs::write(repo_path.join("treefmt.toml"), "").unwrap(); + + // Note: This test will attempt to call 'treefmt' and 'git' from your PATH. + // We mock the 'no_push' and 'no_diff' to true for safety. + // Since 'treefmt' might not be in CI, we expect it might 'fail' in the log + // but the function should continue. + + // Create an initial commit so 'HEAD~1' exists for the reset logic + Command::new("git") + .args(["add", "."]) + .current_dir(repo_path) + .status() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "initial"]) + .current_dir(repo_path) + .status() + .unwrap(); + + // Modify the file to make it "dirty" + fs::write(repo_path.join("test.txt"), "dirty").unwrap(); + + let result = wip(true, true, Some(1)); + + // If 'git' is installed in the test env, this should succeed + assert!(result.is_ok()); +} + +#[test] +fn test_prune_logic_skips_protected() { + let (dir, repo) = setup_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + // Create main and a fake remote branch + let head = repo.head().ok(); // might be empty on fresh init + + // The test for prune() is harder because it requires a mock remote. + // Instead, we test that the logic doesn't crash on an empty repo. + // A full integration test would involve `git2` creating a "remote" repo + // on the local disk and linking them. + + let result = prune(false); + // This will likely fail because 'origin' doesn't exist yet + assert!(result.is_err()); +} diff --git a/backpack/lib/src/github/mod.rs b/backpack/lib/src/github/mod.rs index 04b5db6..9954a14 100644 --- a/backpack/lib/src/github/mod.rs +++ b/backpack/lib/src/github/mod.rs @@ -1,3 +1,11 @@ +mod pr; +mod release; +mod workflow; + +pub use pr::*; +pub use release::*; +pub use workflow::*; + use anyhow::{Context, Result}; use reqwest::StatusCode; use reqwest::blocking::{Client, Response}; @@ -19,174 +27,11 @@ pub struct User { pub login: String, } -#[derive(Deserialize, Debug, PartialEq, Eq)] -pub struct PullRequest { - pub number: u64, - pub title: String, - pub user: User, -} - -#[derive(Deserialize, Debug)] -pub struct MergeResponse { - pub message: Option, - pub sha: Option, -} - -// --- GitHub API Functions --- - -/// Lists all open PRs and filters them to only include those created by Dependabot. -pub fn list_dependabot_prs(client: &Client, repo: &str, token: &str) -> Result> { - let url = format!("{GITHUB_API_BASE}/repos/{repo}/pulls?state=open&per_page=100"); - - let response = client - .get(&url) - .header(AUTHORIZATION, format!("Bearer {token}")) - .header(ACCEPT, "application/vnd.github.v3+json") - .header(USER_AGENT, "DependabotAutoMerger") - .send() - .context("Failed to send list PRs request")?; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().unwrap_or_default(); - eprintln!("GitHub API Error (List PRs): Status {status}, Body: {body}"); - return Err(anyhow::anyhow!( - "Failed to list PRs from GitHub API. Check token scope." - )); - } - - let all_prs: Vec = response - .json() - .context("Failed to parse list PRs response")?; - - let dependabot_prs: Vec = all_prs - .into_iter() - .filter(|pr| pr.user.login == DEPENDABOT_USER) - .collect(); - - Ok(dependabot_prs) -} - -/// Core function to attempt merge, handle stale branch errors, and retry. -pub fn process_pr(client: &Client, repo: &str, token: &str, pr: &PullRequest) -> Result<()> { - for attempt in 1..=MAX_MERGE_ATTEMPTS { - // 1. Attempt to merge the PR - let merge_response = attempt_merge(client, repo, token, pr).context(format!( - "Failed to send merge request for PR #{}", - pr.number - ))?; - - if merge_response.status().is_success() { - let response_body: MergeResponse = merge_response.json()?; - println!( - " ✅ Successfully MERGED. Commit SHA: {}", - response_body.sha.unwrap_or_else(|| "N/A".to_string()) - ); - return Ok(()); - } - - let error_message = merge_response.json::().map_or_else( - |_| "Failed to parse error response".to_string(), - |r| r.message.unwrap_or_else(|| "Unknown API Error".to_string()), - ); - - // 2. Handle failure based on reason - if error_message.contains("Base branch was modified") { - println!(" ⚠️ Merge FAILED (Attempt {attempt}). Reason: Base branch modified."); - - if attempt == MAX_MERGE_ATTEMPTS { - println!(" ⏭️ Final attempt failed. Skipping PR (leaving open)."); - return Ok(()); - } - - // Otherwise, attempt to update the branch and retry - if update_pr_branch(client, repo, token, pr)? { - continue; // Continue to the next iteration (retry) - } - println!(" ⏭️ Branch update failed. Skipping PR (leaving open)."); - return Ok(()); - } - // Other merge failures (e.g., CI failure, conflicts, etc.) - println!(" ⏭️ Merge FAILED. Reason: {error_message}. Skipping PR (leaving open)."); - return Ok(()); - } - - Ok(()) -} - -/// Performs the PUT request to merge the PR. -pub fn attempt_merge( - client: &Client, - repo: &str, - token: &str, - pr: &PullRequest, -) -> Result { - let merge_url = format!( - "{}/repos/{}/pulls/{}/merge", - GITHUB_API_BASE, repo, pr.number - ); - let merge_body = serde_json::json!({ - "commit_title": format!("{} (#{})", pr.title, pr.number), - "commit_message": "Automated merge by Rust utility.", - "merge_method": "squash" // You can change this to "merge" or "rebase" - }); - - client - .put(&merge_url) - .header(AUTHORIZATION, format!("Bearer {token}")) - .header(ACCEPT, "application/vnd.github.v3+json") - .header(CONTENT_TYPE, "application/json") - .header(USER_AGENT, "DependabotAutoMerger") - .json(&merge_body) - .send() - .map_err(anyhow::Error::from) -} - -/// Triggers a branch update (rebase/merge) on the PR's head branch from the base branch. -pub fn update_pr_branch( - client: &Client, - repo: &str, - token: &str, - pr: &PullRequest, -) -> Result { - let update_url = format!( - "{}/repos/{}/pulls/{}/update-branch", - GITHUB_API_BASE, repo, pr.number - ); - - let response = client - .put(&update_url) - .header(AUTHORIZATION, format!("Bearer {token}")) - .header(ACCEPT, "application/vnd.github.v3+json") - .header(USER_AGENT, "DependabotAutoMerger") - .header(CONTENT_TYPE, "application/json") - .send() - .context("Failed to send branch update request")?; - - let status = response.status(); - - if status.is_success() || status.as_u16() == 202 { - println!( - " 🔄 Branch update ACCEPTED (queued). Waiting {UPDATE_WAIT_SECS} seconds to allow update/CI run..." - ); - thread::sleep(Duration::from_secs(UPDATE_WAIT_SECS)); - Ok(true) - } else { - let error_message = response - .text() - .unwrap_or_else(|_| "Failed to get error body".to_string()); - eprintln!(" 🚨 Branch update FAILED. Status: {status}. Body: {error_message}"); - Ok(false) - } -} - use colored::Colorize; use std::env; use std::process::{Command, Stdio}; use url::Url; -// --- API CLIENT & HELPERS --- -// pub struct GitHubClient { client: Client, token: String, @@ -325,425 +170,6 @@ impl GitHubClient { } } -// --- DATA STRUCTURES --- - -#[derive(Debug, Deserialize)] -pub struct WorkflowRun { - id: i64, - name: String, - conclusion: Option, -} - -#[derive(Debug, Deserialize)] -pub struct Release { - id: i64, - tag_name: String, -} - -#[derive(Debug, Deserialize)] -pub struct PackageVersion { - id: i64, - metadata: Option, -} - -#[derive(Debug, Deserialize)] -pub struct PackageMetadata { - container: Option, -} - -#[derive(Debug, Deserialize)] -pub struct ContainerMetadata { - tags: Option>, -} - -#[derive(Debug, Serialize)] -pub struct CreateRelease { - tag_name: String, - target_commitish: String, - name: String, - body: String, - draft: bool, - prerelease: bool, - generate_release_notes: bool, -} - -// --- CORE LOGIC FUNCTIONS --- - -/// Deletes failed/cancelled workflows concurrently using standard threads (max 10 at a time). -pub fn delete_failed_workflows(client: &GitHubClient, repo: &str) { - println!( - "{}", - format!("Deleting failed workflows for {repo}").yellow() - ); - - let path = &format!("repos/{repo}/actions/runs"); - match client.fetch_paginated::(path) { - Ok(runs) => { - let failed_or_cancelled_runs: Vec = runs - .into_iter() - .filter(|r| { - r.conclusion.as_deref() == Some("failure") - || r.conclusion.as_deref() == Some("cancelled") - }) - .map(|r| r.id) - .collect(); - - let count = failed_or_cancelled_runs.len(); - if count > 0 { - // Chunk the runs into groups of 10 for concurrent deletion - let chunked_runs = failed_or_cancelled_runs.chunks(10); - for chunk in chunked_runs { - let mut handles = Vec::new(); - - for id in chunk { - // Clone necessary parts for thread ownership - let client_clone = client.client.clone(); - let token_clone = client.token.clone(); - let api_base_clone = client.api_base.clone(); - let repo_str = repo.to_string(); - let id_copy = *id; - - // Spawn a standard OS thread for deletion - handles.push(thread::spawn(move || { - let delete_path = format!("repos/{repo_str}/actions/runs/{id_copy}"); - let url = api_base_clone.join(&delete_path).unwrap(); - - let res = client_clone - .delete(url) - .bearer_auth(token_clone) - .header("Accept", "application/vnd.github+json") - .header("X-GitHub-Api-Version", "2022-11-28") - .send(); - - if let Err(e) = res { - eprintln!( - "{}", - format!("Error deleting workflow run {id_copy}: {e}").red() - ); - } - })); - } - - // Wait for the current chunk of threads to finish (blocking) - for h in handles { - let _ = h.join(); - } - } - - println!( - "{}", - format!("{count} failed/cancelled workflows deleted.").blue() - ); - } else { - println!("{}", "No failed/cancelled workflows found.".blue()); - } - } - Err(e) => { - eprintln!("{}", format!("Error fetching workflow runs: {e}").red()); - } - } - println!("{}", "Done.".yellow()); -} - -/// Deletes untagged container versions. -pub fn delete_old_container_versions(client: &GitHubClient, repo: &str) { - println!("{}", format!("Deleting old containers for {repo}").yellow()); - - let parts: Vec<&str> = repo.split('/').collect(); - if parts.len() != 2 { - eprintln!("{}", format!("Error: Repository format '{repo}' is invalid. Expected 'owner/project'. Skipping container deletion.").red()); - return; - } - let org = parts[0]; - let project = parts[1]; - - let path = &format!("orgs/{org}/packages/container/{project}/versions"); - match client.fetch_paginated::(path) { - Ok(versions) => { - let untagged_versions: Vec = versions - .into_iter() - .filter_map(|v| { - let tags = v - .metadata - .and_then(|m| m.container) - .and_then(|c| c.tags) - .unwrap_or_default(); - - if tags.is_empty() { Some(v.id) } else { None } - }) - .collect(); - - let count = untagged_versions.len(); - if count > 0 { - let mut handles = Vec::new(); - for id in untagged_versions { - // Clone necessary parts for thread ownership - let client_clone = client.client.clone(); - let token_clone = client.token.clone(); - let api_base_clone = client.api_base.clone(); - let org_str = org.to_string(); - let project_str = project.to_string(); - - handles.push(thread::spawn(move || { - let delete_path = format!( - "orgs/{org_str}/packages/container/{project_str}/versions/{id}" - ); - let url = api_base_clone.join(&delete_path).unwrap(); - - let res = client_clone - .delete(url) - .bearer_auth(token_clone) - .header("Accept", "application/vnd.github+json") - .header("X-GitHub-Api-Version", "2022-11-28") - .send(); - - if let Err(e) = res { - eprintln!( - "{}", - format!("Error deleting container version {id}: {e}").red() - ); - } - })); - } - - // Wait for all deletions to complete - for h in handles { - let _ = h.join(); - } - - println!( - "{}", - format!("{count} untagged container versions deleted.").blue() - ); - } else { - println!( - "{}", - "No untagged container versions found to delete.".blue() - ); - } - } - Err(e) => { - eprintln!( - "{}", - format!( - "Error fetching container versions: {e}. Check if the repo is an org package." - ) - .red() - ); - } - } - println!("{}", "Done.".yellow()); -} - -/// Deletes all releases and their corresponding Git tags. -pub fn delete_all_releases( - client: &GitHubClient, - repo: &str, -) -> Result<(), Box> { - // 1. Delete releases - println!("{}", format!("Deleting all releases for {repo}").yellow()); - let releases_path = &format!("repos/{repo}/releases"); - - match client.fetch_paginated::(releases_path) { - Ok(releases) => { - let count = releases.len(); - let mut handles = Vec::new(); - for r in releases { - let client_clone = client.client.clone(); - let token_clone = client.token.clone(); - let api_base_clone = client.api_base.clone(); - let repo_str = repo.to_string(); - - handles.push(thread::spawn(move || { - let delete_path = format!("repos/{}/releases/{}", repo_str, r.id); - let url = api_base_clone.join(&delete_path).unwrap(); - - let res = client_clone - .delete(url) - .bearer_auth(token_clone) - .header("Accept", "application/vnd.github+json") - .header("X-GitHub-Api-Version", "2022-11-28") - .send(); - - if let Err(e) = res { - eprintln!( - "{}", - format!("Error deleting release {}: {}", r.tag_name, e).red() - ); - } - })); - } - - for h in handles { - let _ = h.join(); - } - - println!("{}", format!("{count} releases deleted.").blue()); - } - Err(e) => { - eprintln!("{}", format!("Error fetching releases: {e}").red()); - } - } - println!("{}", "Done.".yellow()); - - // 2. Delete tags (using external git commands, like the original script) - println!("{}", format!("Deleting all tags for {repo}").yellow()); - - // Create a temporary directory - let temp_dir = tempfile::tempdir()?; - let temp_path = temp_dir.path(); - let repo_url = format!("https://github.com/{repo}"); - - // Clone the repo - // We clone a mirror to access tags easily without checking out history - let clone_output = Command::new("git") - .arg("clone") - .arg("--quiet") - .arg("--mirror") - .arg(&repo_url) - .arg(temp_path) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .status()?; - - if !clone_output.success() { - eprintln!( - "{}", - format!( - "Error: Unable to clone repo {repo}. Ensure it exists and you have permission." - ) - .red() - ); - return Err("Git clone failed".into()); - } - - // List tags - let tags_output = Command::new("git") - .current_dir(temp_path) - .arg("tag") - .output()?; - - let tags = String::from_utf8(tags_output.stdout)? - .lines() - .map(std::string::ToString::to_string) - .collect::>(); - - if tags.is_empty() { - println!("{}", "No tags found to delete.".blue()); - println!("{}", "Done.".yellow()); - return Ok(()); - } - - println!( - "{}", - format!("Found {} tags. Deleting...", tags.len()).blue() - ); - - // Delete tags on remote using one push command - let mut push_command = Command::new("git"); - push_command - .current_dir(temp_path) - .arg("push") - .arg("origin") - .arg("--delete"); - - // Add all tags to the delete command - for tag in &tags { - push_command.arg(tag); - } - - // Execute the push command - let push_output = push_command - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .output()?; - - if !push_output.status.success() { - let stderr = String::from_utf8_lossy(&push_output.stderr); - eprintln!("{}", format!("Error pushing tag deletions: {stderr}").red()); - return Err("Git push --delete failed".into()); - } - - println!("{}", "Done.".yellow()); - Ok(()) -} - -/// Creates a new v0.1.0 release. -pub fn create_release(client: &GitHubClient, repo: &str) -> Result<(), reqwest::Error> { - let release_data = CreateRelease { - tag_name: "v0.1.0".to_string(), - target_commitish: "main".to_string(), - name: "v0.1.0".to_string(), - body: "First release of github-rs.".to_string(), - draft: false, - prerelease: false, - generate_release_notes: true, - }; - - let path = &format!("repos/{repo}/releases"); - match client.post::<_, serde_json::Value>(path, &release_data) { - Ok(res) => { - println!( - "{}", - format!("Successfully created release v0.1.0 for {repo}.").green() - ); - if let Some(url) = res["html_url"].as_str() { - println!("Release URL: {}", url.cyan()); - } - } - Err(e) => { - eprintln!("{}", format!("Error creating release: {e}").red()); - } - } - - Ok(()) -} - -/// Reruns failed workflow jobs. -pub fn rerun_failed_jobs(client: &GitHubClient, repo: &str) { - println!("{}", format!("Rerun failed jobs for {repo}").yellow()); - - let path = &format!("repos/{repo}/actions/runs"); - match client.fetch_paginated::(path) { - Ok(runs) => { - let failed_runs: Vec = runs - .into_iter() - .filter(|r| r.conclusion.as_deref() == Some("failure")) - .collect(); - - if failed_runs.is_empty() { - println!("{}", "No failed jobs found to rerun.".blue()); - return; - } - - for run in failed_runs { - println!( - "{}", - format!("Rerunning job \"{}\" ({})", run.name, run.id).green() - ); - let rerun_path = - &format!("repos/{}/actions/runs/{}/rerun-failed-jobs", repo, run.id); - - // Use post with an empty body - let res = client.post::<_, serde_json::Value>(rerun_path, &serde_json::json!({})); - - if let Err(e) = res { - eprintln!("{}", format!("Error rerunning job {}: {}", run.id, e).red()); - } else { - // Introduce a slight delay to avoid hitting rate limits too quickly - thread::sleep(Duration::from_millis(500)); - } - } - } - Err(e) => { - eprintln!( - "{}", - format!("Error fetching workflow runs for rerun: {e}").red() - ); - } - } -} - #[cfg(test)] #[path = "tests.rs"] mod tests; diff --git a/backpack/lib/src/github/pr.rs b/backpack/lib/src/github/pr.rs new file mode 100644 index 0000000..108ceb4 --- /dev/null +++ b/backpack/lib/src/github/pr.rs @@ -0,0 +1,165 @@ +use crate::github::{DEPENDABOT_USER, GITHUB_API_BASE, MAX_MERGE_ATTEMPTS, UPDATE_WAIT_SECS, User}; +use anyhow::{Context, Result}; +use reqwest::blocking::{Client, Response}; +use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, USER_AGENT}; +use serde::Deserialize; +use std::{thread, time::Duration}; + +#[derive(Deserialize, Debug, PartialEq, Eq)] +pub struct PullRequest { + pub number: u64, + pub title: String, + pub user: User, +} + +#[derive(Deserialize, Debug)] +pub struct MergeResponse { + pub message: Option, + pub sha: Option, +} + +/// Lists all open PRs and filters them to only include those created by Dependabot. +pub fn list_dependabot_prs(client: &Client, repo: &str, token: &str) -> Result> { + let url = format!("{GITHUB_API_BASE}/repos/{repo}/pulls?state=open&per_page=100"); + + let response = client + .get(&url) + .header(AUTHORIZATION, format!("Bearer {token}")) + .header(ACCEPT, "application/vnd.github.v3+json") + .header(USER_AGENT, "DependabotAutoMerger") + .send() + .context("Failed to send list PRs request")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + eprintln!("GitHub API Error (List PRs): Status {status}, Body: {body}"); + return Err(anyhow::anyhow!( + "Failed to list PRs from GitHub API. Check token scope." + )); + } + + let all_prs: Vec = response + .json() + .context("Failed to parse list PRs response")?; + + let dependabot_prs: Vec = all_prs + .into_iter() + .filter(|pr| pr.user.login == DEPENDABOT_USER) + .collect(); + + Ok(dependabot_prs) +} + +/// Core function to attempt merge, handle stale branch errors, and retry. +pub fn process_pr(client: &Client, repo: &str, token: &str, pr: &PullRequest) -> Result<()> { + for attempt in 1..=MAX_MERGE_ATTEMPTS { + // 1. Attempt to merge the PR + let merge_response = attempt_merge(client, repo, token, pr).context(format!( + "Failed to send merge request for PR #{}", + pr.number + ))?; + + if merge_response.status().is_success() { + let response_body: MergeResponse = merge_response.json()?; + println!( + " ✅ Successfully MERGED. Commit SHA: {}", + response_body.sha.unwrap_or_else(|| "N/A".to_string()) + ); + return Ok(()); + } + + let error_message = merge_response.json::().map_or_else( + |_| "Failed to parse error response".to_string(), + |r| r.message.unwrap_or_else(|| "Unknown API Error".to_string()), + ); + + // 2. Handle failure based on reason + if error_message.contains("Base branch was modified") { + println!(" ⚠️ Merge FAILED (Attempt {attempt}). Reason: Base branch modified."); + + if attempt == MAX_MERGE_ATTEMPTS { + println!(" ⏭️ Final attempt failed. Skipping PR (leaving open)."); + return Ok(()); + } + + // Otherwise, attempt to update the branch and retry + if update_pr_branch(client, repo, token, pr)? { + continue; // Continue to the next iteration (retry) + } + println!(" ⏭️ Branch update failed. Skipping PR (leaving open)."); + return Ok(()); + } + // Other merge failures (e.g., CI failure, conflicts, etc.) + println!(" ⏭️ Merge FAILED. Reason: {error_message}. Skipping PR (leaving open)."); + return Ok(()); + } + + Ok(()) +} + +/// Performs the PUT request to merge the PR. +pub fn attempt_merge( + client: &Client, + repo: &str, + token: &str, + pr: &PullRequest, +) -> Result { + let merge_url = format!( + "{}/repos/{}/pulls/{}/merge", + GITHUB_API_BASE, repo, pr.number + ); + let merge_body = serde_json::json!({ + "commit_title": format!("Merge Dependabot PR #{} ({})", pr.number, pr.title), + "commit_message": "Automated merge by Rust utility.", + "merge_method": "squash" // You can change this to "merge" or "rebase" + }); + + client + .put(&merge_url) + .header(AUTHORIZATION, format!("Bearer {token}")) + .header(ACCEPT, "application/vnd.github.v3+json") + .header(CONTENT_TYPE, "application/json") + .header(USER_AGENT, "DependabotAutoMerger") + .json(&merge_body) + .send() + .map_err(anyhow::Error::from) +} + +/// Triggers a branch update (rebase/merge) on the PR's head branch from the base branch. +pub fn update_pr_branch( + client: &Client, + repo: &str, + token: &str, + pr: &PullRequest, +) -> Result { + let update_url = format!( + "{}/repos/{}/pulls/{}/update-branch", + GITHUB_API_BASE, repo, pr.number + ); + + let response = client + .put(&update_url) + .header(AUTHORIZATION, format!("Bearer {token}")) + .header(ACCEPT, "application/vnd.github.v3+json") + .header(USER_AGENT, "DependabotAutoMerger") + .header(CONTENT_TYPE, "application/json") + .send() + .context("Failed to send branch update request")?; + + let status = response.status(); + + if status.is_success() || status.as_u16() == 202 { + println!( + " 🔄 Branch update ACCEPTED (queued). Waiting {UPDATE_WAIT_SECS} seconds to allow update/CI run..." + ); + thread::sleep(Duration::from_secs(UPDATE_WAIT_SECS)); + Ok(true) + } else { + let error_message = response + .text() + .unwrap_or_else(|_| "Failed to get error body".to_string()); + eprintln!(" 🚨 Branch update FAILED. Status: {status}. Body: {error_message}"); + Ok(false) + } +} diff --git a/backpack/lib/src/github/release.rs b/backpack/lib/src/github/release.rs new file mode 100644 index 0000000..7947558 --- /dev/null +++ b/backpack/lib/src/github/release.rs @@ -0,0 +1,293 @@ +use crate::github::GitHubClient; +use colored::Colorize; +use serde::{Deserialize, Serialize}; +use std::process::{Command, Stdio}; +use std::thread; +use tempfile; + +#[derive(Debug, Deserialize)] +pub struct Release { + id: u64, + tag_name: String, +} + +#[derive(Debug, Deserialize)] +pub struct PackageVersion { + id: u64, + metadata: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PackageMetadata { + container: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ContainerMetadata { + tags: Option>, +} + +#[derive(Debug, Serialize)] +pub struct CreateRelease { + tag_name: String, + target_commitish: String, + name: String, + body: String, + draft: bool, + prerelease: bool, + generate_release_notes: bool, +} + +/// Deletes untagged container versions. +pub fn delete_old_container_versions(client: &GitHubClient, repo: &str) { + println!("{}", format!("Deleting old containers for {repo}").yellow()); + + let parts: Vec<&str> = repo.split('/').collect(); + if parts.len() != 2 { + eprintln!("{}", format!("Error: Repository format '{repo}' is invalid. Expected 'owner/project'. Skipping container deletion.").red()); + return; + } + let org = parts[0]; + let project = parts[1]; + + let path = &format!("orgs/{org}/packages/container/{project}/versions"); + match client.fetch_paginated::(path) { + Ok(versions) => { + let untagged_versions: Vec = versions + .into_iter() + .filter_map(|v| { + let tags = v + .metadata + .and_then(|m| m.container) + .and_then(|c| c.tags) + .unwrap_or_default(); + + if tags.is_empty() { Some(v.id) } else { None } + }) + .collect(); + + let count = untagged_versions.len(); + if count > 0 { + let mut handles = Vec::new(); + for id in untagged_versions { + // Clone necessary parts for thread ownership + let client_clone = client.client.clone(); + let token_clone = client.token.clone(); + let api_base_clone = client.api_base.clone(); + let org_str = org.to_string(); + let project_str = project.to_string(); + + handles.push(thread::spawn(move || { + let delete_path = format!( + "orgs/{org_str}/packages/container/{project_str}/versions/{id}" + ); + let url = api_base_clone.join(&delete_path).unwrap(); + + let res = client_clone + .delete(url) + .bearer_auth(token_clone) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .send(); + + if let Err(e) = res { + eprintln!( + "{}", + format!("Error deleting container version {id}: {e}").red() + ); + } + })); + } + + // Wait for all deletions to complete + for h in handles { + let _ = h.join(); + } + + println!( + "{}", + format!("{count} untagged container versions deleted.").blue() + ); + } else { + println!( + "{}", + "No untagged container versions found to delete.".blue() + ); + } + } + Err(e) => { + eprintln!( + "{}", + format!( + "Error fetching container versions: {e}. Check if the repo is an org package." + ) + .red() + ); + } + } + println!("{}", "Done.".yellow()); +} + +/// Deletes all releases and their corresponding Git tags. +pub fn delete_all_releases( + client: &GitHubClient, + repo: &str, +) -> Result<(), Box> { + // 1. Delete releases + println!("{}", format!("Deleting all releases for {repo}").yellow()); + let releases_path = &format!("repos/{repo}/releases"); + + match client.fetch_paginated::(releases_path) { + Ok(releases) => { + let count = releases.len(); + let mut handles = Vec::new(); + for r in releases { + let client_clone = client.client.clone(); + let token_clone = client.token.clone(); + let api_base_clone = client.api_base.clone(); + let repo_str = repo.to_string(); + + handles.push(thread::spawn(move || { + let delete_path = format!("repos/{}/releases/{}", repo_str, r.id); + let url = api_base_clone.join(&delete_path).unwrap(); + + let res = client_clone + .delete(url) + .bearer_auth(token_clone) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .send(); + + if let Err(e) = res { + eprintln!( + "{}", + format!("Error deleting release {}: {}", r.tag_name, e).red() + ); + } + })); + } + + for h in handles { + let _ = h.join(); + } + + println!("{}", format!("{count} releases deleted.").blue()); + } + Err(e) => { + eprintln!("{}", format!("Error fetching releases: {e}").red()); + } + } + println!("{}", "Done.".yellow()); + + // 2. Delete tags (using external git commands, like the original script) + println!("{}", format!("Deleting all tags for {repo}").yellow()); + + // Create a temporary directory + let temp_dir = tempfile::tempdir()?; + let temp_path = temp_dir.path(); + let repo_url = format!("https://github.com/{repo}"); + + // Clone the repo + // We clone a mirror to access tags easily without checking out history + let clone_output = Command::new("git") + .arg("clone") + .arg("--quiet") + .arg("--mirror") + .arg(&repo_url) + .arg(temp_path) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .status()?; + + if !clone_output.success() { + eprintln!( + "{}", + format!( + "Error: Unable to clone repo {repo}. Ensure it exists and you have permission." + ) + .red() + ); + return Err("Git clone failed".into()); + } + + // List tags + let tags_output = Command::new("git") + .current_dir(temp_path) + .arg("tag") + .output()?; + + let tags = String::from_utf8(tags_output.stdout)? + .lines() + .map(std::string::ToString::to_string) + .collect::>(); + + if tags.is_empty() { + println!("{}", "No tags found to delete.".blue()); + println!("{}", "Done.".yellow()); + return Ok(()); + } + + println!( + "{}", + format!("Found {} tags. Deleting...", tags.len()).blue() + ); + + // Delete tags on remote using one push command + let mut push_command = Command::new("git"); + push_command + .current_dir(temp_path) + .arg("push") + .arg("origin") + .arg("--delete"); + + // Add all tags to the delete command + for tag in &tags { + push_command.arg(tag); + } + + // Execute the push command + let push_output = push_command + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output()?; + + if !push_output.status.success() { + let stderr = String::from_utf8_lossy(&push_output.stderr); + eprintln!("{}", format!("Error pushing tag deletions: {stderr}").red()); + return Err("Git push --delete failed".into()); + } + + println!("{}", "Done.".yellow()); + Ok(()) +} + +/// Creates a new v0.1.0 release. +pub fn create_release(client: &GitHubClient, repo: &str) -> Result<(), reqwest::Error> { + let release_data = CreateRelease { + tag_name: "v0.1.0".to_string(), + target_commitish: "main".to_string(), + name: "v0.1.0".to_string(), + body: "First release of github-rs.".to_string(), + draft: false, + prerelease: false, + generate_release_notes: true, + }; + + let path = &format!("repos/{repo}/releases"); + match client.post::<_, serde_json::Value>(path, &release_data) { + Ok(res) => { + println!( + "{}", + format!("Successfully created release v0.1.0 for {repo}.").green() + ); + if let Some(url) = res["html_url"].as_str() { + println!("Release URL: {}", url.cyan()); + } + } + Err(e) => { + eprintln!("{}", format!("Error creating release: {e}").red()); + } + } + + Ok(()) +} diff --git a/backpack/lib/src/github/tests.rs b/backpack/lib/src/github/tests.rs index 82ebf55..d6569fd 100644 --- a/backpack/lib/src/github/tests.rs +++ b/backpack/lib/src/github/tests.rs @@ -1,20 +1,19 @@ -#[cfg(test)] -mod tests { - use anyhow::{Context, Result}; - use mockito; - use reqwest::blocking::Client; - use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, USER_AGENT}; +use anyhow::{Context, Result}; +use mockito; +use reqwest::blocking::Client; +use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, USER_AGENT}; - use crate::github::{DEPENDABOT_USER, MergeResponse, PullRequest, User}; +use crate::github::pr::{MergeResponse, PullRequest}; +use crate::github::{DEPENDABOT_USER, User}; - const REPO: &str = "test_owner/test_repo"; - const TOKEN: &str = "test_token"; +const REPO: &str = "test_owner/test_repo"; +const TOKEN: &str = "test_token"; - // --- Data Structure Tests --- +// --- Data Structure Tests --- - #[test] - fn deserialize_pull_request() -> Result<()> { - let json_input = r#"{ +#[test] +fn deserialize_pull_request() -> Result<()> { + let json_input = r#"{ "number": 123, "title": "chore: bump rust from 1.70.0 to 1.70.1", "user": { @@ -22,130 +21,414 @@ mod tests { } }"#; - let pr: PullRequest = serde_json::from_str(json_input)?; + let pr: PullRequest = serde_json::from_str(json_input)?; - assert_eq!(pr.number, 123); - assert_eq!(pr.title, "chore: bump rust from 1.70.0 to 1.70.1"); - assert_eq!(pr.user.login, DEPENDABOT_USER); - Ok(()) - } + assert_eq!(pr.number, 123); + assert_eq!(pr.title, "chore: bump rust from 1.70.0 to 1.70.1"); + assert_eq!(pr.user.login, DEPENDABOT_USER); + Ok(()) +} - // --- API Function Tests (using mockito) --- +// --- API Function Tests (using mockito) --- - #[test] - fn test_list_dependabot_prs_success() -> Result<()> { - // 1. Setup Mock Server - let mut server = mockito::Server::new(); - let mock_base = server.url(); +#[test] +fn test_list_dependabot_prs_success() -> Result<()> { + // 1. Setup Mock Server + let mut server = mockito::Server::new(); + let mock_base = server.url(); - let body = format!( - r#" + let body = format!( + r#" [ {{ "number": 1, "title": "Dependabot PR", "user": {{ "login": "{}" }} }}, {{ "number": 2, "title": "Manual PR", "user": {{ "login": "some_user" }} }}, {{ "number": 3, "title": "Another Dependabot PR", "user": {{ "login": "{}" }} }} ]"#, - DEPENDABOT_USER, DEPENDABOT_USER - ); + DEPENDABOT_USER, DEPENDABOT_USER + ); - let mock = server - .mock("GET", format!("/repos/{}/pulls", REPO).as_str()) - .match_query("state=open&per_page=100") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(body) - .create(); + let mock = server + .mock("GET", format!("/repos/{}/pulls", REPO).as_str()) + .match_query("state=open&per_page=100") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(body) + .create(); - // 2. Call Function (Manually making the request to the mock URL) - let client = Client::builder().build()?; + // 2. Call Function (Manually making the request to the mock URL) + let client = Client::builder().build()?; - let url = format!("{}/repos/{}/pulls?state=open&per_page=100", mock_base, REPO); + let url = format!("{}/repos/{}/pulls?state=open&per_page=100", mock_base, REPO); - let response = client - .get(&url) - .header(AUTHORIZATION, format!("Bearer {}", TOKEN)) - .header(ACCEPT, "application/vnd.github.v3+json") - .header(USER_AGENT, "DependabotAutoMerger") - .send() - .context("Failed to send list PRs request")?; + let response = client + .get(&url) + .header(AUTHORIZATION, format!("Bearer {}", TOKEN)) + .header(ACCEPT, "application/vnd.github.v3+json") + .header(USER_AGENT, "DependabotAutoMerger") + .send() + .context("Failed to send list PRs request")?; - let all_prs: Vec = response.json()?; + let all_prs: Vec = response.json()?; - let dependabot_prs: Vec = all_prs - .into_iter() - .filter(|pr| pr.user.login == DEPENDABOT_USER) - .collect(); + let dependabot_prs: Vec = all_prs + .into_iter() + .filter(|pr| pr.user.login == DEPENDABOT_USER) + .collect(); + + // 3. Assertions + mock.assert(); + assert_eq!( + dependabot_prs.len(), + 2, + "Should have filtered out the manual PR." + ); + assert!( + dependabot_prs + .iter() + .all(|pr| pr.user.login == DEPENDABOT_USER) + ); + + Ok(()) +} + +#[test] +fn test_attempt_merge_success() -> Result<()> { + // 1. Setup Mock Server + let mut server = mockito::Server::new(); + let pr_number = 456; + + let merge_body = r#"{ "message": "Pull Request successfully merged", "sha": "abcdef123456" }"#; + + let mock = server + .mock( + "PUT", + format!("/repos/{}/pulls/{}/merge", REPO, pr_number).as_str(), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(merge_body) + .create(); + + // 2. Call Function (using mockito server URL as base) + let client = Client::builder().build()?; + let pr = PullRequest { + number: pr_number, + title: "Test PR".to_string(), + user: User { + login: DEPENDABOT_USER.to_string(), + }, + }; + + let mock_base = server.url(); + + // Manually build the merge URL using the mock server's URL + let merge_url = format!("{}/repos/{}/pulls/{}/merge", mock_base, REPO, pr.number); + let merge_body_json = serde_json::json!({ + "commit_title": format!("{} (#{})", pr.title, pr.number), + "commit_message": "Automated merge by Rust utility.", + "merge_method": "squash" + }); + + let response = client + .put(&merge_url) + .header(AUTHORIZATION, format!("Bearer {}", TOKEN)) + .header(ACCEPT, "application/vnd.github.v3+json") + .header(CONTENT_TYPE, "application/json") + .header(USER_AGENT, "DependabotAutoMerger") + .json(&merge_body_json) + .send()?; + + // 3. Assertions + mock.assert(); + assert!(response.status().is_success()); + let response_data: MergeResponse = response.json()?; + assert_eq!(response_data.sha.unwrap(), "abcdef123456"); + + Ok(()) +} + +use super::*; +use mockito::{Mock, Server, ServerGuard}; +use serde_json::json; + +async fn setup_mock_server() -> ServerGuard { + Server::new_async().await +} + +fn create_workflow_run_json( + id: u64, + name: &str, + status: &str, + conclusion: Option<&str>, +) -> serde_json::Value { + json!({ + "id": id, + "name": name, + "status": status, + "conclusion": conclusion, + "html_url": format!("https://github.com/owner/repo/actions/runs/{}", id) + }) +} + +#[tokio::test] +async fn test_get_workflow_runs_success() { + let mut server = setup_mock_server().await; + + let mock = server + .mock("GET", "/repos/owner/repo/actions/runs") + .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded( + "head_sha".into(), + "abc123".into(), + )])) + .match_header("authorization", "Bearer test-token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "workflow_runs": [ + create_workflow_run_json(1, "CI", "completed", Some("success")), + create_workflow_run_json(2, "Tests", "completed", Some("failure")), + ] + }) + .to_string(), + ) + .create_async() + .await; + + // This is a conceptual test - you'd need to modify get_workflow_runs + // to accept a base_url parameter for testing + // let runs = get_workflow_runs("test-token", "owner/repo", "abc123", Some(&server.url())).await.unwrap(); + + // assert_eq!(runs.len(), 2); + // assert_eq!(runs[0].id, 1); + // assert_eq!(runs[0].name, "CI"); + // assert_eq!(runs[1].conclusion, Some("failure".to_string())); + + // placeholder: no HTTP request is made in this unit test + +} + +#[tokio::test] +async fn test_get_workflow_runs_empty_response() { + let mut server = setup_mock_server().await; + + let mock = server + .mock("GET", "/repos/owner/repo/actions/runs") + .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded( + "head_sha".into(), + "abc123".into(), + )])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({"workflow_runs": []}).to_string()) + .create_async() + .await; + + // let runs = get_workflow_runs("test-token", "owner/repo", "abc123", Some(&server.url())).await.unwrap(); + // assert_eq!(runs.len(), 0); + + // placeholder: no HTTP request is made in this unit test + +} - // 3. Assertions - mock.assert(); - assert_eq!( - dependabot_prs.len(), +#[tokio::test] +async fn test_get_workflow_runs_api_error() { + let mut server = setup_mock_server().await; + + let mock = server + .mock("GET", "/repos/owner/repo/actions/runs") + .with_status(401) + .with_header("content-type", "application/json") + .with_body(json!({"message": "Bad credentials"}).to_string()) + .create_async() + .await; + + // let result = get_workflow_runs("bad-token", "owner/repo", "abc123", Some(&server.url())).await; + // assert!(result.is_err()); + + // placeholder: no HTTP request is made in this unit test + +} + +#[tokio::test] +async fn test_rerun_workflow_success() { + let mut server = setup_mock_server().await; + + let mock = server + .mock( + "POST", + "/repos/owner/repo/actions/runs/123/rerun-failed-jobs", + ) + .match_header("authorization", "Bearer test-token") + .with_status(201) + .create_async() + .await; + + // let result = rerun_workflow("test-token", "owner/repo", 123, Some(&server.url())).await; + // assert!(result.is_ok()); + + // placeholder: no HTTP request is made in this unit test + +} + +#[tokio::test] +async fn test_rerun_workflow_failure() { + let mut server = setup_mock_server().await; + + let mock = server + .mock( + "POST", + "/repos/owner/repo/actions/runs/123/rerun-failed-jobs", + ) + .with_status(403) + .with_header("content-type", "application/json") + .with_body(json!({"message": "Forbidden"}).to_string()) + .create_async() + .await; + + // let result = rerun_workflow("test-token", "owner/repo", 123, Some(&server.url())).await; + // assert!(result.is_err()); + + // placeholder: no HTTP request is made in this unit test + +} + +#[test] +fn test_filter_failed_runs() { + let runs = vec![ + serde_json::from_value::(create_workflow_run_json( + 1, + "CI", + "completed", + Some("success"), + )) + .unwrap(), + serde_json::from_value::(create_workflow_run_json( 2, - "Should have filtered out the manual PR." - ); - assert!( - dependabot_prs - .iter() - .all(|pr| pr.user.login == DEPENDABOT_USER) - ); - - Ok(()) + "Tests", + "completed", + Some("failure"), + )) + .unwrap(), + serde_json::from_value::(create_workflow_run_json( + 3, + "Build", + "completed", + Some("timed_out"), + )) + .unwrap(), + serde_json::from_value::(create_workflow_run_json( + 4, + "Lint", + "completed", + Some("cancelled"), + )) + .unwrap(), + serde_json::from_value::(create_workflow_run_json( + 5, + "Deploy", + "in_progress", + None, + )) + .unwrap(), + ]; + + let failed: Vec<_> = runs + .iter() + .filter(|run| { + run.conclusion.as_deref() == Some("failure") + || run.conclusion.as_deref() == Some("timed_out") + || run.conclusion.as_deref() == Some("cancelled") + }) + .collect(); + + assert_eq!(failed.len(), 3); + assert_eq!(failed[0].id, 2); + assert_eq!(failed[1].id, 3); + assert_eq!(failed[2].id, 4); +} + +#[test] +fn test_parse_github_url_https() { + let url = "https://github.com/owner/repo.git"; + let repo = url + .trim_end_matches(".git") + .split('/') + .rev() + .take(2) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("/"); + + assert_eq!(repo, "owner/repo"); +} + +#[test] +fn test_parse_github_url_ssh() { + let url = "git@github.com:owner/repo.git"; + // This test shows the current parsing logic doesn't handle SSH URLs + // You may want to improve the parsing logic in get_repo_from_git + let parts: Vec<&str> = url.trim_end_matches(".git").split(':').collect(); + let repo = if parts.len() == 2 { + parts[1].to_string() + } else { + url.split('/') + .rev() + .take(2) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("/") + }; + + assert_eq!(repo, "owner/repo"); +} + +// Add this to your WorkflowRun struct for testing +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +struct WorkflowRun { + id: u64, + name: String, + status: String, + conclusion: Option, + html_url: String, +} + +// Integration test helpers +#[cfg(test)] +mod integration_tests { + use std::process::Command; + + #[test] + #[ignore] // Ignore by default as it requires git setup + fn test_get_latest_commit_integration() { + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .expect("Failed to execute git command"); + + assert!(output.status.success()); + let commit = String::from_utf8(output.stdout).unwrap(); + assert!(!commit.trim().is_empty()); + assert_eq!(commit.trim().len(), 40); // SHA-1 hash length } #[test] - fn test_attempt_merge_success() -> Result<()> { - // 1. Setup Mock Server - let mut server = mockito::Server::new(); - let pr_number = 456; - - let merge_body = - r#"{ "message": "Pull Request successfully merged", "sha": "abcdef123456" }"#; - - let mock = server - .mock( - "PUT", - format!("/repos/{}/pulls/{}/merge", REPO, pr_number).as_str(), - ) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(merge_body) - .create(); - - // 2. Call Function (using mockito server URL as base) - let client = Client::builder().build()?; - let pr = PullRequest { - number: pr_number, - title: "Test PR".to_string(), - user: User { - login: DEPENDABOT_USER.to_string(), - }, - }; - - let mock_base = server.url(); - - // Manually build the merge URL using the mock server's URL - let merge_url = format!("{}/repos/{}/pulls/{}/merge", mock_base, REPO, pr.number); - let merge_body_json = serde_json::json!({ - "commit_title": format!("{} (#{})", pr.title, pr.number), - "commit_message": "Automated merge by Rust utility.", - "merge_method": "squash" - }); - - let response = client - .put(&merge_url) - .header(AUTHORIZATION, format!("Bearer {}", TOKEN)) - .header(ACCEPT, "application/vnd.github.v3+json") - .header(CONTENT_TYPE, "application/json") - .header(USER_AGENT, "DependabotAutoMerger") - .json(&merge_body_json) - .send()?; - - // 3. Assertions - mock.assert(); - assert!(response.status().is_success()); - let response_data: MergeResponse = response.json()?; - assert_eq!(response_data.sha.unwrap(), "abcdef123456"); - - Ok(()) + #[ignore] // Ignore by default as it requires git setup + fn test_get_repo_from_git_integration() { + let output = Command::new("git") + .args(["remote", "get-url", "origin"]) + .output() + .expect("Failed to execute git command"); + + if output.status.success() { + let url = String::from_utf8(output.stdout).unwrap(); + assert!(!url.trim().is_empty()); + } } } diff --git a/backpack/lib/src/github/workflow.rs b/backpack/lib/src/github/workflow.rs new file mode 100644 index 0000000..bc3cf15 --- /dev/null +++ b/backpack/lib/src/github/workflow.rs @@ -0,0 +1,308 @@ +use clap::Parser; +use serde::Deserialize; +use std::error::Error; +use std::process::Command; + +use crate::github::GitHubClient; +use crate::log::*; +use colored::Colorize; +use std::thread; +use std::time::Duration; + +#[derive(Debug, Deserialize)] +pub struct WorkflowRun { + id: u64, + name: String, + status: String, + conclusion: Option, + html_url: String, +} + +#[derive(Debug, Deserialize)] +struct WorkflowRunsResponse { + workflow_runs: Vec, +} + +fn get_latest_commit() -> Result> { + let output = Command::new("git").args(["rev-parse", "HEAD"]).output()?; + + if !output.status.success() { + return Err("Failed to get latest commit".into()); + } + + Ok(String::from_utf8(output.stdout)?.trim().to_string()) +} + +fn get_repo_from_git() -> Result> { + let output = Command::new("git") + .args(["remote", "get-url", "origin"]) + .output()?; + + if !output.status.success() { + return Err("Failed to get git remote".into()); + } + + let url = String::from_utf8(output.stdout)?.trim().to_string(); + + // Parse GitHub URL to extract owner/repo + let repo = url + .trim_end_matches(".git") + .split('/') + .rev() + .take(2) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("/"); + + Ok(repo) +} + +async fn get_workflow_runs( + client: &GitHubClient, + repo: &str, + commit: &str, +) -> Result, Box> { + let url = format!( + "https://api.github.com/repos/{}/actions/runs?head_sha={}", + repo, commit + ); + + let http_client = reqwest::Client::new(); + let response = http_client + .get(&url) + .header("Authorization", format!("Bearer {}", client.token)) + .header("User-Agent", "github-workflow-rerunner") + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await?; + + if !response.status().is_success() { + return Err(format!("GitHub API error: {}", response.status()).into()); + } + + let runs: WorkflowRunsResponse = response.json().await?; + Ok(runs.workflow_runs) +} + +async fn rerun_workflow( + client: &GitHubClient, + repo: &str, + run_id: u64, +) -> Result<(), Box> { + let url = format!( + "https://api.github.com/repos/{}/actions/runs/{}/rerun-failed-jobs", + repo, run_id + ); + + let http_client = reqwest::Client::new(); + let response = http_client + .post(&url) + .header("Authorization", format!("Bearer {}", client.token)) + .header("User-Agent", "github-workflow-rerunner") + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await?; + + if !response.status().is_success() { + return Err(format!("Failed to rerun workflow: {}", response.status()).into()); + } + + Ok(()) +} + +pub async fn rerun_workflows( + client: &GitHubClient, + commit: Option, + repo: Option, +) -> Result<(), Box> { + // Get commit SHA + let commit = match commit { + Some(c) => c, + None => { + println!("No commit specified, using latest commit..."); + get_latest_commit()? + } + }; + + println!("Using commit: {}", commit); + + // Get repository + let repo = match repo { + Some(r) => r, + None => { + println!("No repository specified, detecting from git remote..."); + get_repo_from_git()? + } + }; + + println!("Repository: {}\n", repo); + + // Get workflow runs for the commit + println!("Fetching workflow runs..."); + let runs = get_workflow_runs(&client, &repo, &commit).await?; + + if runs.is_empty() { + println!("No workflow runs found for this commit."); + return Ok(()); + } + + // Filter for failed runs + let failed_runs: Vec<_> = runs + .iter() + .filter(|run| { + run.conclusion.as_deref() == Some("failure") + || run.conclusion.as_deref() == Some("timed_out") + || run.conclusion.as_deref() == Some("cancelled") + }) + .collect(); + + if failed_runs.is_empty() { + println!("No failed workflow runs found for this commit."); + println!("\nAll workflows:"); + for run in &runs { + println!(" - {} ({}): {:?}", run.name, run.status, run.conclusion); + } + return Ok(()); + } + + println!("Found {} failed workflow run(s):\n", failed_runs.len()); + + for run in &failed_runs { + println!(" - {} (ID: {})", run.name, run.id); + println!(" Status: {}", run.status); + println!(" Conclusion: {:?}", run.conclusion); + println!(" URL: {}\n", run.html_url); + } + + // Re-run failed workflows + println!("Re-running failed workflows...\n"); + for run in &failed_runs { + print!("Re-running '{}'... ", run.name); + match rerun_workflow(&client, &repo, run.id).await { + Ok(_) => log().success(""), + Err(e) => log().fail(&format!("Failed: {}", e)), + } + } + + log().done("Done"); + Ok(()) +} + +/// Deletes failed/cancelled workflows concurrently using standard threads (max 10 at a time). +pub fn delete_failed_workflows(client: &GitHubClient, repo: &str) { + log().intro(&format!("Deleting failed workflows for {repo}")); + + let path = &format!("repos/{repo}/actions/runs"); + match client.fetch_paginated::(path) { + Ok(runs) => { + let failed_or_cancelled_runs: Vec = runs + .into_iter() + .filter(|r| { + r.conclusion.as_deref() == Some("failure") + || r.conclusion.as_deref() == Some("cancelled") + }) + .map(|r| r.id) + .collect(); + + let count = failed_or_cancelled_runs.len(); + if count > 0 { + // Chunk the runs into groups of 10 for concurrent deletion + let chunked_runs = failed_or_cancelled_runs.chunks(10); + for chunk in chunked_runs { + let mut handles = Vec::new(); + + for id in chunk { + // Clone necessary parts for thread ownership + let client_clone = client.client.clone(); + let token_clone = client.token.clone(); + let api_base_clone = client.api_base.clone(); + let repo_str = repo.to_string(); + let id_copy = *id; + + // Spawn a standard OS thread for deletion + handles.push(thread::spawn(move || { + let delete_path = format!("repos/{repo_str}/actions/runs/{id_copy}"); + let url = api_base_clone.join(&delete_path).unwrap(); + + let res = client_clone + .delete(url) + .bearer_auth(token_clone) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .send(); + + if let Err(e) = res { + log().err(&format!( + "{}", + format!("Error deleting workflow run {id_copy}: {e}").red() + )); + } + })); + } + + // Wait for the current chunk of threads to finish (blocking) + for h in handles { + let _ = h.join(); + } + } + + log().ok(&format!("{count} failed/cancelled workflows deleted.")); + } else { + log().info("No failed/cancelled workflows found."); + } + } + Err(e) => { + log().err(&format!("Error fetching workflow runs: {e}")); + } + } + log().done("Done"); +} + +/// Reruns failed workflow jobs. +pub fn rerun_failed_jobs(client: &GitHubClient, repo: &str) { + println!("{}", format!("Rerun failed jobs for {repo}").yellow()); + + let path = &format!("repos/{repo}/actions/runs"); + match client.fetch_paginated::(path) { + Ok(runs) => { + let failed_runs: Vec = runs + .into_iter() + .filter(|r| r.conclusion.as_deref() == Some("failure")) + .collect(); + + if failed_runs.is_empty() { + println!("{}", "No failed jobs found to rerun.".blue()); + return; + } + + for run in failed_runs { + println!( + "{}", + format!("Rerunning job \"{}\" ({})", run.name, run.id).green() + ); + let rerun_path = + &format!("repos/{}/actions/runs/{}/rerun-failed-jobs", repo, run.id); + + // Use post with an empty body + let res = client.post::<_, serde_json::Value>(rerun_path, &serde_json::json!({})); + + if let Err(e) = res { + eprintln!("{}", format!("Error rerunning job {}: {}", run.id, e).red()); + } else { + // Introduce a slight delay to avoid hitting rate limits too quickly + thread::sleep(Duration::from_millis(500)); + } + } + } + Err(e) => { + eprintln!( + "{}", + format!("Error fetching workflow runs for rerun: {e}").red() + ); + } + } +} diff --git a/backpack/lib/src/github/workflows.rs b/backpack/lib/src/github/workflows.rs new file mode 100644 index 0000000..6b49b25 --- /dev/null +++ b/backpack/lib/src/github/workflows.rs @@ -0,0 +1,105 @@ +use clap::Parser; +use serde::Deserialize; +use std::error::Error; +use std::process::Command; + +#[derive(Debug, Deserialize)] +struct WorkflowRun { + id: u64, + name: String, + status: String, + conclusion: Option, + html_url: String, +} + +#[derive(Debug, Deserialize)] +struct WorkflowRunsResponse { + workflow_runs: Vec, +} + +fn get_latest_commit() -> Result> { + let output = Command::new("git").args(["rev-parse", "HEAD"]).output()?; + + if !output.status.success() { + return Err("Failed to get latest commit".into()); + } + + Ok(String::from_utf8(output.stdout)?.trim().to_string()) +} + +fn get_repo_from_git() -> Result> { + let output = Command::new("git") + .args(["remote", "get-url", "origin"]) + .output()?; + + if !output.status.success() { + return Err("Failed to get git remote".into()); + } + + let url = String::from_utf8(output.stdout)?.trim().to_string(); + + // Parse GitHub URL to extract owner/repo + let repo = url + .trim_end_matches(".git") + .split('/') + .rev() + .take(2) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("/"); + + Ok(repo) +} + +async fn get_workflow_runs( + token: &str, + repo: &str, + commit: &str, +) -> Result, Box> { + let url = format!( + "https://api.github.com/repos/{}/actions/runs?head_sha={}", + repo, commit + ); + + let client = reqwest::Client::new(); + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .header("User-Agent", "github-workflow-rerunner") + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await?; + + if !response.status().is_success() { + return Err(format!("GitHub API error: {}", response.status()).into()); + } + + let runs: WorkflowRunsResponse = response.json().await?; + Ok(runs.workflow_runs) +} + +async fn rerun_workflow(token: &str, repo: &str, run_id: u64) -> Result<(), Box> { + let url = format!( + "https://api.github.com/repos/{}/actions/runs/{}/rerun-failed-jobs", + repo, run_id + ); + + let client = reqwest::Client::new(); + let response = client + .post(&url) + .header("Authorization", format!("Bearer {}", token)) + .header("User-Agent", "github-workflow-rerunner") + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await?; + + if !response.status().is_success() { + return Err(format!("Failed to rerun workflow: {}", response.status()).into()); + } + + Ok(()) +} diff --git a/backpack/lib/src/lib.rs b/backpack/lib/src/lib.rs index e75e51e..c593125 100644 --- a/backpack/lib/src/lib.rs +++ b/backpack/lib/src/lib.rs @@ -1,6 +1,8 @@ pub mod cli; +pub mod ghk; pub mod git; pub mod github; +pub mod log; pub mod plugins; /// Multiplies two integers diff --git a/backpack/lib/src/log.rs b/backpack/lib/src/log.rs new file mode 100644 index 0000000..81e86b2 --- /dev/null +++ b/backpack/lib/src/log.rs @@ -0,0 +1,817 @@ +// Logger utilities +#![allow(dropping_copy_types)] + +use crate::ghk::config; +use once_cell::sync::OnceCell; +use std::{cell::RefCell, sync::Arc, time::Instant}; +use terminal_banner::Banner; +use tracing::{Level, debug, error, info, span, trace, warn}; +use tracing_subscriber::prelude::*; +use tracing_subscriber::{EnvFilter, Registry}; + +/// A global, thread-safe screen logger. +/// +/// Applications call `log().ok("message")` without needing to pass +/// logger instances around. This keeps the API clean and ergonomic. +/// +/// The logger must be initialized once at startup using `set_logger()`. +static LOGGER: OnceCell> = OnceCell::new(); + +/// Set the global logger. +/// +/// This should be called once during program initialization. +/// Subsequent calls are ignored. +pub fn set_logger(logger: L) { + let _ = LOGGER.set(Arc::new(logger)); +} + +/// Retrieve the global logger. +/// +/// Panics if the logger has not been initialized. +/// Applications should call `set_logger()` early in `main()`. +pub fn log() -> &'static Arc { + LOGGER.get().expect("Logger not initialized") +} + +pub fn init() { + // 1. Define the formatted output (The Layer) + let telemetry_fmt = tracing_subscriber::fmt::layer() + .with_ansi(true) + .without_time() + .compact() + .with_file(true) + .with_line_number(true) + .with_thread_ids(true) + .with_target(false); + + // 2. Define the first filter (Environment variable) + let env_filter = EnvFilter::from_default_env(); + + // --- This does not work --- + // 3. Combine the filters: Apply both the environment filter AND the max level filter. + // Note: When chaining filters (env_filter.and(max_level_filter)), the filter that + // allows an event to pass is the intersection of both. + //let combined_filter = env_filter.and(max_level_filter); + + // 4. Construct the registry, applying the format layer and the combined filter layer + let registry = Registry::default() + // Apply formatting layer, filtered by the combined filter + //.with(telemetry_fmt.with_filter(combined_filter)) + .with(telemetry_fmt.with_filter(env_filter)); + // Send traces to tokio console + //.with(console_subscriber::spawn()); + + if let Err(e) = tracing::subscriber::set_global_default(registry) { + eprintln!("Failed to set global default subscriber: {e}"); + } + + tracing::debug!("Logging initialized!"); + tracing::trace!("Tracing initialized!"); + tracing::debug!("Ready to begin..."); + + if std::env::var("RUST_LOG").is_ok() + && ["debug", "trace"].contains(&std::env::var("RUST_LOG").unwrap().to_lowercase().as_str()) + { + let banner = Banner::new() + .text(format!("Welcome to {}!", env!("CARGO_PKG_NAME")).into()) + .text(env!("CARGO_PKG_DESCRIPTION").into()) + .render(); + + println!("{banner}"); + } +} + +/// Cargo-style verbosity levels. +/// +/// Quiet → suppress almost all output +/// Normal → standard CLI output (println) +/// Verbose → use tracing spans + debug-level logs +/// Trace → full tracing, including trace-level logs +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verbosity { + Quiet, // -q + Normal, // default + Verbose, // -v + Trace, // -vv +} + +/// Output format for the logger. +/// +/// Text → human‑friendly CLI output (println / eprintln) +/// Json → machine‑friendly structured output (one JSON object per event) +/// +/// JSON mode is ideal for CI logs, log aggregation, or tools that parse output. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogFormat { + Text, + Json, +} + +/// A span that tracks when it was entered so we can compute +/// how long the task took when outro() / done() is called. +struct TimedSpan { + /// Timestamp when the span was entered + start: Instant, +} + +/// A logger that *only formats* messages into strings. +/// +/// This trait provides: +/// - A set of `_raw` methods that concrete loggers must implement. +/// - Default high-level methods (`ok`, `warn`, etc.) that: +/// • automatically apply quiet-mode suppression +/// • wrap the raw formatting +/// +/// The goal: avoid code duplication across loggers while keeping +/// formatting and quiet-mode logic cleanly separated. +pub trait FormatLogger { + /// Returns true if output should be suppressed. + /// Override if a logger wants custom quiet-mode behavior. + fn is_quiet(&self) -> bool { + config::isquiet() + } + + /// Verbose mode flag (for debug/trace) + fn is_verbose(&self) -> bool { + config::isverbose() + } + + /// Current verbosity level + fn verbosity(&self) -> Verbosity; + + // --------------------------------------------------------------------- + // RAW METHODS (must be implemented by each logger) + // These return *unconditional* formatted strings with no quiet-mode logic. + // --------------------------------------------------------------------- + + /// Format a success message (e.g., green checkmark) + fn ok_raw(&self, m: &str) -> String; + + /// Format a warning message (e.g., yellow warning sign) + fn warn_raw(&self, m: &str) -> String; + + /// Format an error message (always shown) + fn err_raw(&self, m: &str) -> String; + + /// Format an informational message + fn info_raw(&self, m: &str) -> String; + + /// Format a dim/muted message + fn dim_raw(&self, m: &str) -> String; + + /// Format an intro message (start of a task) + fn intro_raw(&self, m: &str) -> String; + + /// Format an outro message (end of a task) + fn outro_raw(&self, m: &str) -> String; + + /// Format a done message (end of a task) + fn done_raw(&self, m: &str) -> String; + + /// Format a step/progress message + fn step_raw(&self, m: &str) -> String; + + /// Debug-level message (verbose only) + fn debug_raw(&self, m: &str) -> String; + + /// Trace-level message (verbose only) + fn trace_raw(&self, m: &str) -> String; + + // --------------------------------------------------------------------- + // DEFAULT METHODS (quiet-mode aware) + // These wrap the raw methods and apply quiet-mode suppression. + // --------------------------------------------------------------------- + + /// Success message (suppressed in quiet mode) + fn ok(&self, m: &str) -> Option { + if self.is_quiet() { + None + } else { + Some(self.ok_raw(m)) + } + } + + /// Warning message (suppressed in quiet mode) + fn warn(&self, m: &str) -> Option { + if self.is_quiet() { + None + } else { + Some(self.warn_raw(m)) + } + } + + /// Error message (never suppressed) + fn err(&self, m: &str) -> String { + self.err_raw(m) + } + + /// Info message (suppressed in quiet mode) + fn info(&self, m: &str) -> Option { + if self.is_quiet() { + None + } else { + Some(self.info_raw(m)) + } + } + + /// Dim/muted message (suppressed in quiet mode) + fn dim(&self, m: &str) -> Option { + if self.is_quiet() { + None + } else { + Some(self.dim_raw(m)) + } + } + + /// Intro message (suppressed in quiet mode) + fn intro(&self, m: &str) -> Option { + if self.is_quiet() { + None + } else { + Some(self.intro_raw(m)) + } + } + + /// Outro message (suppressed in quiet mode) + fn outro(&self, m: &str) -> Option { + if self.is_quiet() { + None + } else { + Some(self.outro_raw(m)) + } + } + + /// Done message (suppressed in quiet mode) + fn done(&self, m: &str) -> Option { + if self.is_quiet() { + None + } else { + Some(self.done_raw(m)) + } + } + + /// Step/progress message (suppressed in quiet mode) + fn step(&self, m: &str) -> Option { + if self.is_quiet() { + None + } else { + Some(self.step_raw(m)) + } + } + + /// Debug messages only appear when verbose mode is enabled. + fn debug(&self, m: &str) -> Option { + if self.is_verbose() { + Some(self.debug_raw(m)) + } else { + None + } + } + + /// Trace messages only appear when verbose mode is enabled. + fn trace(&self, m: &str) -> Option { + if self.is_verbose() { + Some(self.trace_raw(m)) + } else { + None + } + } +} + +/// A simple ANSI-based logger that formats messages using +/// plain ASCII or ANSI escape codes depending on configuration. +/// +/// This logger does *not* print anything — it only formats strings. +/// Quiet-mode suppression is handled by the FormatLogger trait. +pub struct SimpleLogger; + +impl FormatLogger for SimpleLogger { + fn verbosity(&self) -> Verbosity { + Verbosity::Normal + } + fn ok_raw(&self, m: &str) -> String { + // Green checkmark (or ASCII fallback) + if config::isnocolor() { + format!("+ {}", m) + } else { + format!("\x1b[32m✔\x1b[0m {}", m) + } + } + + fn warn_raw(&self, m: &str) -> String { + // Yellow warning sign (or ASCII fallback) + if config::isnocolor() { + format!("! {}", m) + } else { + format!("\x1b[33m⚠\x1b[0m {}", m) + } + } + + fn err_raw(&self, m: &str) -> String { + // Red X (always shown) + if config::isnocolor() { + format!("X {}", m) + } else { + format!("\x1b[31m✗\x1b[0m {}", m) + } + } + + fn info_raw(&self, m: &str) -> String { + // Simple indented info line + format!(" {}", m) + } + + fn dim_raw(&self, m: &str) -> String { + // Dim gray (or plain) + if config::isnocolor() { + format!(" {}", m) + } else { + format!("\x1b[90m {}\x1b[0m", m) + } + } + + fn intro_raw(&self, m: &str) -> String { + // Start of a task + format!("→ {}", m) + } + + fn outro_raw(&self, m: &str) -> String { + // End of a task + format!("✓ {}", m) + } + + fn done_raw(&self, m: &str) -> String { + // End of a task + format!("✓ Done! {}", m) + } + + fn step_raw(&self, m: &str) -> String { + // Progress indicator + if config::isnocolor() { + format!("* {}", m) + } else { + format!("\x1b[36m⠿\x1b[0m {}", m) + } + } + + fn debug_raw(&self, m: &str) -> String { + if config::isnocolor() { + format!("[debug] {m}") + } else { + format!("\x1b[34m[debug]\x1b[0m {m}") + } + } + + fn trace_raw(&self, m: &str) -> String { + if config::isnocolor() { + format!("[trace] {m}") + } else { + format!("\x1b[90m[trace]\x1b[0m {m}") + } + } +} + +/// A logger that *prints* messages to the screen. +/// +/// This trait represents the high-level logging API that most users interact with. +/// It does not format messages itself — instead, it delegates to a `FormatLogger` +/// implementation and then prints the resulting strings. +/// +/// All quiet-mode logic is handled in the FormatLogger layer. +pub trait ScreenLogger { + /// Print a success message + fn ok(&self, m: &str); + + /// Print a warning message + fn warn(&self, m: &str); + + /// Print an error message (never suppressed) + fn err(&self, m: &str); + + /// Print an informational message + fn info(&self, m: &str); + + /// Print a dim/muted message + fn dim(&self, m: &str); + + /// Print an intro message (start of a task) + fn intro(&self, m: &str); + + /// Print an outro message (end of a task) + fn outro(&self, m: &str); + + /// Print a done message (end of a task) + fn done(&self, m: &str); + + /// Print a step/progress message + fn step(&self, m: &str); + + /// Verbose-only debug message + fn debug(&self, m: &str); + + /// Verbose-only trace message + fn trace(&self, m: &str); + + // Backwards-compatible aliases + fn success(&self, m: &str) { + self.ok(m); + } + fn sucess(&self, m: &str) { + self.ok(m); + } + fn fail(&self, m: &str) { + self.err(m); + } + fn warning(&self, m: &str) { + self.warn(m); + } + fn error(&self, m: &str) { + self.err(m); + } +} + +/// A screen logger that prints formatted messages and, in verbose/trace mode, +/// also emits structured tracing spans. +/// +/// This logger supports: +/// - Nested task spans via intro() / outro() / done() +/// - Nested step spans inside tasks +/// - Timing of task spans +/// - Cargo-style verbosity levels +pub struct Printer { + /// The underlying formatter (SimpleLogger, ModernLogger, etc.) + inner: L, + + /// Stack of active task spans created by intro() + tasks: RefCell>, + + /// Stack of active step spans created by step() + steps: RefCell>, + + /// Output format: Text (default) or Json + /// + /// When Json is selected: + /// - All log events are emitted as JSON objects + /// - stdout/stderr is still respected based on log level + /// - tracing spans are still created in verbose/trace mode + format: LogFormat, +} + +impl Printer { + /// Create a new printer with the given formatter and output format. + pub fn new(inner: L, format: LogFormat) -> Self { + Self { + inner, + tasks: RefCell::new(Vec::new()), + steps: RefCell::new(Vec::new()), + format, + } + } +} + +impl ScreenLogger for Printer { + fn intro(&self, m: &str) { + // Format intro message (suppressed in Quiet mode) + if let Some(s) = self.inner.intro(m) { + match (self.inner.verbosity(), self.format) { + // JSON mode: emit structured event + (_, LogFormat::Json) => { + self.emit_json("info", &s); + } + + // Verbose/Trace: create a tracing span + timing + (Verbosity::Verbose | Verbosity::Trace, LogFormat::Text) => { + // Create a new tracing span for this task (no enter to keep lifetimes simple) + let _sp = span!(Level::INFO, "task", message = %m); + + // Push a timed marker + self.tasks.borrow_mut().push(TimedSpan { + start: Instant::now(), + }); + + // Emit intro message through tracing + info!("{s}"); + } + + // Normal mode: plain stdout + _ => println!("{s}"), + } + } + } + + fn outro(&self, m: &str) { + // Format outro message (suppressed in Quiet mode) + if let Some(s) = self.inner.outro(m) { + match (self.inner.verbosity(), self.format) { + // JSON mode: structured output + (_, LogFormat::Json) => { + self.emit_json("info", &s); + } + + // Verbose/Trace: close spans + timing + (Verbosity::Verbose | Verbosity::Trace, LogFormat::Text) => { + // Close all step spans first (automatic cleanup) + let mut steps = self.steps.borrow_mut(); + while let Some(step) = steps.pop() { + drop(step); + } + + // Close the task span + if let Some(TimedSpan { start }) = self.tasks.borrow_mut().pop() { + let elapsed = start.elapsed(); + + // Emit outro message with timing + info!("{s} (took {:?})", elapsed); + } + } + + // Normal mode + _ => println!("{s}"), + } + } + } + + fn done(&self, m: &str) { + // Format done message (suppressed in Quiet mode) + if let Some(s) = self.inner.done(m) { + match (self.inner.verbosity(), self.format) { + // JSON mode: structured output + (_, LogFormat::Json) => { + self.emit_json("info", &s); + } + + // Verbose/Trace: close spans + timing + (Verbosity::Verbose | Verbosity::Trace, LogFormat::Text) => { + // Close all step spans first (automatic cleanup) + let mut steps = self.steps.borrow_mut(); + while let Some(step) = steps.pop() { + drop(step); + } + + // Close the task span + if let Some(TimedSpan { start }) = self.tasks.borrow_mut().pop() { + let elapsed = start.elapsed(); + + // Emit done message with timing + info!("{s} (took {:?})", elapsed); + } + } + + // Normal mode + _ => println!("{s}"), + } + } + } + + fn step(&self, m: &str) { + // Format step message (suppressed in Quiet mode) + if let Some(s) = self.inner.step(m) { + match (self.inner.verbosity(), self.format) { + // JSON mode: structured output + (_, LogFormat::Json) => { + self.emit_json("info", &s); + } + + // Verbose/Trace: nested spans + (Verbosity::Verbose | Verbosity::Trace, LogFormat::Text) => { + // Automatically close previous step marker if one exists + let _ = self.steps.borrow_mut().pop(); + + // Push a simple step marker + self.steps.borrow_mut().push(()); + + // Emit step message through tracing + info!("{s}"); + } + + // Normal mode + _ => println!("{s}"), + } + } + } + + fn ok(&self, m: &str) { + // Format success message (suppressed in Quiet mode) + if let Some(s) = self.inner.ok(m) { + match (self.inner.verbosity(), self.format) { + // JSON mode + (_, LogFormat::Json) => self.emit_json("info", &s), + + // Verbose/Trace: tracing event + (Verbosity::Verbose | Verbosity::Trace, LogFormat::Text) => info!("{s}"), + + // Normal mode + _ => println!("{s}"), + } + } + } + + fn warn(&self, m: &str) { + // Format warning message (suppressed in Quiet mode) + if let Some(s) = self.inner.warn(m) { + match (self.inner.verbosity(), self.format) { + // JSON mode + (_, LogFormat::Json) => self.emit_json("warn", &s), + + // Verbose/Trace + (Verbosity::Verbose | Verbosity::Trace, LogFormat::Text) => warn!("{s}"), + + // Normal mode + _ => println!("{s}"), + } + } + } + + fn err(&self, m: &str) { + // Errors are never suppressed + let s = self.inner.err(m); + + match (self.inner.verbosity(), self.format) { + // JSON mode + (_, LogFormat::Json) => self.emit_json("error", &s), + + // Verbose/Trace + (Verbosity::Verbose | Verbosity::Trace, LogFormat::Text) => error!("{s}"), + + // Normal mode + _ => eprintln!("{s}"), + } + } + + fn info(&self, m: &str) { + // Format info message (suppressed in Quiet mode) + if let Some(s) = self.inner.info(m) { + match (self.inner.verbosity(), self.format) { + (_, LogFormat::Json) => self.emit_json("info", &s), + (Verbosity::Verbose | Verbosity::Trace, LogFormat::Text) => info!("{s}"), + _ => println!("{s}"), + } + } + } + + fn dim(&self, m: &str) { + // Dim messages (suppressed in Quiet mode) + if let Some(s) = self.inner.dim(m) { + match (self.inner.verbosity(), self.format) { + (_, LogFormat::Json) => self.emit_json("debug", &s), + (Verbosity::Verbose | Verbosity::Trace, LogFormat::Text) => debug!("{s}"), + _ => println!("{s}"), + } + } + } + + fn debug(&self, m: &str) { + // Debug messages only appear in Verbose/Trace mode + if let Some(s) = self.inner.debug(m) { + match (self.inner.verbosity(), self.format) { + (_, LogFormat::Json) => self.emit_json("debug", &s), + (Verbosity::Verbose | Verbosity::Trace, LogFormat::Text) => debug!("{s}"), + _ => println!("{s}"), + } + } + } + + fn trace(&self, m: &str) { + // Trace messages only appear in Trace mode + if let Some(s) = self.inner.trace(m) { + match (self.inner.verbosity(), self.format) { + (_, LogFormat::Json) => self.emit_json("trace", &s), + (Verbosity::Trace, LogFormat::Text) => trace!("{s}"), + _ => println!("{s}"), + } + } + } +} + +impl Printer { + /// Emit a single log event as a JSON object. + /// + /// This function: + /// - Builds a structured JSON object using serde_json + /// - Includes a timestamp for easier log correlation + /// - Sends errors to stderr and all other levels to stdout + /// - Does NOT create tracing spans (that happens elsewhere) + /// + /// Example output: + /// { + /// "level": "info", + /// "message": "Uploading assets", + /// "timestamp": "2026-01-15T01:17:22.123Z" + /// } + fn emit_json(&self, level: &str, message: &str) { + // Construct a structured JSON object + let obj = serde_json::json!({ + "level": level, + "message": message, + "timestamp": chrono::Utc::now().to_rfc3339(), + }); + + // Print to stderr for errors, stdout otherwise + match level { + "error" => eprintln!("{}", obj.to_string()), + _ => println!("{}", obj.to_string()), + } + } +} + +/// A modern, minimal logger inspired by cliclack's visual style. +/// +/// This logger only *formats* messages — it does not print. +/// Quiet-mode suppression is handled by the FormatLogger trait. +pub struct ModernLogger; + +impl FormatLogger for ModernLogger { + fn verbosity(&self) -> Verbosity { + Verbosity::Normal + } + fn ok_raw(&self, m: &str) -> String { + // Clean success checkmark + format!("✔ {}", m) + } + + fn warn_raw(&self, m: &str) -> String { + // Clean warning symbol + format!("⚠ {}", m) + } + + fn err_raw(&self, m: &str) -> String { + // Clean error symbol + format!("✗ {}", m) + } + + fn info_raw(&self, m: &str) -> String { + // Info symbol + format!("ℹ {}", m) + } + + fn dim_raw(&self, m: &str) -> String { + // Muted remark-style prefix + format!("› {}", m) + } + + fn intro_raw(&self, m: &str) -> String { + // Start of a task + format!("→ {}", m) + } + + fn outro_raw(&self, m: &str) -> String { + // End of a task + format!("✔ {}", m) + } + + fn done_raw(&self, m: &str) -> String { + // End of a task + format!("✔ {}", m) + } + + fn step_raw(&self, m: &str) -> String { + // Progress indicator + format!("⠿ {}", m) + } + + fn debug_raw(&self, m: &str) -> String { + format!("🔍 {}", m) + } + + fn trace_raw(&self, m: &str) -> String { + format!("… {}", m) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ghk::config; + + #[test] + fn simple_logger_color_and_nocolor() { + // Ensure nocolor affects output formatting + config::setnocolor(true); + let s = SimpleLogger.ok_raw("ok"); + assert_eq!(s, "+ ok"); + config::setnocolor(false); + let s2 = SimpleLogger.ok_raw("ok"); + assert!(s2.contains("✔")); + } + + #[test] + fn simple_logger_defaults_quiet() { + // Quiet mode should suppress optional messages + config::setquiet(true); + let l = SimpleLogger; + assert!(l.ok("hi").is_none()); + assert!(l.warn("hi").is_none()); + assert!(l.info("hi").is_none()); + assert!(l.dim("hi").is_none()); + config::setquiet(false); + assert!(l.ok("hi").is_some()); + } + + #[test] + fn modern_logger_raws() { + let m = ModernLogger; + assert_eq!(m.ok_raw("x"), "✔ x"); + assert_eq!(m.warn_raw("y"), "⚠ y"); + assert_eq!(m.err_raw("e"), "✗ e"); + } +} diff --git a/backpack/lib/src/plugins/mod.rs b/backpack/lib/src/plugins/mod.rs index c2f9d10..dd83557 100644 --- a/backpack/lib/src/plugins/mod.rs +++ b/backpack/lib/src/plugins/mod.rs @@ -87,9 +87,9 @@ impl Plugin { color::cprint(message, "green") }); - // Add HTTP fetch via http::client().request - use rhai::packages::Package; - rhai_http::HttpPackage::new().register_into_engine(&mut engine); + // FIX: Add HTTP fetch via http::client().request + //use rhai::packages::Package; + //rhai_http::HttpPackage::new().register_into_engine(&mut engine); engine } diff --git a/backpack/lib/src/plugins/tests.rs b/backpack/lib/src/plugins/tests.rs index 4108c35..d9bb613 100644 --- a/backpack/lib/src/plugins/tests.rs +++ b/backpack/lib/src/plugins/tests.rs @@ -183,9 +183,9 @@ mod tests { // Only two plugins should be loaded successfully assert_eq!(plugins.len(), 2); - // The sorting logic in discover_plugins() ensures they are sorted by name: a-first-plugin, b-second-plugin - assert_eq!(plugins[0].manifest.name, "b-second-plugin"); - assert_eq!(plugins[1].manifest.name, "a-first-plugin"); + // Plugins sorted alphabetically by name: a-first-plugin, b-second-plugin + assert_eq!(plugins[0].manifest.name, "a-first-plugin"); + assert_eq!(plugins[1].manifest.name, "b-second-plugin"); } // --- Plugin::run_script Tests --- diff --git a/sonar-project.properties b/sonar-project.properties index dbcc0d4..2565714 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,8 +1,8 @@ -sonar.projectKey=yonasBSD_rust-ci-github-actions-workflow +sonar.projectKey=yonasBSD_github-bot sonar.organization=yonasbsd # This is the name and version displayed in the SonarCloud UI. -#sonar.projectName=rust-ci-github-actions-workflow +#sonar.projectName=github-bot #sonar.projectVersion=1.0 diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 0dc2d19..b17515e 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -4,7 +4,7 @@ version = "0.2.0" edition = "2024" license = "Apache-2.0" description = "Builder" -repository = "https://github.com/yonasBSD/rust-ci-github-actions-workflow" +repository = "https://github.com/yonasBSD/github-bot" readme = "README.md" keywords = ["template", "rust"] categories = ["template"]