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
22 changes: 21 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ ratatui = "0.30"
crossterm = "0.29"
confy = { version = "2.0.0", features = ["toml_conf"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

# The profile that 'dist' will build with
[profile.dist]
Expand Down
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ cargo run -- <arguments>
| `stash` | `st` | Stash changes |
| `log` | `l` | View commit history |
| `workspace`| `ws` | Manage workspaces (git worktrees) |
| `pr` | `prs`, `pullrequest`, `pullrequests` | Dashboard of your open pull requests|
| `onboarding`| `onboard` | Configure repo-specific setup |
| `setup` | - | Generate shell aliases from config |

Expand Down Expand Up @@ -209,6 +210,49 @@ gx workspace setup # Re-run setup: copy files, then run setup script

**Setup files:** files like `.env` are usually gitignored, so a fresh worktree doesn't have them. When creating a workspace, gx copies the files configured in `workspace.copy_files` and the current repo's onboarding config from the main worktree into the new one (missing files are skipped), then runs the repo-specific setup script when one is configured. The setup script runs from the workspace root. If it fails, gx warns and still switches into the workspace. See [Workspace Configuration](#workspace-configuration) and [Repo Onboarding](#repo-onboarding).

### Pull Requests

An interactive dashboard of your open pull requests, grouped by review **state**
and by **repository**, with inline quick actions. Requires the GitHub CLI (`gh`)
installed and authenticated (`gh auth login`).

```bash
gx pr # Interactive PR dashboard (TUI)
gx prs # Same (aliases: pullrequest, pullrequests)
gx pr list # Non-interactive grouped listing (for non-TTY / piping)
```

The dashboard shows both PRs **you authored** and PRs where **review is requested
of you** (a "Needs your review" section), categorized into: Needs your review,
Waiting for review, Ready to merge, Changes requested, Drafts. PR status (review
decision, merge blockers, check rollup, requested reviewers) streams in the
background so the list renders immediately and resolves as `gh pr view` lands.

**Scope** defaults to the current repository when you run it inside one,
otherwise to all repositories `gh` can see. Press `ctrl+s` to cycle scope between
the current repo, your configured orgs (see configuration), and global.

**Quick actions** on the highlighted PR:

- `enter` / `ctrl+o` — open in your browser
- `ctrl+y` — copy the PR URL
- `ctrl+g` — merge (with a confirmation showing the target and method)
- `ctrl+d` — mark a draft ready for review
- `ctrl+v` — suggest reviewers (deterministic from CODEOWNERS + commit history,
falling back to your configured AI agent when the signal is thin)
- `ctrl+w` — open the PR's branch in a workspace
- `ctrl+t` — troubleshoot: open the PR in a workspace and launch your AI agent to
investigate
- `ctrl+r` refresh, `ctrl+s` switch scope, `?` help, `esc` quit

`ctrl+w` and `ctrl+t` operate on local worktrees, so they are enabled only for
PRs in the repository you launched `gx` from; PRs in other repositories are
marked with `⧉` and those actions are disabled. Fork PRs cannot be opened in a
workspace. The troubleshoot action treats the PR's branch contents as untrusted
and asks for confirmation before launching the agent against a PR you did not
author. Like `gx workspace`, the workspace actions rely on the `gx setup` shell
wrapper to `cd` you into the workspace.

### Repo Onboarding

Configure setup that belongs to the current repository:
Expand Down Expand Up @@ -285,6 +329,30 @@ Example with more setup files:
copy_files = [".env*", "**/.env.local", "config/local.toml", ".vscode"]
```

### PR Dashboard Configuration

```toml
[pr]
# Orgs offered in the dashboard's "org" scope (ctrl+s cycles through scopes).
# Each entry becomes a `gh search --owner <org>` qualifier. Empty by default,
# which omits the org scope from the cycle.
orgs = []

# Default merge method used by the merge action: "squash", "merge", or "rebase".
merge_method = "squash"

# Whether reviewer suggestion falls back to the configured AI agent when the
# deterministic (CODEOWNERS + commit history) signal is thin.
reviewer_ai_fallback = true
```

Example scoping the org filter to your org:

```toml
[pr]
orgs = ["dash0hq"]
```

## License

MIT
183 changes: 183 additions & 0 deletions src/ai.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
//! Shared invocation of the configured AI agent (opencode / claude).
//!
//! Both `gx commit --ai` and the PR dashboard (reviewer suggestion and the
//! troubleshoot launch) drive the same agents, so the command-building and
//! process plumbing live here instead of being duplicated per command.

use crate::config::Agent;
use miette::Diagnostic;
use std::io::Write;
use std::path::Path;
use std::process::{Command, ExitStatus, Stdio};
use thiserror::Error;

#[derive(Error, Debug, Diagnostic)]
pub enum AiError {
#[error("{0}")]
#[diagnostic(
code(gx::ai::spawn_failed),
help("Ensure the configured AI agent is installed and available in your PATH")
)]
Spawn(String),

#[error("{0}")]
#[diagnostic(code(gx::ai::agent_failed))]
Failed(String),

#[error("{0} returned an empty response")]
#[diagnostic(code(gx::ai::empty_response))]
Empty(String),

#[error("{0}")]
#[diagnostic(code(gx::ai::io_error))]
Io(String),
}

/// Build the `(command, args)` needed to run `prompt` with the given agent and
/// model. The prompt is a parameter (unlike the old commit-only helper) so the
/// same builder serves commit messages, reviewer suggestions, and investigate
/// prompts.
pub fn agent_command(agent: &Agent, model: &str, prompt: &str) -> (String, Vec<String>) {
match agent {
Agent::OpenCode => (
"opencode".to_string(),
vec![
"run".to_string(),
prompt.to_string(),
"--model".to_string(),
model.to_string(),
],
),
Agent::Claude => (
"claude".to_string(),
vec![
"-p".to_string(),
prompt.to_string(),
"--model".to_string(),
model.to_string(),
],
),
}
}

/// Run the agent with `prompt`, optionally piping `stdin`, and capture stdout.
///
/// Both stdout and stderr are piped so the agent process can never write onto a
/// caller's alternate screen (the PR dashboard renders a TUI to stderr while
/// this runs on a background thread); stderr is surfaced only inside error
/// messages.
pub fn run_capturing(
agent: &Agent,
model: &str,
prompt: &str,
stdin: Option<&str>,
) -> Result<String, AiError> {
let (command, args) = agent_command(agent, model, prompt);

let mut child = Command::new(&command)
.args(&args)
.stdin(if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| AiError::Spawn(format!("Failed to spawn {command}: {e}")))?;

if let Some(input) = stdin {
// Take the handle and drop it after writing so the agent sees EOF.
let mut handle = child
.stdin
.take()
.ok_or_else(|| AiError::Failed(format!("{command}: failed to open stdin")))?;
handle
.write_all(input.as_bytes())
.map_err(|e| AiError::Io(format!("I/O error talking to {command}: {e}")))?;
}

let output = child
.wait_with_output()
.map_err(|e| AiError::Io(format!("I/O error talking to {command}: {e}")))?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let detail = if stderr.is_empty() {
"agent exited with an error".to_string()
} else {
stderr
};
return Err(AiError::Failed(format!("{command} failed: {detail}")));
}

let message = String::from_utf8_lossy(&output.stdout).trim().to_string();
if message.is_empty() {
return Err(AiError::Empty(command));
}

Ok(message)
}

/// Launch the agent interactively (inherited stdio) in `cwd`, for a hands-on
/// session such as the PR troubleshoot flow. The caller must have torn down any
/// TUI first, since the agent takes over the terminal.
pub fn launch_interactive(
agent: &Agent,
model: &str,
prompt: &str,
cwd: &Path,
) -> Result<ExitStatus, AiError> {
let (command, args) = agent_command(agent, model, prompt);

Command::new(&command)
.args(&args)
.current_dir(cwd)
.status()
.map_err(|e| AiError::Spawn(format!("Failed to spawn {command}: {e}")))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_agent_command_opencode_uses_prompt() {
let (command, args) = agent_command(&Agent::OpenCode, "opencode/big-pickle", "do the thing");
assert_eq!(command, "opencode");
assert_eq!(
args,
vec![
"run".to_string(),
"do the thing".to_string(),
"--model".to_string(),
"opencode/big-pickle".to_string(),
]
);
}

#[test]
fn test_agent_command_claude_uses_prompt() {
let (command, args) = agent_command(&Agent::Claude, "haiku", "review this PR");
assert_eq!(command, "claude");
assert_eq!(
args,
vec![
"-p".to_string(),
"review this PR".to_string(),
"--model".to_string(),
"haiku".to_string(),
]
);
}

#[test]
fn test_agent_command_substitutes_prompt_verbatim() {
// The prompt is a parameter, not a baked-in constant: whatever is passed
// appears verbatim as the run argument.
let (_, opencode_args) = agent_command(&Agent::OpenCode, "m", "PROMPT-A");
let (_, claude_args) = agent_command(&Agent::Claude, "m", "PROMPT-A");
assert!(opencode_args.contains(&"PROMPT-A".to_string()));
assert!(claude_args.contains(&"PROMPT-A".to_string()));
}
}
17 changes: 17 additions & 0 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@ pub enum Commands {
action: Option<WorkspaceCommands>,
},

/// Dashboard of your open pull requests
#[command(aliases = ["prs", "pullrequest", "pullrequests"])]
Pr {
#[command(subcommand)]
action: Option<PrCommands>,
},

/// Configure repo-specific workspace setup
#[command(alias = "onboard")]
Onboarding,
Expand Down Expand Up @@ -155,6 +162,12 @@ pub enum StashCommands {
},
}

#[derive(Subcommand)]
pub enum PrCommands {
/// Print open PRs grouped by state (non-interactive)
List,
}

#[derive(Subcommand)]
pub enum WorkspaceCommands {
/// Create a new workspace
Expand Down Expand Up @@ -275,6 +288,10 @@ impl Commands {
}) => commands::workspace::run_remove(query, force, delete_branch),
Some(WorkspaceCommands::Setup) => commands::workspace::run_setup(),
},
Commands::Pr { action } => match action {
None => commands::pr::run_interactive(),
Some(PrCommands::List) => commands::pr::run_list(),
},
Commands::Setup => commands::setup::run(),
}
}
Expand Down
Loading
Loading