Skip to content
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ on:
push:
branches:
- release/**
# Make release builds so we can test the PoC
pull_request:

jobs:
linux:
Expand Down
92 changes: 91 additions & 1 deletion src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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;
Expand Down Expand Up @@ -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<S>(oid: &Oid, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&oid.to_string())
}

/// Serializes git2::Diff as a unified diff string, skipping binary files.
fn serialize_diff<S>(diff: &&Diff<'_>, serializer: S) -> Result<S::Ok, S::Error>
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.
Expand Down Expand Up @@ -966,6 +998,19 @@ impl AuthenticatedApi<'_> {
}
Ok(rv)
}

/// Sends code for AI-powered review and returns predictions.
pub fn review_code(&self, org: &str, request: &ReviewRequest<'_>) -> ApiResult<ReviewResponse> {
let path = format!(
"/api/0/organizations/{}/code-review/local-review/",
PathArg(org)
);
self.request(Method::Post, &path)?
.with_timeout(REVIEW_TIMEOUT)?
.with_json_body(request)?
.send()?
.convert()
}
}

/// Available datasets for fetching organization events
Expand Down Expand Up @@ -1267,6 +1312,13 @@ impl ApiRequest {
Ok(self)
}

/// Sets the timeout for the request.
pub fn with_timeout(mut self, timeout: std::time::Duration) -> ApiResult<Self> {
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;
Expand Down Expand Up @@ -1938,3 +1990,41 @@ pub struct LogEntry {
pub timestamp: String,
pub message: Option<String>,
}

/// Nested repository info for review request
#[derive(Serialize)]
pub struct ReviewRepository {
pub name: String,
#[serde(serialize_with = "serialize_oid")]
pub base_commit_sha: Oid,
}

/// Request for AI code review
#[derive(Serialize)]
pub struct ReviewRequest<'a> {
pub repository: ReviewRepository,
#[serde(serialize_with = "serialize_diff")]
pub diff: &'a Diff<'a>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub commit_message: Option<String>,
}

/// Response from the AI code review endpoint
#[derive(Deserialize, Debug, Serialize)]
pub struct ReviewResponse {
pub status: String,
pub predictions: Vec<ReviewPrediction>,
pub diagnostics: serde_json::Value,
pub seer_run_id: Option<u64>,
}

/// A single prediction from AI code review
#[derive(Deserialize, Debug, Serialize)]
pub struct ReviewPrediction {
pub path: String,
pub line: Option<u32>,
pub message: String,
pub level: String,
}
3 changes: 3 additions & 0 deletions src/commands/derive_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -35,4 +36,6 @@ pub(super) struct SentryCLI {
pub(super) enum SentryCLICommand {
Logs(LogsArgs),
DartSymbolMap(DartSymbolMapArgs),
#[command(hide = true)]
Review(ReviewArgs),
}
2 changes: 2 additions & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ mod projects;
mod react_native;
mod releases;
mod repos;
mod review;
mod send_envelope;
mod send_event;
mod sourcemaps;
Expand Down Expand Up @@ -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);
Expand Down
146 changes: 146 additions & 0 deletions src/commands/review/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
//! 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, ReviewRepository, ReviewRequest};
use crate::config::Config;
use crate::utils::vcs::{get_repo_from_remote, 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 {
#[arg(short = 'o', long = "org")]
#[arg(help = "The organization ID or slug.")]
org: Option<String>,
}

pub(super) fn make_command(command: Command) -> Command {
ReviewArgs::augment_args(command.about(ABOUT).long_about(LONG_ABOUT).hide(true))
}

pub(super) fn execute(_: &ArgMatches) -> Result<()> {
let SentryCLICommand::Review(args) = SentryCLI::parse().command else {
unreachable!("expected review command");
};

eprintln!(
"{}",
style("[EXPERIMENTAL] This feature is in development.").yellow()
);

run_review(args)
}

fn run_review(args: ReviewArgs) -> Result<()> {
// Resolve organization
let config = Config::current();
let (default_org, _) = config.get_org_and_project_defaults();
let org = args.org.as_ref().or(default_org.as_ref()).ok_or_else(|| {
anyhow::anyhow!(
"No organization specified. Please specify an organization using the --org argument."
)
})?;

// 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 reference for current branch name
let head_ref = repo.head().context("Failed to get HEAD reference")?;
let current_branch = head_ref.shorthand().map(String::from);

// Get HEAD commit
let head = head_ref
.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 commit message
let commit_message = head.message().map(ToOwned::to_owned);

// 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 and extract repo name
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'")?;
let repo_name = get_repo_from_remote(&remote_url);

eprintln!("Analyzing commit... (this may take up to 10 minutes)");

// Build request with borrowed diff - repo still alive
let request = ReviewRequest {
repository: ReviewRepository {
name: repo_name,
base_commit_sha: parent.id(),
},
diff: &diff,
current_branch,
commit_message,
};

// Send request and output raw JSON
let response = Api::current()
.authenticated()
.context("Authentication required for review")?
.review_code(org, &request)
.context("Failed to get review results")?;

// Output raw JSON for agentic workflow consumption
println!("{}", serde_json::to_string(&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(())
}
Loading