Skip to content
Draft
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
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
89 changes: 88 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,16 @@ 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/", 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 +1309,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 +1987,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)
}

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