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: diff --git a/src/api/mod.rs b/src/api/mod.rs index 7dce776cae..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}; @@ -29,13 +30,15 @@ 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::ser::Error as SerError; +use serde::{Deserialize, Serialize, Serializer}; use sha1_smol::Digest; use symbolic::common::DebugId; use symbolic::debuginfo::ObjectKind; @@ -69,6 +72,35 @@ const RETRY_STATUS_CODES: &[u32] = &[ http::HTTP_STATUS_524_CLOUDFLARE_TIMEOUT, ]; +/// Timeout for the review API request (10 minutes) +const REVIEW_TIMEOUT: StdDuration = StdDuration::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(SerError::custom)?; + + let diff_str = String::from_utf8(output).map_err(SerError::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 +998,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 @@ -1267,6 +1308,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; @@ -1938,3 +1986,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/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/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..cd02145dcd --- /dev/null +++ b/src/commands/review/mod.rs @@ -0,0 +1,169 @@ +//! This module implements the `sentry-cli review` command for AI-powered code review. + +use anyhow::{bail, Context as _, Result}; +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; + +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."; + +/// 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 { + 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() + ); + + run_review() +} + +fn run_review() -> Result<()> { + // 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")?; + + // 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 parent commit + let parent = head + .parent(0) + .context("HEAD has no parent commit - cannot review initial commit")?; + + // 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 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")?; + + // Validate diff + validate_diff(&diff)?; + + // 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'")?; + + 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 = Api::current() + .authenticated() + .context("Authentication required for review")? + .review_code(&request) + .context("Failed to get review results")?; + display_results(response); + + Ok(()) +} + +/// Validates the diff meets requirements. +fn validate_diff(diff: &Diff<'_>) -> Result<()> { + let stats = diff.stats().context("Failed to get diff stats")?; + + if stats.files_changed() == 0 { + bail!("No changes found between HEAD and HEAD~1"); + } + + // 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)"); + } + + Ok(()) +} + +/// 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!(); + + 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: &ReviewPrediction) { + let severity_lower = prediction.severity.to_lowercase(); + + 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}. {styled} {}", prediction.file_path); + + if let Some(line) = prediction.line_number { + println!(" Line: {line}"); + } + + println!(" {}", prediction.description); + + if let Some(fix) = &prediction.suggested_fix { + println!(" {}: {fix}", style("Suggested fix").green()); + } + + println!(); +}