use bcode_client::BcodeClient;
use bcode_code_review_models::{
CODE_REVIEW_SERVICE_INTERFACE_ID, ExternalPublishReviewRequest, OP_REVIEW_BUNDLE_GET,
OP_REVIEW_PUBLISHER_PREVIEW, OP_REVIEW_PUBLISHER_SUBMIT, REVIEW_PUBLISHER_INTERFACE_ID,
ReviewBundle, ReviewContextRequest, ReviewTarget,
};
use bcode_plugin_sdk::{
StaticCliFuture, StaticCliHostAction, StaticCliOutcome, StaticCliRegistration,
};
use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
const REVIEW_SURFACE_KIND: &str = "code-review";
#[derive(Debug, Parser)]
#[command(name = "review", about = "Review repository changes")]
struct ReviewCli {
#[command(subcommand)]
command: Option<ReviewCommand>,
}
#[derive(Debug, thiserror::Error)]
enum CliError {
#[error(transparent)]
Client(#[from] bcode_client::ClientError),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("plugin service error {code}: {message}")]
PluginService { code: String, message: String },
#[error("code review error: {0}")]
Review(String),
}
pub fn registration() -> StaticCliRegistration {
StaticCliRegistration {
requires_daemon,
command: ReviewCli::command,
invoke,
}
}
fn requires_daemon(matches: &clap::ArgMatches) -> bool {
matches.subcommand_name() == Some("publish-github")
}
fn invoke(matches: clap::ArgMatches) -> StaticCliFuture {
Box::pin(async move {
let cli = ReviewCli::from_arg_matches(&matches).map_err(|e| e.to_string())?;
run(cli.command).await.map_err(|e| e.to_string())
})
}
fn resolve_repo_argument(repo: &Path) -> Result<PathBuf, CliError> {
std::fs::canonicalize(repo).map_err(|error| {
CliError::Review(format!(
"repository path {} is unavailable: {error}",
repo.display()
))
})
}
fn surface_outcome(repo: PathBuf, target: Option<ReviewTarget>) -> StaticCliOutcome {
let mut options = BTreeMap::new();
if let Some(target) = target {
options.insert(
"target".into(),
serde_json::to_string(&target).expect("review target serializes"),
);
}
StaticCliOutcome {
host_action: Some(StaticCliHostAction::OpenTuiSurface {
surface_kind: REVIEW_SURFACE_KIND.into(),
repo_path: Some(repo),
options,
}),
}
}
#[derive(Debug, Subcommand)]
enum ReviewCommand {
Unstaged {
#[arg(long, default_value = ".")]
repo: PathBuf,
},
Staged {
#[arg(long, default_value = ".")]
repo: PathBuf,
},
All {
#[arg(long, default_value = ".")]
repo: PathBuf,
},
LastCommit {
#[arg(long, default_value = ".")]
repo: PathBuf,
},
Range {
base: String,
head: String,
#[arg(long, default_value = ".")]
repo: PathBuf,
#[arg(long)]
two_dot: bool,
},
Repo {
#[arg(long, default_value = ".")]
repo: PathBuf,
},
PublishGithub {
#[arg(long)]
github_repo: Option<String>,
#[arg(long)]
pr: Option<u64>,
#[arg(long, default_value = ".")]
repo: PathBuf,
#[arg(long, default_value = "GITHUB_TOKEN")]
token_env: String,
#[arg(long, value_enum, default_value_t = GithubSubmitEvent::Comment)]
submit_event: GithubSubmitEvent,
#[arg(long)]
summary: Option<String>,
#[arg(long)]
fallback_unmapped_to_summary: bool,
#[arg(long)]
submit: bool,
#[arg(long, value_enum, default_value_t = ReviewTargetArg::Unstaged)]
target: ReviewTargetArg,
},
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum GithubSubmitEvent {
Comment,
RequestChanges,
Approve,
}
impl GithubSubmitEvent {
const fn as_github_event(self) -> &'static str {
match self {
Self::Comment => "COMMENT",
Self::RequestChanges => "REQUEST_CHANGES",
Self::Approve => "APPROVE",
}
}
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum ReviewTargetArg {
Unstaged,
Staged,
All,
LastCommit,
}
impl From<ReviewTargetArg> for ReviewTarget {
fn from(value: ReviewTargetArg) -> Self {
match value {
ReviewTargetArg::Unstaged => Self::WorkingTreeUnstaged,
ReviewTargetArg::Staged => Self::IndexStaged,
ReviewTargetArg::All => Self::WorkingTreeAndIndex,
ReviewTargetArg::LastCommit => Self::LastCommit,
}
}
}
async fn run(command: Option<ReviewCommand>) -> Result<StaticCliOutcome, CliError> {
let Some(command) = command else {
return Ok(surface_outcome(
resolve_repo_argument(Path::new("."))?,
None,
));
};
let (repo, target) = match command {
ReviewCommand::Unstaged { repo } => (repo, ReviewTarget::WorkingTreeUnstaged),
ReviewCommand::Staged { repo } => (repo, ReviewTarget::IndexStaged),
ReviewCommand::All { repo } => (repo, ReviewTarget::WorkingTreeAndIndex),
ReviewCommand::LastCommit { repo } => (repo, ReviewTarget::LastCommit),
ReviewCommand::Range {
repo,
base,
head,
two_dot,
} => (
repo,
ReviewTarget::CommitRange {
base,
head,
merge_base: !two_dot,
},
),
ReviewCommand::Repo { repo } => (repo, ReviewTarget::Repository),
ReviewCommand::PublishGithub {
github_repo,
pr,
repo,
token_env,
submit_event,
summary,
fallback_unmapped_to_summary,
submit,
target,
} => {
publish_github_review(GithubPublishCliRequest {
github_repo,
pr,
repo,
token_env,
submit_event,
summary,
fallback_unmapped_to_summary,
submit,
target: target.into(),
})
.await?;
return Ok(StaticCliOutcome::default());
}
};
Ok(surface_outcome(resolve_repo_argument(&repo)?, Some(target)))
}
struct GithubPublishCliRequest {
github_repo: Option<String>,
pr: Option<u64>,
repo: PathBuf,
token_env: String,
submit_event: GithubSubmitEvent,
summary: Option<String>,
fallback_unmapped_to_summary: bool,
submit: bool,
target: ReviewTarget,
}
async fn publish_github_review(request: GithubPublishCliRequest) -> Result<(), CliError> {
let client = BcodeClient::default_endpoint();
let repo = resolve_repo_argument(&request.repo)?;
let bundle_payload = serde_json::to_vec(&ReviewContextRequest {
repo_path: repo.clone(),
target: request.target,
})?;
let bundle_response = client
.call_plugin_service(
CODE_REVIEW_SERVICE_INTERFACE_ID.to_string(),
OP_REVIEW_BUNDLE_GET.to_string(),
bundle_payload,
)
.await?;
let bundle = plugin_response_json::<ReviewBundle>(bundle_response)?;
let repository = match request.github_repo {
Some(repository) => repository,
None => detect_github_repository(&repo)?,
};
let pull_request = match request.pr {
Some(pull_request) => pull_request,
None => detect_pull_request_number(&repo)?,
};
let mut options = serde_json::json!({
"repository": repository,
"pull_request": pull_request.to_string(),
"token_env": request.token_env,
"submit_event": request.submit_event.as_github_event(),
});
if let Some(summary) = request.summary {
options["summary"] = serde_json::Value::String(summary);
}
if request.fallback_unmapped_to_summary {
options["fallback_unmapped_to_summary"] = serde_json::Value::Bool(true);
}
let publish_payload = serde_json::to_vec(&ExternalPublishReviewRequest { bundle, options })?;
let operation = if request.submit {
OP_REVIEW_PUBLISHER_SUBMIT
} else {
OP_REVIEW_PUBLISHER_PREVIEW
};
let response = client
.call_plugin_service(
REVIEW_PUBLISHER_INTERFACE_ID.to_string(),
operation.to_string(),
publish_payload,
)
.await?;
let value = plugin_response_json::<serde_json::Value>(response)?;
if request.submit {
println!("{}", value["message"].as_str().unwrap_or("submitted"));
if let Some(output) = value["output"].as_str() {
println!("{output}");
}
} else if let Some(preview) = value["preview"].as_str() {
println!("{preview}");
} else {
println!("{}", serde_json::to_string_pretty(&value)?);
}
Ok(())
}
fn plugin_response_json<T: for<'de> Deserialize<'de>>(
response: bcode_ipc::PluginServiceResponse,
) -> Result<T, CliError> {
if let Some(error) = response.error {
return Err(CliError::PluginService {
code: error.code,
message: error.message,
});
}
serde_json::from_slice(&response.payload).map_err(CliError::Json)
}
fn detect_github_repository(repo: &Path) -> Result<String, CliError> {
let output = Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(repo)
.output()?;
if !output.status.success() {
return Err(CliError::Review(
"failed to detect GitHub repository from origin remote; pass --github-repo".to_string(),
));
}
let remote = String::from_utf8_lossy(&output.stdout);
parse_github_remote_url(remote.trim()).ok_or_else(|| {
CliError::Review(
"origin remote is not a GitHub owner/repo URL; pass --github-repo".to_string(),
)
})
}
fn parse_github_remote_url(remote: &str) -> Option<String> {
let trimmed = remote.trim().trim_end_matches(".git");
if let Some(path) = trimmed.strip_prefix("git@github.com:") {
return owner_repo_from_path(path);
}
if let Some(path) = trimmed.strip_prefix("https://github.com/") {
return owner_repo_from_path(path);
}
if let Some(path) = trimmed.strip_prefix("ssh://git@github.com/") {
return owner_repo_from_path(path);
}
None
}
fn owner_repo_from_path(path: &str) -> Option<String> {
let mut parts = path.split('/');
let owner = parts.next()?.trim();
let repo = parts.next()?.trim();
(!owner.is_empty() && !repo.is_empty()).then(|| format!("{owner}/{repo}"))
}
fn detect_pull_request_number(repo: &Path) -> Result<u64, CliError> {
let output = Command::new("git")
.args(["branch", "--show-current"])
.current_dir(repo)
.output()?;
if !output.status.success() {
return Err(CliError::Review(
"failed to detect current branch; pass --pr".to_string(),
));
}
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
parse_pull_request_from_branch(&branch).ok_or_else(|| {
CliError::Review("failed to detect pull request number from branch; pass --pr".to_string())
})
}
fn parse_pull_request_from_branch(branch: &str) -> Option<u64> {
branch
.strip_prefix("pull/")
.and_then(|rest| rest.split('/').next())
.or_else(|| branch.strip_prefix("pr/"))
.and_then(|value| value.parse().ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn interactive_review_commands_defer_daemon_startup() {
let registration = registration();
for args in [
vec!["review"],
vec!["review", "unstaged"],
vec!["review", "staged", "--repo", "/tmp/review"],
vec!["review", "all"],
vec!["review", "last-commit"],
vec!["review", "range", "main", "HEAD"],
vec!["review", "range", "main", "HEAD", "--two-dot"],
vec!["review", "repo"],
] {
let matches = (registration.command)().try_get_matches_from(args).unwrap();
assert!(!(registration.requires_daemon)(&matches));
}
}
#[test]
fn github_preview_and_submit_require_daemon_before_invocation() {
let registration = registration();
for args in [
vec!["review", "publish-github"],
vec!["review", "publish-github", "--submit"],
vec!["review", "publish-github", "--target", "staged"],
] {
let matches = (registration.command)().try_get_matches_from(args).unwrap();
assert!((registration.requires_daemon)(&matches));
}
}
#[test]
fn repo_argument_resolves_relative_default_to_absolute_path() {
let resolved = resolve_repo_argument(Path::new(".")).expect("current directory resolves");
assert!(
resolved.is_absolute(),
"relative repo argument should resolve to an absolute path, got {}",
resolved.display()
);
}
#[test]
fn repo_argument_resolves_nested_relative_path_against_current_directory() {
let temp = tempfile::tempdir().expect("tempdir");
let nested = temp.path().join("nested");
std::fs::create_dir(&nested).expect("create nested directory");
let expected = std::fs::canonicalize(&nested).expect("canonical nested directory");
let resolved = resolve_repo_argument(&nested).expect("nested directory resolves");
assert_eq!(resolved, expected);
}
#[test]
fn repo_argument_rejects_missing_directory() {
let temp = tempfile::tempdir().expect("tempdir");
let missing = temp.path().join("does-not-exist");
let error = resolve_repo_argument(&missing).expect_err("missing directory should fail");
assert!(
matches!(error, CliError::Review(_)),
"expected a review error, got {error:?}"
);
}
#[test]
fn surface_outcome_forwards_resolved_repository_path() {
let temp = tempfile::tempdir().expect("tempdir");
let repo = std::fs::canonicalize(temp.path()).expect("canonical tempdir");
let outcome = surface_outcome(repo.clone(), Some(ReviewTarget::IndexStaged));
let Some(StaticCliHostAction::OpenTuiSurface { repo_path, .. }) = outcome.host_action
else {
panic!("expected an OpenTuiSurface host action");
};
assert_eq!(repo_path, Some(repo));
}
}