Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ members = [
".",
"common/repos-github",
"plugins/repos-health",
"plugins/repos-review",
"plugins/repos-validate",
]

Expand Down
4 changes: 3 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,13 @@ list-plugins:
[group('devex')]
link-plugins:
sudo ln -sf $(pwd)/target/release/repos-health /usr/local/bin/repos-health
sudo ln -sf $(pwd)/target/release/repos-health /usr/local/bin/repos-health
sudo ln -sf $(pwd)/target/release/repos-validate /usr/local/bin/repos-validate
sudo ln -sf $(pwd)/target/release/repos-review /usr/local/bin/repos-review

[group('devex')]
unlink-plugins:
sudo rm -f /usr/local/bin/repos-health
sudo rm -f /usr/local/bin/repos-validate
sudo rm -f /usr/local/bin/repos-review

# vim: set filetype=Makefile ts=4 sw=4 et:
16 changes: 16 additions & 0 deletions plugins/repos-review/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[package]
name = "repos-review"
version = "0.1.0"
edition = "2024"

[[bin]]
name = "repos-review"
path = "src/main.rs"

[dependencies]
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

[dependencies.repos]
path = "../.."
61 changes: 61 additions & 0 deletions plugins/repos-review/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# repos-review

Interactive repository review plugin for the `repos` CLI tool.

## Overview

`repos-review` allows you to interactively review changes made in repositories before creating a pull request. It uses `fzf` for repository selection with a live preview of `git status`, then displays both `git status` and `git diff` for detailed review.

## Requirements

- `fzf` - Fuzzy finder for interactive repository selection
- Install on macOS: `brew install fzf`
- Install on Linux: Use your package manager (e.g., `apt install fzf`, `yum install fzf`)

## Usage

```bash
repos review
```

The plugin will:

1. Display a list of all repositories with an `fzf` interface
2. Show a preview of `git status` for each repository
3. After selection, display full `git status` and `git diff`
4. Wait for user input to either:
- Press **Enter** to go back to the repository list
- Press **Escape** or **Q** to exit

## Features

- **Interactive Selection**: Uses `fzf` with live preview of repository status
- **Color Output**: Syntax highlighting for better readability
- **Loop Mode**: Review multiple repositories in a single session
- **Simple Navigation**: Easy keyboard controls for efficient workflow

## Example Workflow

```bash
# Review changes across all repositories
repos review

# Use with tag filters to review specific repositories
repos review --tags backend

# Review repositories matching a pattern
repos review --pattern "api-*"
```

## Key Bindings

- **↑/↓** or **Ctrl-N/Ctrl-P**: Navigate repository list
- **Enter**: Select repository for review
- **Escape** or **Q**: Exit after reviewing a repository
- **Enter** (in review): Return to repository list

## Notes

- The plugin respects the same filters as other `repos` commands (`--tags`, `--pattern`, etc.)
- Only repositories with a configured path are shown
- If `fzf` is not installed, the plugin will exit with an error message
175 changes: 175 additions & 0 deletions plugins/repos-review/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
use anyhow::{Context, Result};
use repos::Repository;
use std::env;
use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};

fn main() -> Result<()> {
let _args: Vec<String> = env::args().collect();

// Load context injected by core repos CLI
let repos = repos::load_plugin_context()
.context("Failed to load plugin context")?
.ok_or_else(|| anyhow::anyhow!("Plugin must be invoked via repos CLI"))?;

// Check if fzf is available
if !is_fzf_available() {
eprintln!("Error: fzf must be installed.");
eprintln!("Install it via: brew install fzf (macOS) or your package manager");
std::process::exit(1);
}

// Main loop: select and review repositories
loop {
match select_repository(&repos)? {
Some(repo) => {
review_repository(&repo)?;
}
None => {
println!("No repo selected. Exiting.");
break;
}
}
}

Ok(())
}

/// Check if fzf is installed and available in PATH
fn is_fzf_available() -> bool {
Command::new("which")
.arg("fzf")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}

/// Use fzf to select a repository interactively
fn select_repository(repos: &[Repository]) -> Result<Option<Repository>> {
// Build list of repository paths for fzf
let repo_list: Vec<String> = repos
.iter()
.filter_map(|r| r.path.as_ref())
.map(|p| p.to_string())
.collect();

if repo_list.is_empty() {
return Ok(None);
}

let input = repo_list.join("\n");

// Launch fzf with preview showing git status
let mut fzf = Command::new("fzf")
.args([
"--color=fg:#4d4d4c,bg:#eeeeee,hl:#d7005f",
"--color=fg+:#4d4d4c,bg+:#e8e8e8,hl+:#d7005f",
"--color=info:#4271ae,prompt:#8959a8,pointer:#d7005f",
"--color=marker:#4271ae,spinner:#4271ae,header:#4271ae",
"--height=100%",
"--ansi",
"--preview",
r#"if git -C {} diff-index --quiet HEAD -- 2>/dev/null; then git -C {} status | head -20 | awk 'NF {print "\033[32m" $0 "\033[0m"}'; else git -C {} status | head -20 | awk 'NF {print "\033[31m" $0 "\033[0m"}'; fi"#,
"--preview-window=right:50%",
"--no-sort",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.context("Failed to spawn fzf")?;

// Write repo list to fzf's stdin
if let Some(mut stdin) = fzf.stdin.take() {
stdin
.write_all(input.as_bytes())
.context("Failed to write to fzf stdin")?;
}

// Get selected repository path from fzf
let output = fzf.wait_with_output().context("Failed to wait for fzf")?;

if !output.status.success() {
return Ok(None);
}

let selected_path = String::from_utf8(output.stdout)
.context("Invalid UTF-8 from fzf")?
.trim()
.to_string();

if selected_path.is_empty() {
return Ok(None);
}

// Find the matching repository
let repo = repos
.iter()
.find(|r| r.path.as_deref() == Some(selected_path.as_str()))
.cloned();

Ok(repo)
}

/// Review a repository by showing git status and git diff
fn review_repository(repo: &Repository) -> Result<()> {
let repo_path = repo
.path
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Repository has no path"))?;

// Clear screen
print!("\x1B[2J\x1B[1;1H");
io::stdout().flush()?;

let path_buf = PathBuf::from(repo_path);
let repo_name = path_buf.file_name().unwrap_or_default().to_string_lossy();

println!("Reviewing changes in {}...\n", repo_name);

// Show git status
let status = Command::new("git")
.arg("-C")
.arg(repo_path)
.arg("status")
.status()
.context("Failed to run git status")?;

if !status.success() {
eprintln!("Warning: git status failed");
}

println!();

// Show git diff
let diff = Command::new("git")
.arg("-C")
.arg(repo_path)
.arg("diff")
.status()
.context("Failed to run git diff")?;

if !diff.success() {
eprintln!("Warning: git diff failed");
}

// Prompt user
println!("\n\x1b[32mPress [Enter] to go back or [Escape/Q] to exit...\x1b[0m");

// Read single key
let mut buffer = [0u8; 1];
io::stdin()
.read_exact(&mut buffer)
.context("Failed to read input")?;

let key = buffer[0];

// Check for Escape (27) or Q/q (81/113)
if key == 27 || key == b'q' || key == b'Q' {
std::process::exit(0);
}

Ok(())
}