Skip to content

Commit 7056c84

Browse files
authored
feat: add repos review plugin (#138)
* feat: add repos review plugin * feat: make message red for repos with changes
1 parent a342191 commit 7056c84

File tree

5 files changed

+256
-1
lines changed

5 files changed

+256
-1
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ members = [
1212
".",
1313
"common/repos-github",
1414
"plugins/repos-health",
15+
"plugins/repos-review",
1516
"plugins/repos-validate",
1617
]
1718

justfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,13 @@ list-plugins:
3535
[group('devex')]
3636
link-plugins:
3737
sudo ln -sf $(pwd)/target/release/repos-health /usr/local/bin/repos-health
38-
sudo ln -sf $(pwd)/target/release/repos-health /usr/local/bin/repos-health
38+
sudo ln -sf $(pwd)/target/release/repos-validate /usr/local/bin/repos-validate
39+
sudo ln -sf $(pwd)/target/release/repos-review /usr/local/bin/repos-review
3940

4041
[group('devex')]
4142
unlink-plugins:
4243
sudo rm -f /usr/local/bin/repos-health
4344
sudo rm -f /usr/local/bin/repos-validate
45+
sudo rm -f /usr/local/bin/repos-review
4446

4547
# vim: set filetype=Makefile ts=4 sw=4 et:

plugins/repos-review/Cargo.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[package]
2+
name = "repos-review"
3+
version = "0.1.0"
4+
edition = "2024"
5+
6+
[[bin]]
7+
name = "repos-review"
8+
path = "src/main.rs"
9+
10+
[dependencies]
11+
anyhow = "1"
12+
serde = { version = "1", features = ["derive"] }
13+
serde_json = "1"
14+
15+
[dependencies.repos]
16+
path = "../.."

plugins/repos-review/README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# repos-review
2+
3+
Interactive repository review plugin for the `repos` CLI tool.
4+
5+
## Overview
6+
7+
`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.
8+
9+
## Requirements
10+
11+
- `fzf` - Fuzzy finder for interactive repository selection
12+
- Install on macOS: `brew install fzf`
13+
- Install on Linux: Use your package manager (e.g., `apt install fzf`, `yum install fzf`)
14+
15+
## Usage
16+
17+
```bash
18+
repos review
19+
```
20+
21+
The plugin will:
22+
23+
1. Display a list of all repositories with an `fzf` interface
24+
2. Show a preview of `git status` for each repository
25+
3. After selection, display full `git status` and `git diff`
26+
4. Wait for user input to either:
27+
- Press **Enter** to go back to the repository list
28+
- Press **Escape** or **Q** to exit
29+
30+
## Features
31+
32+
- **Interactive Selection**: Uses `fzf` with live preview of repository status
33+
- **Color Output**: Syntax highlighting for better readability
34+
- **Loop Mode**: Review multiple repositories in a single session
35+
- **Simple Navigation**: Easy keyboard controls for efficient workflow
36+
37+
## Example Workflow
38+
39+
```bash
40+
# Review changes across all repositories
41+
repos review
42+
43+
# Use with tag filters to review specific repositories
44+
repos review --tags backend
45+
46+
# Review repositories matching a pattern
47+
repos review --pattern "api-*"
48+
```
49+
50+
## Key Bindings
51+
52+
- **↑/↓** or **Ctrl-N/Ctrl-P**: Navigate repository list
53+
- **Enter**: Select repository for review
54+
- **Escape** or **Q**: Exit after reviewing a repository
55+
- **Enter** (in review): Return to repository list
56+
57+
## Notes
58+
59+
- The plugin respects the same filters as other `repos` commands (`--tags`, `--pattern`, etc.)
60+
- Only repositories with a configured path are shown
61+
- If `fzf` is not installed, the plugin will exit with an error message

plugins/repos-review/src/main.rs

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
use anyhow::{Context, Result};
2+
use repos::Repository;
3+
use std::env;
4+
use std::io::{self, Read, Write};
5+
use std::path::PathBuf;
6+
use std::process::{Command, Stdio};
7+
8+
fn main() -> Result<()> {
9+
let _args: Vec<String> = env::args().collect();
10+
11+
// Load context injected by core repos CLI
12+
let repos = repos::load_plugin_context()
13+
.context("Failed to load plugin context")?
14+
.ok_or_else(|| anyhow::anyhow!("Plugin must be invoked via repos CLI"))?;
15+
16+
// Check if fzf is available
17+
if !is_fzf_available() {
18+
eprintln!("Error: fzf must be installed.");
19+
eprintln!("Install it via: brew install fzf (macOS) or your package manager");
20+
std::process::exit(1);
21+
}
22+
23+
// Main loop: select and review repositories
24+
loop {
25+
match select_repository(&repos)? {
26+
Some(repo) => {
27+
review_repository(&repo)?;
28+
}
29+
None => {
30+
println!("No repo selected. Exiting.");
31+
break;
32+
}
33+
}
34+
}
35+
36+
Ok(())
37+
}
38+
39+
/// Check if fzf is installed and available in PATH
40+
fn is_fzf_available() -> bool {
41+
Command::new("which")
42+
.arg("fzf")
43+
.stdout(Stdio::null())
44+
.stderr(Stdio::null())
45+
.status()
46+
.map(|status| status.success())
47+
.unwrap_or(false)
48+
}
49+
50+
/// Use fzf to select a repository interactively
51+
fn select_repository(repos: &[Repository]) -> Result<Option<Repository>> {
52+
// Build list of repository paths for fzf
53+
let repo_list: Vec<String> = repos
54+
.iter()
55+
.filter_map(|r| r.path.as_ref())
56+
.map(|p| p.to_string())
57+
.collect();
58+
59+
if repo_list.is_empty() {
60+
return Ok(None);
61+
}
62+
63+
let input = repo_list.join("\n");
64+
65+
// Launch fzf with preview showing git status
66+
let mut fzf = Command::new("fzf")
67+
.args([
68+
"--color=fg:#4d4d4c,bg:#eeeeee,hl:#d7005f",
69+
"--color=fg+:#4d4d4c,bg+:#e8e8e8,hl+:#d7005f",
70+
"--color=info:#4271ae,prompt:#8959a8,pointer:#d7005f",
71+
"--color=marker:#4271ae,spinner:#4271ae,header:#4271ae",
72+
"--height=100%",
73+
"--ansi",
74+
"--preview",
75+
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"#,
76+
"--preview-window=right:50%",
77+
"--no-sort",
78+
])
79+
.stdin(Stdio::piped())
80+
.stdout(Stdio::piped())
81+
.spawn()
82+
.context("Failed to spawn fzf")?;
83+
84+
// Write repo list to fzf's stdin
85+
if let Some(mut stdin) = fzf.stdin.take() {
86+
stdin
87+
.write_all(input.as_bytes())
88+
.context("Failed to write to fzf stdin")?;
89+
}
90+
91+
// Get selected repository path from fzf
92+
let output = fzf.wait_with_output().context("Failed to wait for fzf")?;
93+
94+
if !output.status.success() {
95+
return Ok(None);
96+
}
97+
98+
let selected_path = String::from_utf8(output.stdout)
99+
.context("Invalid UTF-8 from fzf")?
100+
.trim()
101+
.to_string();
102+
103+
if selected_path.is_empty() {
104+
return Ok(None);
105+
}
106+
107+
// Find the matching repository
108+
let repo = repos
109+
.iter()
110+
.find(|r| r.path.as_deref() == Some(selected_path.as_str()))
111+
.cloned();
112+
113+
Ok(repo)
114+
}
115+
116+
/// Review a repository by showing git status and git diff
117+
fn review_repository(repo: &Repository) -> Result<()> {
118+
let repo_path = repo
119+
.path
120+
.as_ref()
121+
.ok_or_else(|| anyhow::anyhow!("Repository has no path"))?;
122+
123+
// Clear screen
124+
print!("\x1B[2J\x1B[1;1H");
125+
io::stdout().flush()?;
126+
127+
let path_buf = PathBuf::from(repo_path);
128+
let repo_name = path_buf.file_name().unwrap_or_default().to_string_lossy();
129+
130+
println!("Reviewing changes in {}...\n", repo_name);
131+
132+
// Show git status
133+
let status = Command::new("git")
134+
.arg("-C")
135+
.arg(repo_path)
136+
.arg("status")
137+
.status()
138+
.context("Failed to run git status")?;
139+
140+
if !status.success() {
141+
eprintln!("Warning: git status failed");
142+
}
143+
144+
println!();
145+
146+
// Show git diff
147+
let diff = Command::new("git")
148+
.arg("-C")
149+
.arg(repo_path)
150+
.arg("diff")
151+
.status()
152+
.context("Failed to run git diff")?;
153+
154+
if !diff.success() {
155+
eprintln!("Warning: git diff failed");
156+
}
157+
158+
// Prompt user
159+
println!("\n\x1b[32mPress [Enter] to go back or [Escape/Q] to exit...\x1b[0m");
160+
161+
// Read single key
162+
let mut buffer = [0u8; 1];
163+
io::stdin()
164+
.read_exact(&mut buffer)
165+
.context("Failed to read input")?;
166+
167+
let key = buffer[0];
168+
169+
// Check for Escape (27) or Q/q (81/113)
170+
if key == 27 || key == b'q' || key == b'Q' {
171+
std::process::exit(0);
172+
}
173+
174+
Ok(())
175+
}

0 commit comments

Comments
 (0)