From c04c6b15a6df7bc1270c56c29ed0fb1cf1a6f6a0 Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Wed, 14 Jan 2026 17:49:45 +0100 Subject: [PATCH 1/7] feat(review): Add experimental AI code review command Implements a new `sentry-cli review` command that analyzes the most recent commit (HEAD vs HEAD~1) using Sentry's Seer AI bug prediction service. Features: - Extracts git diff and sends to Sentry API - Displays predictions with severity coloring (HIGH/MEDIUM/LOW) - 10-minute timeout for long-running analysis - Handles edge cases: merge commits, binary files, large diffs Co-Authored-By: Claude Opus 4.5 --- src/api/mod.rs | 9 +- src/commands/derive_parser.rs | 2 + src/commands/mod.rs | 2 + src/commands/review/mod.rs | 241 ++++++++++++++++++++++++++++++++++ 4 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 src/commands/review/mod.rs diff --git a/src/api/mod.rs b/src/api/mod.rs index 7dce776cae..ee2858e7c8 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -173,7 +173,7 @@ impl Api { /// Create a new `ApiRequest` for the given HTTP method and URL. If the /// URL is just a path then it's relative to the configured API host /// and authentication is automatically enabled. - fn request( + pub fn request( &self, method: Method, url: &str, @@ -1267,6 +1267,13 @@ impl ApiRequest { Ok(self) } + /// Sets the timeout for the request. + pub fn with_timeout(mut self, timeout: std::time::Duration) -> ApiResult { + debug!("setting timeout: {timeout:?}"); + self.handle.timeout(timeout)?; + Ok(self) + } + /// enables a progress bar. pub fn progress_bar_mode(mut self, mode: ProgressBarMode) -> Self { self.progress_bar_mode = mode; diff --git a/src/commands/derive_parser.rs b/src/commands/derive_parser.rs index 2383260d6d..e51ee92330 100644 --- a/src/commands/derive_parser.rs +++ b/src/commands/derive_parser.rs @@ -4,6 +4,7 @@ use clap::{ArgAction::SetTrue, Parser, Subcommand}; use super::dart_symbol_map::DartSymbolMapArgs; use super::logs::LogsArgs; +use super::review::ReviewArgs; #[derive(Parser)] pub(super) struct SentryCLI { @@ -35,4 +36,5 @@ pub(super) struct SentryCLI { pub(super) enum SentryCLICommand { Logs(LogsArgs), DartSymbolMap(DartSymbolMapArgs), + Review(ReviewArgs), } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index dac94c33c7..e5a4209f94 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -36,6 +36,7 @@ mod projects; mod react_native; mod releases; mod repos; +mod review; mod send_envelope; mod send_event; mod sourcemaps; @@ -64,6 +65,7 @@ macro_rules! each_subcommand { $mac!(react_native); $mac!(releases); $mac!(repos); + $mac!(review); $mac!(send_event); $mac!(send_envelope); $mac!(sourcemaps); diff --git a/src/commands/review/mod.rs b/src/commands/review/mod.rs new file mode 100644 index 0000000000..08326e5a09 --- /dev/null +++ b/src/commands/review/mod.rs @@ -0,0 +1,241 @@ +//! This module implements the `sentry-cli review` command for AI-powered code review. + +use std::time::Duration; + +use anyhow::{bail, Context as _, Result}; +use clap::{ArgMatches, Args, Command, Parser as _}; +use console::style; +use git2::{DiffFormat, DiffOptions, Repository}; +use serde::{Deserialize, Serialize}; + +use crate::api::{Api, Method}; +use crate::commands::derive_parser::{SentryCLI, SentryCLICommand}; +use crate::utils::vcs::git_repo_remote_url; + +const ABOUT: &str = "[EXPERIMENTAL] Review local changes using Sentry AI"; +const LONG_ABOUT: &str = "\ +[EXPERIMENTAL] Review local changes using Sentry AI. + +This command analyzes the most recent commit (HEAD vs HEAD~1) and sends it to \ +Sentry's AI-powered code review service for bug prediction. + +The base commit must be pushed to the remote repository."; + +/// Timeout for the review API request (10 minutes) +const REVIEW_TIMEOUT: Duration = Duration::from_secs(600); + +/// Maximum diff size in bytes (500 KB) +const MAX_DIFF_SIZE: usize = 500 * 1024; + +#[derive(Args)] +pub(super) struct ReviewArgs { + // No additional args for PoC - reviews HEAD vs HEAD~1 +} + +#[derive(Serialize)] +struct ReviewRequest { + remote_url: String, + base_commit_sha: String, + diff: String, +} + +#[derive(Deserialize, Debug)] +struct ReviewResponse { + predictions: Vec, +} + +#[derive(Deserialize, Debug)] +struct Prediction { + file_path: String, + line_number: Option, + description: String, + severity: String, + suggested_fix: Option, +} + +pub(super) fn make_command(command: Command) -> Command { + command.about(ABOUT).long_about(LONG_ABOUT) +} + +pub(super) fn execute(_: &ArgMatches) -> Result<()> { + let SentryCLICommand::Review(_) = SentryCLI::parse().command else { + unreachable!("expected review command"); + }; + + eprintln!( + "{}", + style("[EXPERIMENTAL] This feature is in development.").yellow() + ); + + run_review() +} + +fn run_review() -> Result<()> { + let (remote_url, base_sha, diff) = get_review_data()?; + + if diff.trim().is_empty() { + bail!("No changes found between HEAD and HEAD~1"); + } + + if diff.len() > MAX_DIFF_SIZE { + bail!( + "Diff size ({} bytes) exceeds maximum allowed size ({MAX_DIFF_SIZE} bytes)", + diff.len() + ); + } + + eprintln!("Analyzing commit... (this may take up to 10 minutes)"); + + let response = send_review_request(remote_url, base_sha, diff)?; + display_results(response); + + Ok(()) +} + +/// Extracts git diff and metadata from the repository. +fn get_review_data() -> Result<(String, String, String)> { + let repo = Repository::open_from_env() + .context("Failed to open git repository from current directory")?; + + // Get HEAD commit + let head = repo + .head() + .context("Failed to get HEAD reference")? + .peel_to_commit() + .context("Failed to resolve HEAD to a commit")?; + + // Check for merge commit (multiple parents) + if head.parent_count() > 1 { + bail!("HEAD is a merge commit. Merge commits are not supported for review."); + } + + // Get HEAD~1 (parent) commit + let parent = head + .parent(0) + .context("HEAD has no parent commit - cannot review initial commit")?; + let base_sha = parent.id().to_string(); + + // Get trees for both commits + let head_tree = head.tree().context("Failed to get HEAD tree")?; + let parent_tree = parent.tree().context("Failed to get parent tree")?; + + // Generate unified diff, excluding binary files + let mut diff_opts = DiffOptions::new(); + let diff = repo + .diff_tree_to_tree(Some(&parent_tree), Some(&head_tree), Some(&mut diff_opts)) + .context("Failed to generate diff")?; + + let diff_string = generate_diff_string(&diff)?; + + // Get remote URL (prefer origin) + let remote_url = git_repo_remote_url(&repo, "origin") + .or_else(|_| git_repo_remote_url(&repo, "upstream")) + .context("No remote URL found for 'origin' or 'upstream'")?; + + Ok((remote_url, base_sha, diff_string)) +} + +/// Generates a diff string from a git2::Diff, skipping binary files. +fn generate_diff_string(diff: &git2::Diff) -> Result { + let mut diff_output = Vec::new(); + + diff.print(DiffFormat::Patch, |delta, _hunk, line| { + // Skip binary files + if delta.flags().is_binary() { + return true; + } + + diff_output.extend_from_slice(line.content()); + true + }) + .context("Failed to print diff")?; + + String::from_utf8(diff_output).context("Diff contains invalid UTF-8") +} + +/// Sends the review request to the Sentry API. +fn send_review_request( + remote_url: String, + base_sha: String, + diff: String, +) -> Result { + let api = Api::current(); + api.authenticated()?; + + let request_body = ReviewRequest { + remote_url, + base_commit_sha: base_sha, + diff, + }; + + let path = "/api/0/bug-prediction/cli/"; + + let response = api + .request(Method::Post, path, None)? + .with_json_body(&request_body)? + .with_timeout(REVIEW_TIMEOUT)? + .send() + .context("Failed to send review request")?; + + response + .convert::() + .context("Failed to parse review response") +} + +/// Displays the review results in a human-readable format. +fn display_results(response: ReviewResponse) { + if response.predictions.is_empty() { + println!("{}", style("No issues found in this commit.").green()); + return; + } + + println!( + "{}", + style(format!( + "Found {} potential issue(s):", + response.predictions.len() + )) + .yellow() + .bold() + ); + println!(); + + response + .predictions + .iter() + .enumerate() + .for_each(|(i, prediction)| { + display_prediction(i + 1, prediction); + }); +} + +/// Displays a single prediction in a formatted way. +fn display_prediction(index: usize, prediction: &Prediction) { + let severity_label = match prediction.severity.to_lowercase().as_str() { + "high" => "[HIGH]".to_owned(), + "medium" => "[MEDIUM]".to_owned(), + "low" => "[LOW]".to_owned(), + _ => format!("[{}]", prediction.severity.to_uppercase()), + }; + + let severity_styled = match prediction.severity.to_lowercase().as_str() { + "high" => style(severity_label).red().bold(), + "medium" => style(severity_label).yellow().bold(), + "low" => style(severity_label).cyan(), + _ => style(severity_label).dim(), + }; + + println!("{index}. {severity_styled} {}", prediction.file_path); + + if let Some(line) = prediction.line_number { + println!(" Line: {line}"); + } + + println!(" {}", prediction.description); + + if let Some(ref fix) = prediction.suggested_fix { + println!(" {}: {fix}", style("Suggested fix").green()); + } + + println!(); +} From 45bb7ec67d9ff6f3562e58c8c3792b2767088dbd Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Wed, 14 Jan 2026 17:55:32 +0100 Subject: [PATCH 2/7] ref(review): Use git2 types directly with custom serialization Use git2::Oid and git2::Diff<'a> instead of converting to strings early. Add serialize_oid and serialize_diff custom serde serializers. Restructure run_review() to keep Repository alive for proper lifetimes. Remove ReviewArgs from derive_parser since command has no arguments. Simplify display code with for loop, cached to_lowercase, and cleaner severity styling. Co-Authored-By: Claude --- src/commands/derive_parser.rs | 2 - src/commands/review/mod.rs | 167 ++++++++++++++++------------------ 2 files changed, 78 insertions(+), 91 deletions(-) diff --git a/src/commands/derive_parser.rs b/src/commands/derive_parser.rs index e51ee92330..2383260d6d 100644 --- a/src/commands/derive_parser.rs +++ b/src/commands/derive_parser.rs @@ -4,7 +4,6 @@ use clap::{ArgAction::SetTrue, Parser, Subcommand}; use super::dart_symbol_map::DartSymbolMapArgs; use super::logs::LogsArgs; -use super::review::ReviewArgs; #[derive(Parser)] pub(super) struct SentryCLI { @@ -36,5 +35,4 @@ pub(super) struct SentryCLI { pub(super) enum SentryCLICommand { Logs(LogsArgs), DartSymbolMap(DartSymbolMapArgs), - Review(ReviewArgs), } diff --git a/src/commands/review/mod.rs b/src/commands/review/mod.rs index 08326e5a09..17787b9050 100644 --- a/src/commands/review/mod.rs +++ b/src/commands/review/mod.rs @@ -3,13 +3,12 @@ use std::time::Duration; use anyhow::{bail, Context as _, Result}; -use clap::{ArgMatches, Args, Command, Parser as _}; +use clap::{ArgMatches, Command}; use console::style; -use git2::{DiffFormat, DiffOptions, Repository}; -use serde::{Deserialize, Serialize}; +use git2::{Diff, DiffFormat, DiffOptions, Oid, Repository}; +use serde::{Deserialize, Serialize, Serializer}; use crate::api::{Api, Method}; -use crate::commands::derive_parser::{SentryCLI, SentryCLICommand}; use crate::utils::vcs::git_repo_remote_url; const ABOUT: &str = "[EXPERIMENTAL] Review local changes using Sentry AI"; @@ -27,16 +26,39 @@ const REVIEW_TIMEOUT: Duration = Duration::from_secs(600); /// Maximum diff size in bytes (500 KB) const MAX_DIFF_SIZE: usize = 500 * 1024; -#[derive(Args)] -pub(super) struct ReviewArgs { - // No additional args for PoC - reviews HEAD vs HEAD~1 +/// Serializes git2::Oid as a hex string. +fn serialize_oid(oid: &Oid, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(&oid.to_string()) +} + +/// Serializes git2::Diff as a unified diff string, skipping binary files. +fn serialize_diff(diff: &&Diff<'_>, serializer: S) -> Result +where + S: Serializer, +{ + let mut output = Vec::new(); + diff.print(DiffFormat::Patch, |delta, _hunk, line| { + if !delta.flags().is_binary() { + output.extend_from_slice(line.content()); + } + true + }) + .map_err(serde::ser::Error::custom)?; + + let diff_str = String::from_utf8(output).map_err(serde::ser::Error::custom)?; + serializer.serialize_str(&diff_str) } #[derive(Serialize)] -struct ReviewRequest { +struct ReviewRequest<'a> { remote_url: String, - base_commit_sha: String, - diff: String, + #[serde(serialize_with = "serialize_oid")] + base_commit_sha: Oid, + #[serde(serialize_with = "serialize_diff")] + diff: &'a Diff<'a>, } #[derive(Deserialize, Debug)] @@ -58,10 +80,6 @@ pub(super) fn make_command(command: Command) -> Command { } pub(super) fn execute(_: &ArgMatches) -> Result<()> { - let SentryCLICommand::Review(_) = SentryCLI::parse().command else { - unreachable!("expected review command"); - }; - eprintln!( "{}", style("[EXPERIMENTAL] This feature is in development.").yellow() @@ -71,29 +89,7 @@ pub(super) fn execute(_: &ArgMatches) -> Result<()> { } fn run_review() -> Result<()> { - let (remote_url, base_sha, diff) = get_review_data()?; - - if diff.trim().is_empty() { - bail!("No changes found between HEAD and HEAD~1"); - } - - if diff.len() > MAX_DIFF_SIZE { - bail!( - "Diff size ({} bytes) exceeds maximum allowed size ({MAX_DIFF_SIZE} bytes)", - diff.len() - ); - } - - eprintln!("Analyzing commit... (this may take up to 10 minutes)"); - - let response = send_review_request(remote_url, base_sha, diff)?; - display_results(response); - - Ok(()) -} - -/// Extracts git diff and metadata from the repository. -fn get_review_data() -> Result<(String, String, String)> { + // Open repo at top level - keeps it alive for the entire function let repo = Repository::open_from_env() .context("Failed to open git repository from current directory")?; @@ -109,70 +105,72 @@ fn get_review_data() -> Result<(String, String, String)> { bail!("HEAD is a merge commit. Merge commits are not supported for review."); } - // Get HEAD~1 (parent) commit + // Get parent commit let parent = head .parent(0) .context("HEAD has no parent commit - cannot review initial commit")?; - let base_sha = parent.id().to_string(); - // Get trees for both commits + // Get trees for diff let head_tree = head.tree().context("Failed to get HEAD tree")?; let parent_tree = parent.tree().context("Failed to get parent tree")?; - // Generate unified diff, excluding binary files + // Generate diff (borrows from repo) let mut diff_opts = DiffOptions::new(); let diff = repo .diff_tree_to_tree(Some(&parent_tree), Some(&head_tree), Some(&mut diff_opts)) .context("Failed to generate diff")?; - let diff_string = generate_diff_string(&diff)?; + // Validate diff + validate_diff(&diff)?; - // Get remote URL (prefer origin) + // Get remote URL let remote_url = git_repo_remote_url(&repo, "origin") .or_else(|_| git_repo_remote_url(&repo, "upstream")) .context("No remote URL found for 'origin' or 'upstream'")?; - Ok((remote_url, base_sha, diff_string)) + eprintln!("Analyzing commit... (this may take up to 10 minutes)"); + + // Build request with borrowed diff - repo still alive + let request = ReviewRequest { + remote_url, + base_commit_sha: parent.id(), + diff: &diff, + }; + + // Send request and display results + let response = send_review_request(&request)?; + display_results(response); + + Ok(()) } -/// Generates a diff string from a git2::Diff, skipping binary files. -fn generate_diff_string(diff: &git2::Diff) -> Result { - let mut diff_output = Vec::new(); +/// Validates the diff meets requirements. +fn validate_diff(diff: &Diff<'_>) -> Result<()> { + let stats = diff.stats().context("Failed to get diff stats")?; - diff.print(DiffFormat::Patch, |delta, _hunk, line| { - // Skip binary files - if delta.flags().is_binary() { - return true; - } + if stats.files_changed() == 0 { + bail!("No changes found between HEAD and HEAD~1"); + } - diff_output.extend_from_slice(line.content()); - true - }) - .context("Failed to print diff")?; + // Estimate size by summing insertions and deletions (rough approximation) + let estimated_size = (stats.insertions() + stats.deletions()) * 80; // ~80 chars per line + if estimated_size > MAX_DIFF_SIZE { + bail!("Diff is too large (estimated {estimated_size} bytes, max {MAX_DIFF_SIZE} bytes)"); + } - String::from_utf8(diff_output).context("Diff contains invalid UTF-8") + Ok(()) } /// Sends the review request to the Sentry API. -fn send_review_request( - remote_url: String, - base_sha: String, - diff: String, -) -> Result { +fn send_review_request(request: &ReviewRequest<'_>) -> Result { let api = Api::current(); api.authenticated()?; - let request_body = ReviewRequest { - remote_url, - base_commit_sha: base_sha, - diff, - }; - let path = "/api/0/bug-prediction/cli/"; let response = api .request(Method::Post, path, None)? - .with_json_body(&request_body)? + .with_json_body(request)? .with_timeout(REVIEW_TIMEOUT)? .send() .context("Failed to send review request")?; @@ -200,32 +198,23 @@ fn display_results(response: ReviewResponse) { ); println!(); - response - .predictions - .iter() - .enumerate() - .for_each(|(i, prediction)| { - display_prediction(i + 1, prediction); - }); + for (i, prediction) in response.predictions.iter().enumerate() { + display_prediction(i + 1, prediction); + } } /// Displays a single prediction in a formatted way. fn display_prediction(index: usize, prediction: &Prediction) { - let severity_label = match prediction.severity.to_lowercase().as_str() { - "high" => "[HIGH]".to_owned(), - "medium" => "[MEDIUM]".to_owned(), - "low" => "[LOW]".to_owned(), - _ => format!("[{}]", prediction.severity.to_uppercase()), - }; + let severity_lower = prediction.severity.to_lowercase(); - let severity_styled = match prediction.severity.to_lowercase().as_str() { - "high" => style(severity_label).red().bold(), - "medium" => style(severity_label).yellow().bold(), - "low" => style(severity_label).cyan(), - _ => style(severity_label).dim(), + let styled = match severity_lower.as_str() { + "high" => style("[HIGH]".to_owned()).red().bold(), + "medium" => style("[MEDIUM]".to_owned()).yellow().bold(), + "low" => style("[LOW]".to_owned()).cyan(), + _ => style(format!("[{}]", prediction.severity.to_uppercase())).dim(), }; - println!("{index}. {severity_styled} {}", prediction.file_path); + println!("{index}. {styled} {}", prediction.file_path); if let Some(line) = prediction.line_number { println!(" Line: {line}"); @@ -233,7 +222,7 @@ fn display_prediction(index: usize, prediction: &Prediction) { println!(" {}", prediction.description); - if let Some(ref fix) = prediction.suggested_fix { + if let Some(fix) = &prediction.suggested_fix { println!(" {}: {fix}", style("Suggested fix").green()); } From 6c442e97a71718f55cd6776e0cce80d73a45c952 Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Wed, 14 Jan 2026 18:09:55 +0100 Subject: [PATCH 3/7] ref(review): Move review API types and method to api module Move ReviewRequest, ReviewResponse, ReviewPrediction types and the review_code() method from the command module to src/api/mod.rs to follow the established pattern where API types and methods live in the api module. Co-Authored-By: Claude --- src/api/mod.rs | 67 ++++++++++++++++++++++++++++- src/commands/review/mod.rs | 86 ++++---------------------------------- 2 files changed, 74 insertions(+), 79 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index ee2858e7c8..21580747b3 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -29,13 +29,14 @@ use chrono::Duration; use chrono::{DateTime, FixedOffset, Utc}; use clap::ArgMatches; use flate2::write::GzEncoder; +use git2::{Diff, DiffFormat, Oid}; use lazy_static::lazy_static; use log::{debug, info, warn}; use parking_lot::Mutex; use regex::{Captures, Regex}; use secrecy::ExposeSecret as _; use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, Serializer}; use sha1_smol::Digest; use symbolic::common::DebugId; use symbolic::debuginfo::ObjectKind; @@ -69,6 +70,35 @@ const RETRY_STATUS_CODES: &[u32] = &[ http::HTTP_STATUS_524_CLOUDFLARE_TIMEOUT, ]; +/// Timeout for the review API request (10 minutes) +const REVIEW_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600); + +/// Serializes git2::Oid as a hex string. +fn serialize_oid(oid: &Oid, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(&oid.to_string()) +} + +/// Serializes git2::Diff as a unified diff string, skipping binary files. +fn serialize_diff(diff: &&Diff<'_>, serializer: S) -> Result +where + S: Serializer, +{ + let mut output = Vec::new(); + diff.print(DiffFormat::Patch, |delta, _hunk, line| { + if !delta.flags().is_binary() { + output.extend_from_slice(line.content()); + } + true + }) + .map_err(serde::ser::Error::custom)?; + + let diff_str = String::from_utf8(output).map_err(serde::ser::Error::custom)?; + serializer.serialize_str(&diff_str) +} + /// Helper for the API access. /// Implements the low-level API access methods, and provides high-level implementations for interacting /// with portions of the API that do not require authentication via an auth token. @@ -966,6 +996,15 @@ impl AuthenticatedApi<'_> { } Ok(rv) } + + /// Sends code for AI-powered review and returns predictions. + pub fn review_code(&self, request: &ReviewRequest<'_>) -> ApiResult { + self.request(Method::Post, "/api/0/bug-prediction/cli/")? + .with_timeout(REVIEW_TIMEOUT)? + .with_json_body(request)? + .send()? + .convert() + } } /// Available datasets for fetching organization events @@ -1945,3 +1984,29 @@ pub struct LogEntry { pub timestamp: String, pub message: Option, } + +/// Request for AI code review +#[derive(Serialize)] +pub struct ReviewRequest<'a> { + pub remote_url: String, + #[serde(serialize_with = "serialize_oid")] + pub base_commit_sha: Oid, + #[serde(serialize_with = "serialize_diff")] + pub diff: &'a Diff<'a>, +} + +/// Response from the AI code review endpoint +#[derive(Deserialize, Debug)] +pub struct ReviewResponse { + pub predictions: Vec, +} + +/// A single prediction from AI code review +#[derive(Deserialize, Debug)] +pub struct ReviewPrediction { + pub file_path: String, + pub line_number: Option, + pub description: String, + pub severity: String, + pub suggested_fix: Option, +} diff --git a/src/commands/review/mod.rs b/src/commands/review/mod.rs index 17787b9050..a7315a8081 100644 --- a/src/commands/review/mod.rs +++ b/src/commands/review/mod.rs @@ -1,14 +1,11 @@ //! This module implements the `sentry-cli review` command for AI-powered code review. -use std::time::Duration; - use anyhow::{bail, Context as _, Result}; use clap::{ArgMatches, Command}; use console::style; -use git2::{Diff, DiffFormat, DiffOptions, Oid, Repository}; -use serde::{Deserialize, Serialize, Serializer}; +use git2::{Diff, DiffOptions, Repository}; -use crate::api::{Api, Method}; +use crate::api::{Api, ReviewPrediction, ReviewRequest, ReviewResponse}; use crate::utils::vcs::git_repo_remote_url; const ABOUT: &str = "[EXPERIMENTAL] Review local changes using Sentry AI"; @@ -20,61 +17,9 @@ Sentry's AI-powered code review service for bug prediction. The base commit must be pushed to the remote repository."; -/// Timeout for the review API request (10 minutes) -const REVIEW_TIMEOUT: Duration = Duration::from_secs(600); - /// Maximum diff size in bytes (500 KB) const MAX_DIFF_SIZE: usize = 500 * 1024; -/// Serializes git2::Oid as a hex string. -fn serialize_oid(oid: &Oid, serializer: S) -> Result -where - S: Serializer, -{ - serializer.serialize_str(&oid.to_string()) -} - -/// Serializes git2::Diff as a unified diff string, skipping binary files. -fn serialize_diff(diff: &&Diff<'_>, serializer: S) -> Result -where - S: Serializer, -{ - let mut output = Vec::new(); - diff.print(DiffFormat::Patch, |delta, _hunk, line| { - if !delta.flags().is_binary() { - output.extend_from_slice(line.content()); - } - true - }) - .map_err(serde::ser::Error::custom)?; - - let diff_str = String::from_utf8(output).map_err(serde::ser::Error::custom)?; - serializer.serialize_str(&diff_str) -} - -#[derive(Serialize)] -struct ReviewRequest<'a> { - remote_url: String, - #[serde(serialize_with = "serialize_oid")] - base_commit_sha: Oid, - #[serde(serialize_with = "serialize_diff")] - diff: &'a Diff<'a>, -} - -#[derive(Deserialize, Debug)] -struct ReviewResponse { - predictions: Vec, -} - -#[derive(Deserialize, Debug)] -struct Prediction { - file_path: String, - line_number: Option, - description: String, - severity: String, - suggested_fix: Option, -} - pub(super) fn make_command(command: Command) -> Command { command.about(ABOUT).long_about(LONG_ABOUT) } @@ -138,7 +83,11 @@ fn run_review() -> Result<()> { }; // Send request and display results - let response = send_review_request(&request)?; + let response = Api::current() + .authenticated() + .context("Authentication required for review")? + .review_code(&request) + .context("Failed to get review results")?; display_results(response); Ok(()) @@ -161,25 +110,6 @@ fn validate_diff(diff: &Diff<'_>) -> Result<()> { Ok(()) } -/// Sends the review request to the Sentry API. -fn send_review_request(request: &ReviewRequest<'_>) -> Result { - let api = Api::current(); - api.authenticated()?; - - let path = "/api/0/bug-prediction/cli/"; - - let response = api - .request(Method::Post, path, None)? - .with_json_body(request)? - .with_timeout(REVIEW_TIMEOUT)? - .send() - .context("Failed to send review request")?; - - response - .convert::() - .context("Failed to parse review response") -} - /// Displays the review results in a human-readable format. fn display_results(response: ReviewResponse) { if response.predictions.is_empty() { @@ -204,7 +134,7 @@ fn display_results(response: ReviewResponse) { } /// Displays a single prediction in a formatted way. -fn display_prediction(index: usize, prediction: &Prediction) { +fn display_prediction(index: usize, prediction: &ReviewPrediction) { let severity_lower = prediction.severity.to_lowercase(); let styled = match severity_lower.as_str() { From 2ce704e8ae48e7291626706cc95a9b07c5a9d8bd Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Wed, 14 Jan 2026 18:11:53 +0100 Subject: [PATCH 4/7] make not public --- src/api/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 21580747b3..75f5692c9c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -203,7 +203,7 @@ impl Api { /// Create a new `ApiRequest` for the given HTTP method and URL. If the /// URL is just a path then it's relative to the configured API host /// and authentication is automatically enabled. - pub fn request( + fn request( &self, method: Method, url: &str, From a10234799ae209fb560a8f75693de4582d87e700 Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Wed, 14 Jan 2026 18:25:14 +0100 Subject: [PATCH 5/7] style(api): Import types instead of using inline paths Add proper imports for StdDuration and SerError to improve readability instead of using std::time::Duration and serde::ser::Error inline. Co-Authored-By: Claude --- src/api/mod.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 75f5692c9c..ecf1babf58 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -20,6 +20,7 @@ use std::fs::File; use std::io::{self, Read as _, Write}; use std::rc::Rc; use std::sync::Arc; +use std::time::Duration as StdDuration; use std::{fmt, thread}; use anyhow::{Context as _, Result}; @@ -36,6 +37,7 @@ use parking_lot::Mutex; use regex::{Captures, Regex}; use secrecy::ExposeSecret as _; use serde::de::DeserializeOwned; +use serde::ser::Error as SerError; use serde::{Deserialize, Serialize, Serializer}; use sha1_smol::Digest; use symbolic::common::DebugId; @@ -71,7 +73,7 @@ const RETRY_STATUS_CODES: &[u32] = &[ ]; /// Timeout for the review API request (10 minutes) -const REVIEW_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600); +const REVIEW_TIMEOUT: StdDuration = StdDuration::from_secs(600); /// Serializes git2::Oid as a hex string. fn serialize_oid(oid: &Oid, serializer: S) -> Result @@ -93,9 +95,9 @@ where } true }) - .map_err(serde::ser::Error::custom)?; + .map_err(SerError::custom)?; - let diff_str = String::from_utf8(output).map_err(serde::ser::Error::custom)?; + let diff_str = String::from_utf8(output).map_err(SerError::custom)?; serializer.serialize_str(&diff_str) } From 3272e7b34f845f60abde9b2ed808ee6dd32607c5 Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Wed, 14 Jan 2026 18:40:44 +0100 Subject: [PATCH 6/7] ref(review): Use derive API and hide command from help Convert review command to use clap's derive API pattern with ReviewArgs struct and add to SentryCLICommand enum. Hide the command from help text since it's experimental. Co-Authored-By: Claude --- src/commands/derive_parser.rs | 3 +++ src/commands/review/mod.rs | 13 +++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/commands/derive_parser.rs b/src/commands/derive_parser.rs index 2383260d6d..a969dd56fa 100644 --- a/src/commands/derive_parser.rs +++ b/src/commands/derive_parser.rs @@ -4,6 +4,7 @@ use clap::{ArgAction::SetTrue, Parser, Subcommand}; use super::dart_symbol_map::DartSymbolMapArgs; use super::logs::LogsArgs; +use super::review::ReviewArgs; #[derive(Parser)] pub(super) struct SentryCLI { @@ -35,4 +36,6 @@ pub(super) struct SentryCLI { pub(super) enum SentryCLICommand { Logs(LogsArgs), DartSymbolMap(DartSymbolMapArgs), + #[command(hide = true)] + Review(ReviewArgs), } diff --git a/src/commands/review/mod.rs b/src/commands/review/mod.rs index a7315a8081..cd02145dcd 100644 --- a/src/commands/review/mod.rs +++ b/src/commands/review/mod.rs @@ -1,10 +1,11 @@ //! This module implements the `sentry-cli review` command for AI-powered code review. use anyhow::{bail, Context as _, Result}; -use clap::{ArgMatches, Command}; +use clap::{ArgMatches, Args, Command, Parser as _}; use console::style; use git2::{Diff, DiffOptions, Repository}; +use super::derive_parser::{SentryCLI, SentryCLICommand}; use crate::api::{Api, ReviewPrediction, ReviewRequest, ReviewResponse}; use crate::utils::vcs::git_repo_remote_url; @@ -20,11 +21,19 @@ The base commit must be pushed to the remote repository."; /// Maximum diff size in bytes (500 KB) const MAX_DIFF_SIZE: usize = 500 * 1024; +#[derive(Args)] +#[command(about = ABOUT, long_about = LONG_ABOUT, hide = true)] +pub(super) struct ReviewArgs; + pub(super) fn make_command(command: Command) -> Command { - command.about(ABOUT).long_about(LONG_ABOUT) + ReviewArgs::augment_args(command) } pub(super) fn execute(_: &ArgMatches) -> Result<()> { + let SentryCLICommand::Review(ReviewArgs) = SentryCLI::parse().command else { + unreachable!("expected review command"); + }; + eprintln!( "{}", style("[EXPERIMENTAL] This feature is in development.").yellow() From af075bcc9c8b7ea5422c854c0d74404ae9028eee Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Thu, 15 Jan 2026 16:22:03 +0100 Subject: [PATCH 7/7] run release build on this branch --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f61325bf2e..ec671704e2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,8 @@ on: push: branches: - release/** + # Make release builds so we can test the PoC + pull_request: jobs: linux: