#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::multiple_crate_versions)]
#[cfg(feature = "static-bundled")]
mod cli;
pub mod async_values;
pub mod code_review_home;
pub mod code_review_tui;
pub mod code_review_tui_display;
pub mod code_review_tui_render;
pub mod code_review_tui_view;
mod keybindings;
pub mod terminal_events;
pub mod tui;
pub mod tui_host_types;
use bcode_code_review_models::{
ArchiveReviewWorkspaceRequest, ArchiveReviewWorkspaceResponse, CreateReviewWorkspaceRequest,
CreateReviewWorkspaceResponse, DeleteDraftRequest, DeleteDraftResponse,
DeleteReviewAiExchangeRequest, DeleteReviewAiExchangeResponse, DraftAnchor, DraftComment,
ExternalReviewImportRecord, GetReviewDiffRequest, GetReviewThreadRequest,
GetReviewWorkspaceRequest, GetReviewWorkspaceResponse, LinkThreadSessionRequest,
LinkThreadSessionResponse, ListDraftsRequest, ListDraftsResponse,
ListExternalReviewImportsRequest, ListExternalReviewImportsResponse, ListPublishRecordsRequest,
ListPublishRecordsResponse, ListReviewAiExchangesRequest, ListReviewAiExchangesResponse,
ListReviewPublishersResponse, ListReviewSuggestionsRequest, ListReviewSuggestionsResponse,
ListReviewWorkspacesRequest, ListReviewWorkspacesResponse, MaterializeReviewWorkspaceRequest,
MaterializeReviewWorkspaceResponse, OP_REVIEW_EXTERNAL_IMPORT_SAVE,
OP_REVIEW_EXTERNAL_IMPORTS_LIST, OP_REVIEW_PUBLISH_RECORD_SAVE, OP_REVIEW_PUBLISH_RECORDS_LIST,
OP_REVIEW_REPO_FILE_GET, OP_REVIEW_WORKSPACE_ARCHIVE, OP_REVIEW_WORKSPACE_CREATE,
OP_REVIEW_WORKSPACE_GET, OP_REVIEW_WORKSPACE_LIST, OP_REVIEW_WORKSPACE_MATERIALIZE,
OP_REVIEW_WORKSPACE_UPDATE, PublishReviewPreviewResponse, PublishReviewRequest,
PublishReviewResponse, RepositoryFileRequest, RepositoryFileResponse, ResolveThreadRequest,
ResolveThreadResponse, ReviewAiExchange, ReviewAiExchangeStatus, ReviewAnchorKind,
ReviewBundle, ReviewBundleLine, ReviewBundleThread, ReviewContextRequest, ReviewFile,
ReviewFileStatus, ReviewFileSummary, ReviewHunk, ReviewLine, ReviewLineKind,
ReviewPublishRecord, ReviewPublisherCapabilities, ReviewPublisherManifest,
ReviewRepositoryCommit, ReviewScope, ReviewSource, ReviewSourceDiagnostic,
ReviewSourceDiagnosticSeverity, ReviewSourceKind, ReviewSuggestion, ReviewSuggestionStatus,
ReviewSurface, ReviewSurfaceKind, ReviewTarget, ReviewThreadKind, ReviewThreadSeverity,
ReviewWorkspace, ReviewWorkspaceListItem, ReviewWorkspaceMaterialization, SaveDraftRequest,
SaveDraftResponse, SaveExternalReviewImportRequest, SaveExternalReviewImportResponse,
SavePublishRecordRequest, SavePublishRecordResponse, SaveReviewAiExchangeRequest,
SaveReviewAiExchangeResponse, SaveReviewSuggestionRequest, SaveReviewSuggestionResponse,
UpdateDraftRequest, UpdateDraftResponse, UpdateReviewWorkspaceRequest,
UpdateReviewWorkspaceResponse,
};
use bcode_command::{
COMMAND_INTERFACE_ID, CommandAction, CommandContribution, CommandEffect, CommandOwner,
CommandSurface, InvokeCommandRequest, InvokeCommandResponse, OP_INVOKE_COMMAND,
};
use bcode_plugin_sdk::path::display_from_current_dir;
use bcode_plugin_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fmt::Write as _;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use switchy::database::query::{FilterableQuery as _, where_eq};
use switchy::database::schema::{Column, DataType, alter_table, create_table};
use switchy::database::{Database, DatabaseError, DatabaseValue, Row};
use switchy::schema::discovery::code::{CodeMigration, CodeMigrationSource};
use switchy::schema::runner::MigrationRunner;
use switchy::schema::version::VersionTracker;
use thiserror::Error;
pub const CODE_REVIEW_SERVICE_INTERFACE_ID: &str = "bcode.code_review/v1";
pub const OP_CREATE_REVIEW: &str = "create_review";
pub const OP_DRAFT_LIST: &str = "draft.list";
pub const OP_DRAFT_SAVE: &str = "draft.save";
pub const OP_DRAFT_DELETE: &str = "draft.delete";
pub const OP_DRAFT_UPDATE: &str = "draft.update";
pub const OP_THREAD_LINK_SESSION: &str = "thread.link_session";
pub const OP_THREAD_RESOLVE: &str = "thread.resolve";
pub const OP_REVIEW_CONTEXT_GET: &str = "review.context.get";
pub const OP_REVIEW_COMMENTS_LIST: &str = "review.comments.list";
pub const OP_REVIEW_THREAD_GET: &str = "review.thread.get";
pub const OP_REVIEW_DIFF_GET: &str = "review.diff.get";
pub const OP_REVIEW_ASSIGNMENT_VALIDATE: &str = "review.assignment.validate";
pub const OP_REVIEW_ASSIGNMENTS_MATERIALIZE: &str = "review.assignments.materialize";
pub const OP_REVIEW_REPORT_VALIDATE: &str = "review.report.validate";
pub const OP_REVIEW_CONSENSUS_VALIDATE: &str = "review.consensus.validate";
pub const OP_REVIEW_BUNDLE_GET: &str = "review.bundle.get";
pub const OP_REVIEW_PUBLISHERS_LIST: &str = "review.publishers.list";
pub const OP_REVIEW_PUBLISH_PREVIEW: &str = "review.publish.preview";
pub const OP_REVIEW_PUBLISH_SUBMIT: &str = "review.publish.submit";
const CODE_REVIEW_STATE_DIR_ENV: &str = "BCODE_CODE_REVIEW_STATE_DIR";
const DEFAULT_REPO_STATE_ROOT: &str = ".bcode/code-review";
const DEFAULT_STATE_SUBDIR: &str = "code-review";
const DATABASE_FILE_NAME: &str = "code-review.db";
const MIGRATIONS_TABLE: &str = "__bcode_code_review_migrations";
const DATABASE_OPEN_RETRY_ATTEMPTS: u32 = 7;
const DATABASE_OPEN_INITIAL_RETRY_DELAY: Duration = Duration::from_millis(25);
const DATABASE_OPEN_MAX_RETRY_DELAY: Duration = Duration::from_secs(2);
const DATABASE_BUSY_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_REPOSITORY_FILE_BYTES: u64 = 1_000_000;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CodeReviewStateLocation {
#[default]
User,
Repo,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodeReviewPluginConfig {
#[serde(default)]
pub state_location: CodeReviewStateLocation,
#[serde(default)]
pub state_dir: Option<PathBuf>,
}
#[derive(Default)]
pub struct CodeReviewPlugin;
impl RustPlugin for CodeReviewPlugin {
fn register_commands(&mut self, registrar: CommandRegistrar) -> Result<(), PluginError> {
registrar
.register(&diff_toggle_command())
.map_err(|error| PluginError::failed(error.to_string()))
}
fn invoke_service(&mut self, context: NativeServiceContext) -> ServiceResponse {
if context.request.interface_id == COMMAND_INTERFACE_ID {
return invoke_command_service(&context.request);
}
if context.request.interface_id != CODE_REVIEW_SERVICE_INTERFACE_ID {
return ServiceResponse::error(
"unsupported_interface",
"unsupported code review plugin service interface",
);
}
match context.request.operation.as_str() {
OP_CREATE_REVIEW => create_review(&context.request),
OP_DRAFT_LIST => list_drafts(&context),
OP_DRAFT_SAVE => save_draft(&context),
OP_DRAFT_DELETE => delete_draft(&context),
OP_DRAFT_UPDATE => update_draft(&context),
bcode_code_review_models::OP_REVIEW_SUGGESTIONS_LIST => list_suggestions(&context),
bcode_code_review_models::OP_REVIEW_SUGGESTION_SAVE => save_suggestion(&context),
bcode_code_review_models::OP_REVIEW_AI_EXCHANGES_LIST => list_ai_exchanges(&context),
bcode_code_review_models::OP_REVIEW_AI_EXCHANGE_SAVE => save_ai_exchange(&context),
bcode_code_review_models::OP_REVIEW_AI_EXCHANGE_DELETE => delete_ai_exchange(&context),
OP_THREAD_LINK_SESSION => link_thread_session(&context),
OP_THREAD_RESOLVE => resolve_thread(&context),
OP_REVIEW_CONTEXT_GET => review_context_get(&context),
OP_REVIEW_COMMENTS_LIST => review_comments_list(&context),
OP_REVIEW_THREAD_GET => review_thread_get(&context),
OP_REVIEW_DIFF_GET => review_diff_get(&context),
OP_REVIEW_BUNDLE_GET => review_bundle_get(&context),
OP_REVIEW_REPO_FILE_GET => review_repo_file_get(&context.request),
OP_REVIEW_WORKSPACE_LIST => review_workspace_list(&context),
OP_REVIEW_WORKSPACE_CREATE => review_workspace_create(&context),
OP_REVIEW_WORKSPACE_GET => review_workspace_get(&context),
OP_REVIEW_WORKSPACE_UPDATE => review_workspace_update(&context),
OP_REVIEW_WORKSPACE_ARCHIVE => review_workspace_archive(&context),
OP_REVIEW_WORKSPACE_MATERIALIZE => review_workspace_materialize(&context),
OP_REVIEW_PUBLISHERS_LIST => review_publishers_list(&context.request),
OP_REVIEW_PUBLISH_PREVIEW => review_publish_preview(&context),
OP_REVIEW_PUBLISH_SUBMIT => review_publish_submit(&context),
OP_REVIEW_PUBLISH_RECORD_SAVE => review_publish_record_save(&context),
OP_REVIEW_PUBLISH_RECORDS_LIST => review_publish_records_list(&context),
OP_REVIEW_EXTERNAL_IMPORT_SAVE => review_external_import_save(&context),
OP_REVIEW_EXTERNAL_IMPORTS_LIST => review_external_imports_list(&context),
_ => ServiceResponse::error(
"unsupported_operation",
"unsupported code review service operation",
),
}
}
}
fn diff_toggle_command() -> CommandContribution {
CommandContribution {
id: "diff.toggle".to_string(),
title: "Diff: Toggle Panel".to_string(),
description: Some("Show or hide the inline diff review panel".to_string()),
category: Some("diff".to_string()),
surfaces: std::collections::BTreeSet::from([CommandSurface::Palette]),
slash: None,
arguments: Vec::new(),
session: bcode_command::CommandSessionRequirement::Optional,
execution: bcode_command::CommandExecution::Normal,
owner: CommandOwner::Plugin {
plugin_id: "bcode.code_review".to_string(),
},
action: CommandAction::Plugin {
plugin_id: "bcode.code_review".to_string(),
command_id: "diff.toggle".to_string(),
},
}
}
fn invoke_command_service(request: &ServiceRequest) -> ServiceResponse {
if request.operation != OP_INVOKE_COMMAND {
return ServiceResponse::error(
"unsupported_operation",
"unsupported code review command operation",
);
}
let Ok(request) = serde_json::from_slice::<InvokeCommandRequest>(&request.payload) else {
return ServiceResponse::error(
"invalid_request",
"invalid code review command invocation request",
);
};
match request.command_id.as_str() {
"diff.toggle" => json_response(&InvokeCommandResponse {
success: true,
message: None,
updated_model: None,
updated_provider: None,
updated_thinking: None,
effects: vec![CommandEffect::ToggleSurface {
surface_id: "diff".to_string(),
}],
}),
_ => ServiceResponse::error("unknown_command", "unknown code review command"),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreateReviewRequest {
pub repo_path: PathBuf,
pub target: ReviewTarget,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewSummary {
pub title: String,
pub repo_root: PathBuf,
pub files: Vec<ReviewFile>,
pub additions: u32,
pub deletions: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewContextResponse {
pub title: String,
pub repo_root: PathBuf,
pub target: ReviewTarget,
pub files: Vec<ReviewFileSummary>,
pub additions: u32,
pub deletions: u32,
pub draft_count: usize,
pub thread_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewThread {
pub thread_id: String,
pub anchor: DraftAnchor,
pub session_id: Option<String>,
#[serde(default)]
pub resolved_at_ms: Option<u64>,
pub comments: Vec<DraftComment>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewCommentsResponse {
pub threads: Vec<ReviewThread>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewThreadResponse {
pub thread: Option<ReviewThread>,
pub selected_lines: Vec<String>,
pub hunk_context: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewFileDiff {
pub path: String,
pub status: ReviewFileStatus,
pub lines: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewDiffResponse {
pub files: Vec<ReviewFileDiff>,
}
#[derive(Debug, Error)]
enum ReviewError {
#[error("invalid request: {0}")]
InvalidRequest(String),
#[error("Git command failed: {0}")]
Git(String),
#[error("failed to parse diff: {0}")]
Parse(String),
#[error("database connection failed: {0}")]
Connection(#[from] switchy::database_connection::InitTursoError),
#[error("database operation failed: {0}")]
Database(#[from] DatabaseError),
#[error("database migration failed: {0}")]
Migration(#[from] switchy::schema::MigrationError),
#[error("serialization failed: {0}")]
Json(#[from] serde_json::Error),
#[error("database row is missing column {0}")]
MissingColumn(&'static str),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("unsupported review publisher: {0}")]
UnsupportedPublisher(String),
}
fn review_repo_file_get(request: &ServiceRequest) -> ServiceResponse {
let request = match request.payload_json::<RepositoryFileRequest>() {
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
match repository_file_get(&request) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("repo_file_get_failed", error.to_string()),
}
}
fn review_workspace_list(context: &NativeServiceContext) -> ServiceResponse {
let request = match context
.request
.payload_json::<ListReviewWorkspacesRequest>()
{
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match list_review_workspaces_for_request(&request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("workspace_list_failed", error.to_string()),
}
}
fn review_workspace_create(context: &NativeServiceContext) -> ServiceResponse {
let request = match context
.request
.payload_json::<CreateReviewWorkspaceRequest>()
{
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match create_review_workspace_for_request(request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("workspace_create_failed", error.to_string()),
}
}
fn review_workspace_get(context: &NativeServiceContext) -> ServiceResponse {
let request = match context.request.payload_json::<GetReviewWorkspaceRequest>() {
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match get_review_workspace_for_request(request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("workspace_get_failed", error.to_string()),
}
}
fn review_workspace_update(context: &NativeServiceContext) -> ServiceResponse {
let request = match context
.request
.payload_json::<UpdateReviewWorkspaceRequest>()
{
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match update_review_workspace_for_request(request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("workspace_update_failed", error.to_string()),
}
}
fn review_workspace_archive(context: &NativeServiceContext) -> ServiceResponse {
let request = match context
.request
.payload_json::<ArchiveReviewWorkspaceRequest>()
{
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match archive_review_workspace_for_request(request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("workspace_archive_failed", error.to_string()),
}
}
fn review_workspace_materialize(context: &NativeServiceContext) -> ServiceResponse {
let request = match context
.request
.payload_json::<MaterializeReviewWorkspaceRequest>()
{
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
match materialize_review_workspace_for_request(request) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("workspace_materialize_failed", error.to_string()),
}
}
fn create_review(request: &ServiceRequest) -> ServiceResponse {
let request = match request.payload_json::<CreateReviewRequest>() {
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
match create_review_summary(&request) {
Ok(summary) => json_response(&summary),
Err(error) => ServiceResponse::error("review_failed", error.to_string()),
}
}
fn plugin_config(context: &NativeServiceContext) -> Result<CodeReviewPluginConfig, ReviewError> {
context.config_or_default().map_err(ReviewError::Json)
}
fn list_drafts(context: &NativeServiceContext) -> ServiceResponse {
let request = match context.request.payload_json::<ListDraftsRequest>() {
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match list_drafts_for_request(&request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("draft_list_failed", error.to_string()),
}
}
fn save_draft(context: &NativeServiceContext) -> ServiceResponse {
let request = match context.request.payload_json::<SaveDraftRequest>() {
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match save_draft_for_request(request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("draft_save_failed", error.to_string()),
}
}
fn delete_draft(context: &NativeServiceContext) -> ServiceResponse {
let request = match context.request.payload_json::<DeleteDraftRequest>() {
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match delete_draft_for_request(request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("draft_delete_failed", error.to_string()),
}
}
fn update_draft(context: &NativeServiceContext) -> ServiceResponse {
let request = match context.request.payload_json::<UpdateDraftRequest>() {
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match update_draft_for_request(request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("draft_update_failed", error.to_string()),
}
}
fn list_suggestions(context: &NativeServiceContext) -> ServiceResponse {
let request = match context
.request
.payload_json::<ListReviewSuggestionsRequest>()
{
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match list_suggestions_for_request(&request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("suggestion_list_failed", error.to_string()),
}
}
fn save_suggestion(context: &NativeServiceContext) -> ServiceResponse {
let request = match context
.request
.payload_json::<SaveReviewSuggestionRequest>()
{
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match save_suggestion_for_request(request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("suggestion_save_failed", error.to_string()),
}
}
fn list_ai_exchanges(context: &NativeServiceContext) -> ServiceResponse {
let request = match context
.request
.payload_json::<ListReviewAiExchangesRequest>()
{
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match list_ai_exchanges_for_request(&request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("ai_exchange_list_failed", error.to_string()),
}
}
fn save_ai_exchange(context: &NativeServiceContext) -> ServiceResponse {
let request = match context
.request
.payload_json::<SaveReviewAiExchangeRequest>()
{
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match save_ai_exchange_for_request(request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("ai_exchange_save_failed", error.to_string()),
}
}
fn delete_ai_exchange(context: &NativeServiceContext) -> ServiceResponse {
let request = match context
.request
.payload_json::<DeleteReviewAiExchangeRequest>()
{
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match delete_ai_exchange_for_request(request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("ai_exchange_delete_failed", error.to_string()),
}
}
fn link_thread_session(context: &NativeServiceContext) -> ServiceResponse {
let request = match context.request.payload_json::<LinkThreadSessionRequest>() {
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match link_thread_session_for_request(request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("thread_link_session_failed", error.to_string()),
}
}
fn resolve_thread(context: &NativeServiceContext) -> ServiceResponse {
let request = match context.request.payload_json::<ResolveThreadRequest>() {
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match resolve_thread_for_request(request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("thread_resolve_failed", error.to_string()),
}
}
fn review_context_get(context: &NativeServiceContext) -> ServiceResponse {
let request = match context.request.payload_json::<ReviewContextRequest>() {
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match review_context_for_request(request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("review_context_failed", error.to_string()),
}
}
fn review_comments_list(context: &NativeServiceContext) -> ServiceResponse {
let request = match context.request.payload_json::<ReviewContextRequest>() {
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match review_comments_for_request(request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("review_comments_failed", error.to_string()),
}
}
fn review_thread_get(context: &NativeServiceContext) -> ServiceResponse {
let request = match context.request.payload_json::<GetReviewThreadRequest>() {
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match review_thread_for_request(request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("review_thread_failed", error.to_string()),
}
}
fn review_diff_get(context: &NativeServiceContext) -> ServiceResponse {
let request = match context.request.payload_json::<GetReviewDiffRequest>() {
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
match review_diff_for_request(request) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("review_diff_failed", error.to_string()),
}
}
fn review_bundle_get(context: &NativeServiceContext) -> ServiceResponse {
let request = match context.request.payload_json::<PublishReviewRequest>() {
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match review_bundle_for_request(request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("review_bundle_failed", error.to_string()),
}
}
fn review_publishers_list(request: &ServiceRequest) -> ServiceResponse {
if let Err(error) = request.payload_json::<serde_json::Value>() {
return ServiceResponse::error("invalid_request", error.to_string());
}
json_response(&ListReviewPublishersResponse {
publishers: builtin_publishers(),
})
}
fn review_publish_preview(context: &NativeServiceContext) -> ServiceResponse {
let request = match context.request.payload_json::<PublishReviewRequest>() {
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match publish_preview_for_request(request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("review_publish_preview_failed", error.to_string()),
}
}
fn review_publish_submit(context: &NativeServiceContext) -> ServiceResponse {
let request = match context.request.payload_json::<PublishReviewRequest>() {
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match publish_submit_for_request(request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("review_publish_submit_failed", error.to_string()),
}
}
fn review_context_for_request(
request: ReviewContextRequest,
config: &CodeReviewPluginConfig,
) -> Result<ReviewContextResponse, ReviewError> {
let summary = create_review_summary(&CreateReviewRequest {
repo_path: request.repo_path.clone(),
target: request.target.clone(),
})?;
let drafts = list_drafts_for_request(
&ListDraftsRequest {
repo_path: request.repo_path,
target: request.target.clone(),
scope: None,
},
config,
)?
.drafts;
let thread_count = drafts
.iter()
.map(|draft| draft.thread_id.clone())
.collect::<BTreeSet<_>>()
.len();
Ok(ReviewContextResponse {
title: summary.title,
repo_root: summary.repo_root,
target: request.target,
files: summary
.files
.iter()
.map(|file| ReviewFileSummary {
path: file.display_path().to_string(),
status: file.status,
additions: file.additions,
deletions: file.deletions,
hunks: file.hunks.len(),
is_binary: file.is_binary,
})
.collect(),
additions: summary.additions,
deletions: summary.deletions,
draft_count: drafts.len(),
thread_count,
})
}
fn review_comments_for_request(
request: ReviewContextRequest,
config: &CodeReviewPluginConfig,
) -> Result<ReviewCommentsResponse, ReviewError> {
let drafts = list_drafts_for_request(
&ListDraftsRequest {
repo_path: request.repo_path,
target: request.target,
scope: None,
},
config,
)?
.drafts;
Ok(ReviewCommentsResponse {
threads: threads_from_drafts(drafts),
})
}
fn review_thread_for_request(
request: GetReviewThreadRequest,
config: &CodeReviewPluginConfig,
) -> Result<ReviewThreadResponse, ReviewError> {
let summary = create_review_summary(&CreateReviewRequest {
repo_path: request.repo_path.clone(),
target: request.target.clone(),
})?;
let drafts = list_drafts_for_request(
&ListDraftsRequest {
repo_path: request.repo_path,
target: request.target,
scope: None,
},
config,
)?
.drafts;
let thread = threads_from_drafts(drafts).into_iter().find(|thread| {
request
.thread_id
.as_ref()
.is_some_and(|thread_id| thread.thread_id == *thread_id)
|| request
.anchor
.as_ref()
.is_some_and(|anchor| anchors_match(&thread.anchor, anchor))
});
let (selected_lines, hunk_context) = thread.as_ref().map_or_else(
|| (Vec::new(), Vec::new()),
|thread| {
let (_, selected_lines, hunk_context) =
diff_context_for_anchor(&summary, &thread.anchor);
(selected_lines, hunk_context)
},
);
Ok(ReviewThreadResponse {
thread,
selected_lines,
hunk_context,
})
}
fn review_diff_for_request(
request: GetReviewDiffRequest,
) -> Result<ReviewDiffResponse, ReviewError> {
let summary = create_review_summary(&CreateReviewRequest {
repo_path: request.repo_path,
target: request.target,
})?;
let files = summary
.files
.iter()
.filter(|file| {
request
.file_path
.as_ref()
.is_none_or(|path| file.display_path() == path)
})
.map(|file| ReviewFileDiff {
path: file.display_path().to_string(),
status: file.status,
lines: rendered_diff_lines(file),
})
.collect();
Ok(ReviewDiffResponse { files })
}
fn review_bundle_for_request(
request: PublishReviewRequest,
config: &CodeReviewPluginConfig,
) -> Result<ReviewBundle, ReviewError> {
let workspace = request.workspace.clone();
let (summary, surfaces) = if let Some(workspace) = workspace.clone() {
let materialization =
materialize_review_workspace_for_request(MaterializeReviewWorkspaceRequest {
repo_path: request.repo_path.clone(),
workspace,
})?
.materialization;
(
review_summary_from_materialization(&materialization),
materialization.surfaces,
)
} else {
(
create_review_summary(&CreateReviewRequest {
repo_path: request.repo_path.clone(),
target: request.target.clone(),
})?,
Vec::new(),
)
};
let review_key = workspace.as_ref().map_or_else(
|| review_key(&summary.repo_root, &request.target),
|workspace| Ok(workspace_review_key(workspace)),
)?;
let scope = workspace.as_ref().map(|workspace| ReviewScope::Workspace {
workspace_id: workspace.id.clone(),
target: request.target.clone(),
});
let excluded_thread_anchors = request.excluded_thread_anchors;
let threads = threads_from_drafts(
list_drafts_for_request(
&ListDraftsRequest {
repo_path: request.repo_path.clone(),
target: request.target.clone(),
scope,
},
config,
)?
.drafts,
)
.into_iter()
.filter(|thread| !excluded_thread_anchors.contains(&thread.anchor))
.map(|thread| {
let (mut selected_lines, selected_diff_lines, hunk_context) =
context_for_anchor(&summary, &thread.anchor);
hydrate_bundle_line_anchor_metadata(&mut selected_lines, &thread.anchor);
ReviewBundleThread {
thread_id: thread.thread_id,
anchor: thread.anchor,
comments: thread.comments,
session_id: thread.session_id,
resolved_at_ms: thread.resolved_at_ms,
selected_lines,
selected_diff_lines,
hunk_context,
}
})
.collect();
Ok(ReviewBundle {
review_id: review_key,
title: summary.title,
repo_root: summary.repo_root,
target: request.target,
surfaces,
files: summary
.files
.iter()
.map(|file| ReviewFileSummary {
path: file.display_path().to_string(),
status: file.status,
additions: file.additions,
deletions: file.deletions,
hunks: file.hunks.len(),
is_binary: file.is_binary,
})
.collect(),
threads,
generated_at_ms: now_ms(),
})
}
fn review_summary_from_materialization(
materialization: &ReviewWorkspaceMaterialization,
) -> ReviewSummary {
ReviewSummary {
title: materialization.workspace.title.clone(),
repo_root: materialization.workspace.repo_root.clone(),
files: materialization
.surfaces
.iter()
.filter_map(|surface| surface.file.clone())
.collect(),
additions: materialization.additions,
deletions: materialization.deletions,
}
}
trait ReviewPublisher {
fn manifest(&self) -> ReviewPublisherManifest;
fn preview(
&self,
bundle: &ReviewBundle,
options: &serde_json::Value,
) -> Result<String, ReviewError>;
fn submit(
&self,
bundle: &ReviewBundle,
options: &serde_json::Value,
) -> Result<PublishReviewResponse, ReviewError>;
}
struct LocalMarkdownPublisher;
impl ReviewPublisher for LocalMarkdownPublisher {
fn manifest(&self) -> ReviewPublisherManifest {
ReviewPublisherManifest {
id: "local_markdown".to_string(),
label: "Local review markdown".to_string(),
description: "Write a local-first Markdown review notebook export".to_string(),
capabilities: file_publisher_capabilities(),
options_schema: file_publisher_options_schema(),
route: None,
}
}
fn preview(
&self,
bundle: &ReviewBundle,
_options: &serde_json::Value,
) -> Result<String, ReviewError> {
Ok(publish_local_markdown(bundle))
}
fn submit(
&self,
bundle: &ReviewBundle,
options: &serde_json::Value,
) -> Result<PublishReviewResponse, ReviewError> {
write_file_publish(
self.manifest().id,
bundle,
options,
"md",
publish_local_markdown(bundle),
)
}
}
struct MarkdownFilePublisher;
impl ReviewPublisher for MarkdownFilePublisher {
fn manifest(&self) -> ReviewPublisherManifest {
ReviewPublisherManifest {
id: "markdown_file".to_string(),
label: "Markdown file".to_string(),
description: "Write a local Markdown review export".to_string(),
capabilities: file_publisher_capabilities(),
options_schema: file_publisher_options_schema(),
route: None,
}
}
fn preview(
&self,
bundle: &ReviewBundle,
_options: &serde_json::Value,
) -> Result<String, ReviewError> {
Ok(publish_markdown(bundle))
}
fn submit(
&self,
bundle: &ReviewBundle,
options: &serde_json::Value,
) -> Result<PublishReviewResponse, ReviewError> {
write_file_publish(
self.manifest().id,
bundle,
options,
"md",
publish_markdown(bundle),
)
}
}
struct JsonFilePublisher;
impl ReviewPublisher for JsonFilePublisher {
fn manifest(&self) -> ReviewPublisherManifest {
ReviewPublisherManifest {
id: "json_file".to_string(),
label: "JSON file".to_string(),
description: "Write a local JSON review bundle".to_string(),
capabilities: file_publisher_capabilities(),
options_schema: file_publisher_options_schema(),
route: None,
}
}
fn preview(
&self,
bundle: &ReviewBundle,
_options: &serde_json::Value,
) -> Result<String, ReviewError> {
serde_json::to_string_pretty(bundle).map_err(ReviewError::Json)
}
fn submit(
&self,
bundle: &ReviewBundle,
options: &serde_json::Value,
) -> Result<PublishReviewResponse, ReviewError> {
write_file_publish(
self.manifest().id,
bundle,
options,
"json",
serde_json::to_string_pretty(bundle)?,
)
}
}
fn builtin_review_publishers() -> Vec<Box<dyn ReviewPublisher>> {
vec![
Box::new(LocalMarkdownPublisher),
Box::new(MarkdownFilePublisher),
Box::new(JsonFilePublisher),
]
}
fn builtin_publishers() -> Vec<ReviewPublisherManifest> {
builtin_review_publishers()
.into_iter()
.map(|publisher| publisher.manifest())
.collect()
}
const fn file_publisher_capabilities() -> ReviewPublisherCapabilities {
ReviewPublisherCapabilities {
preview: true,
submit: true,
update_existing: true,
supports_threads: true,
supports_ranges: true,
supports_inline_comments: true,
supports_summary_comment: true,
}
}
fn file_publisher_options_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"output_path": { "type": "string", "description": "Optional output path" }
}
})
}
fn publish_preview_for_request(
request: PublishReviewRequest,
config: &CodeReviewPluginConfig,
) -> Result<PublishReviewPreviewResponse, ReviewError> {
let publisher_id = request.publisher_id.clone();
let options = request.options.clone();
let bundle = review_bundle_for_request(request, config)?;
let preview = with_publisher(&publisher_id, |publisher| {
publisher.preview(&bundle, &options)
})?;
Ok(PublishReviewPreviewResponse {
publisher_id,
preview,
})
}
fn review_external_imports_list(context: &NativeServiceContext) -> ServiceResponse {
let request = match context
.request
.payload_json::<ListExternalReviewImportsRequest>()
{
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match list_external_imports_for_request(&request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("external_imports_list_failed", error.to_string()),
}
}
fn list_external_imports_for_request(
request: &ListExternalReviewImportsRequest,
config: &CodeReviewPluginConfig,
) -> Result<ListExternalReviewImportsResponse, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let workspace_id = request.workspace_id.clone();
let records = with_database(&repo_root, config, move |database| {
Box::pin(async move {
CodeReviewDb::new(database)
.list_external_imports(&workspace_id)
.await
})
})?;
Ok(ListExternalReviewImportsResponse { records })
}
fn review_external_import_save(context: &NativeServiceContext) -> ServiceResponse {
let request = match context
.request
.payload_json::<SaveExternalReviewImportRequest>()
{
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match save_external_import_for_request(request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("external_import_save_failed", error.to_string()),
}
}
fn save_external_import_for_request(
request: SaveExternalReviewImportRequest,
config: &CodeReviewPluginConfig,
) -> Result<SaveExternalReviewImportResponse, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let workspace_id = request.workspace_id;
let importer_id = request.import.importer_id;
let record = ExternalReviewImportRecord {
workspace_id,
importer_id,
threads: request.import.threads,
warnings: request.import.warnings,
updated_at_ms: now_ms(),
};
let record_to_save = record.clone();
with_database(&repo_root, config, move |database| {
Box::pin(async move {
CodeReviewDb::new(database)
.save_external_import(&record_to_save)
.await
})
})?;
Ok(SaveExternalReviewImportResponse { record })
}
fn review_publish_records_list(context: &NativeServiceContext) -> ServiceResponse {
let request = match context.request.payload_json::<ListPublishRecordsRequest>() {
Ok(request) => request,
Err(error) => return ServiceResponse::error("invalid_request", error.to_string()),
};
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match list_publish_records_for_request(&request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("publish_records_list_failed", error.to_string()),
}
}
fn list_publish_records_for_request(
request: &ListPublishRecordsRequest,
config: &CodeReviewPluginConfig,
) -> Result<ListPublishRecordsResponse, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let workspace_id = request.workspace_id.clone();
let records = with_database(&repo_root, config, move |database| {
Box::pin(async move {
CodeReviewDb::new(database)
.list_publish_records(&workspace_id)
.await
})
})?;
Ok(ListPublishRecordsResponse { records })
}
fn review_publish_record_save(context: &NativeServiceContext) -> ServiceResponse {
let config = match plugin_config(context) {
Ok(config) => config,
Err(error) => return ServiceResponse::error("invalid_config", error.to_string()),
};
match save_publish_record_for_request(&context.request, &config) {
Ok(response) => json_response(&response),
Err(error) => ServiceResponse::error("publish_record_save_failed", error.to_string()),
}
}
fn publish_submit_for_request(
request: PublishReviewRequest,
config: &CodeReviewPluginConfig,
) -> Result<PublishReviewResponse, ReviewError> {
let publisher_id = request.publisher_id.clone();
let options = request.options.clone();
let bundle = review_bundle_for_request(request.clone(), config)?;
let response = with_publisher(&publisher_id, |publisher| {
publisher.submit(&bundle, &options)
})?;
if let Some(workspace) = request.workspace {
let record = ReviewPublishRecord {
id: publish_record_id(&workspace.id, &publisher_id, response.submitted),
workspace_id: Some(workspace.id.clone()),
review_id: bundle.review_id,
publisher_id: response.publisher_id.clone(),
submitted: response.submitted,
published_thread_anchors: bundle
.threads
.iter()
.map(|thread| thread.anchor.clone())
.collect(),
output: response.output.clone(),
message: response.message.clone(),
created_at_ms: now_ms(),
};
with_database(&workspace.repo_root, config, move |database| {
Box::pin(async move {
CodeReviewDb::new(database)
.save_publish_record(&record)
.await
})
})?;
}
Ok(response)
}
fn save_publish_record_for_request(
request: &ServiceRequest,
config: &CodeReviewPluginConfig,
) -> Result<SavePublishRecordResponse, ReviewError> {
let request = request.payload_json::<SavePublishRecordRequest>()?;
let record = ReviewPublishRecord {
id: publish_record_id(
&request.workspace.id,
&request.publisher_id,
request.submitted,
),
workspace_id: Some(request.workspace.id.clone()),
review_id: request.review_id,
publisher_id: request.publisher_id,
submitted: request.submitted,
published_thread_anchors: request.published_thread_anchors,
output: request.output,
message: request.message,
created_at_ms: now_ms(),
};
let record_to_save = record.clone();
with_database(&request.workspace.repo_root, config, move |database| {
Box::pin(async move {
CodeReviewDb::new(database)
.save_publish_record(&record_to_save)
.await
})
})?;
Ok(SavePublishRecordResponse { record })
}
fn publish_record_id(workspace_id: &str, publisher_id: &str, submitted: bool) -> String {
let status = if submitted { "submitted" } else { "skipped" };
format!(
"publish-{workspace_id}-{publisher_id}-{status}-{}",
now_ms()
)
}
fn with_publisher<T>(
publisher_id: &str,
operation: impl FnOnce(&dyn ReviewPublisher) -> Result<T, ReviewError>,
) -> Result<T, ReviewError> {
for publisher in builtin_review_publishers() {
if publisher.manifest().id == publisher_id {
return operation(publisher.as_ref());
}
}
Err(ReviewError::UnsupportedPublisher(publisher_id.to_string()))
}
fn write_file_publish(
publisher_id: String,
bundle: &ReviewBundle,
options: &serde_json::Value,
extension: &str,
contents: String,
) -> Result<PublishReviewResponse, ReviewError> {
let path = publish_output_path(bundle, options, extension);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, contents)?;
Ok(PublishReviewResponse {
publisher_id,
submitted: true,
output: Some(path.display().to_string()),
message: format!("wrote review export to {}", display_from_current_dir(&path)),
})
}
fn publish_output_path(
bundle: &ReviewBundle,
options: &serde_json::Value,
extension: &str,
) -> PathBuf {
options
.get("output_path")
.and_then(serde_json::Value::as_str)
.filter(|path| !path.is_empty())
.map_or_else(
|| {
bundle
.repo_root
.join(".bcode")
.join("reviews")
.join(format!(
"{}.{}",
safe_review_id(&bundle.review_id),
extension
))
},
PathBuf::from,
)
}
fn publish_local_markdown(bundle: &ReviewBundle) -> String {
let mut output = String::new();
let _ = write!(output, "# Local review: {}\n\n", bundle.title);
let _ = writeln!(output, "* Review id: `{}`", bundle.review_id);
let _ = writeln!(
output,
"* Repository: `{}`",
display_from_current_dir(&bundle.repo_root)
);
let _ = writeln!(output, "* Generated: `{}`", bundle.generated_at_ms);
let _ = writeln!(output, "* Files: `{}`", bundle.files.len());
let open_threads = bundle
.threads
.iter()
.filter(|thread| thread.resolved_at_ms.is_none())
.count();
let resolved_threads = bundle.threads.len().saturating_sub(open_threads);
let comment_count = bundle
.threads
.iter()
.map(|thread| thread.comments.len())
.sum::<usize>();
let _ = writeln!(output, "* Open threads: `{open_threads}`");
let _ = writeln!(output, "* Resolved threads: `{resolved_threads}`");
let _ = write!(output, "* Local comments: `{comment_count}`\n\n");
if !bundle.files.is_empty() {
output.push_str("## Files\n\n");
for file in &bundle.files {
let _ = writeln!(
output,
"* `{}` {:?} +{} -{}",
file.path, file.status, file.additions, file.deletions
);
}
output.push('\n');
}
if bundle.threads.is_empty() {
output.push_str("## Local review threads\n\nNo local review threads.\n");
return output;
}
let review_threads = bundle
.threads
.iter()
.filter(|thread| thread.anchor.kind == ReviewAnchorKind::Review)
.collect::<Vec<_>>();
if !review_threads.is_empty() {
output.push_str("## Review-level threads\n\n");
for thread in review_threads {
push_local_markdown_thread(&mut output, thread);
}
}
let file_threads = bundle
.threads
.iter()
.filter(|thread| thread.anchor.kind != ReviewAnchorKind::Review)
.collect::<Vec<_>>();
if file_threads.is_empty() {
return output;
}
output.push_str("## File threads\n\n");
for thread in file_threads {
push_local_markdown_thread(&mut output, thread);
}
output
}
fn push_local_markdown_thread(output: &mut String, thread: &ReviewBundleThread) {
let status = if thread.resolved_at_ms.is_some() {
"resolved"
} else {
"open"
};
let heading = if thread.anchor.kind == ReviewAnchorKind::Review {
"Review".to_string()
} else {
format!(
"`{}` {}",
thread.anchor.file_path,
anchor_label(&thread.anchor)
)
};
let _ = write!(output, "### {heading} ({status})\n\n");
let _ = writeln!(output, "* Thread id: `{}`", thread.thread_id);
if let Some(session_id) = &thread.session_id {
let _ = writeln!(output, "* Bcode session: `{session_id}`");
}
if let Some(resolved_at_ms) = thread.resolved_at_ms {
let _ = writeln!(output, "* Resolved at: `{resolved_at_ms}`");
}
output.push('\n');
for comment in &thread.comments {
let _ = writeln!(output, "#### Comment `{}`\n", comment.comment_id);
output.push_str(&comment.body);
output.push_str("\n\n");
}
if !thread.selected_diff_lines.is_empty() {
output.push_str("Context:\n\n```diff\n");
output.push_str(&thread.selected_diff_lines.join("\n"));
output.push_str("\n```\n\n");
}
}
fn publish_markdown(bundle: &ReviewBundle) -> String {
let mut output = String::new();
let _ = write!(output, "# {}\n\n", bundle.title);
let _ = writeln!(output, "* Review id: `{}`", bundle.review_id);
let _ = writeln!(
output,
"* Repository: `{}`",
display_from_current_dir(&bundle.repo_root)
);
let _ = writeln!(output, "* Generated: `{}`", bundle.generated_at_ms);
if !bundle.surfaces.is_empty() {
let _ = writeln!(output, "* Surfaces: `{}`", bundle.surfaces.len());
}
let _ = write!(output, "* Threads: `{}`\n\n", bundle.threads.len());
if !bundle.surfaces.is_empty() {
output.push_str("## Review surfaces\n\n");
for surface in &bundle.surfaces {
let _ = writeln!(
output,
"* `{}` `{}` from source `{}`",
surface_kind_label(surface.kind),
surface.path,
surface.source_id
);
}
output.push('\n');
}
for thread in &bundle.threads {
let _ = write!(
output,
"## {} @ {}\n\n",
thread.anchor.file_path,
anchor_label(&thread.anchor)
);
if let Some(surface_id) = &thread.anchor.surface_id {
let _ = writeln!(output, "* Surface: `{surface_id}`");
}
if let Some(source_id) = &thread.anchor.source_id {
let _ = writeln!(output, "* Source: `{source_id}`");
}
if thread.anchor.is_file_anchor {
output.push_str("* Anchor kind: `file`\n");
}
if let Some(session_id) = &thread.session_id {
let _ = write!(output, "* Bcode session: `{session_id}`\n\n");
}
for comment in &thread.comments {
let _ = write!(
output,
"### Comment `{}`\n\n{}\n\n",
comment.comment_id, comment.body
);
}
if !thread.selected_diff_lines.is_empty() {
output.push_str("Selected diff lines:\n\n```diff\n");
output.push_str(&thread.selected_diff_lines.join("\n"));
output.push_str("\n```\n\n");
}
}
output
}
const fn surface_kind_label(kind: ReviewSurfaceKind) -> &'static str {
match kind {
ReviewSurfaceKind::Diff => "diff",
ReviewSurfaceKind::File => "file",
}
}
fn anchor_label(anchor: &DraftAnchor) -> String {
match anchor.kind {
ReviewAnchorKind::Review => "review".to_string(),
ReviewAnchorKind::File => "file".to_string(),
ReviewAnchorKind::Range => {
let start = anchor.start_diff_row.unwrap_or(anchor.diff_row);
let end = anchor.end_diff_row.unwrap_or(anchor.diff_row);
if start == end {
format!("row {start}")
} else {
format!("rows {start}-{end}")
}
}
}
}
fn safe_review_id(review_id: &str) -> String {
review_id
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
.collect()
}
fn list_review_workspaces_for_request(
request: &ListReviewWorkspacesRequest,
config: &CodeReviewPluginConfig,
) -> Result<ListReviewWorkspacesResponse, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let include_archived = request.include_archived;
let db_repo_root = repo_root.clone();
let workspaces = with_database(&repo_root, config, move |database| {
Box::pin(async move {
CodeReviewDb::new(database)
.list_workspaces(&db_repo_root, include_archived)
.await
})
})?;
let items = with_database(&repo_root, config, move |database| {
Box::pin(async move {
let db = CodeReviewDb::new(database);
let mut items = Vec::new();
for workspace in workspaces {
let review_key = workspace_review_key(&workspace);
let (thread_count, draft_count) = db.draft_counts(&review_key).await?;
if let Some(legacy_review_key) = workspace_legacy_review_key(&workspace)? {
let (_, legacy_draft_count) = db.draft_counts(&legacy_review_key).await?;
let (thread_count, draft_count) = if draft_count == 0 && legacy_draft_count > 0
{
db.draft_counts(&legacy_review_key).await?
} else {
(thread_count, draft_count)
};
let last_publish = db.last_publish_record(Some(&workspace.id)).await?;
items.push(ReviewWorkspaceListItem {
workspace,
thread_count,
draft_count,
last_publish,
});
} else {
let last_publish = db.last_publish_record(Some(&workspace.id)).await?;
items.push(ReviewWorkspaceListItem {
workspace,
thread_count,
draft_count,
last_publish,
});
}
}
Ok(items)
})
})?;
let workspaces = items.iter().map(|item| item.workspace.clone()).collect();
Ok(ListReviewWorkspacesResponse { workspaces, items })
}
fn create_review_workspace_for_request(
request: CreateReviewWorkspaceRequest,
config: &CodeReviewPluginConfig,
) -> Result<CreateReviewWorkspaceResponse, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let now = now_ms();
let title = request
.title
.unwrap_or_else(|| "Untitled review".to_string());
let id = workspace_id(&repo_root, &title, now);
let workspace = ReviewWorkspace {
id,
title,
repo_root: repo_root.clone(),
sources: request.sources,
created_at_ms: Some(now),
updated_at_ms: Some(now),
viewed_files: BTreeSet::new(),
archived_at_ms: None,
presentation_state: None,
};
let saved = workspace.clone();
with_database(&repo_root, config, move |database| {
Box::pin(async move { CodeReviewDb::new(database).save_workspace(&saved).await })
})?;
Ok(CreateReviewWorkspaceResponse { workspace })
}
fn get_review_workspace_for_request(
request: GetReviewWorkspaceRequest,
config: &CodeReviewPluginConfig,
) -> Result<GetReviewWorkspaceResponse, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let workspace_id = request.workspace_id;
let workspace = with_database(&repo_root, config, move |database| {
Box::pin(async move {
CodeReviewDb::new(database)
.get_workspace(&workspace_id)
.await
})
})?;
Ok(GetReviewWorkspaceResponse { workspace })
}
fn update_review_workspace_for_request(
request: UpdateReviewWorkspaceRequest,
config: &CodeReviewPluginConfig,
) -> Result<UpdateReviewWorkspaceResponse, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let mut workspace = request.workspace;
workspace.repo_root.clone_from(&repo_root);
workspace.updated_at_ms = Some(now_ms());
let saved = workspace.clone();
with_database(&repo_root, config, move |database| {
Box::pin(async move { CodeReviewDb::new(database).save_workspace(&saved).await })
})?;
Ok(UpdateReviewWorkspaceResponse { workspace })
}
fn archive_review_workspace_for_request(
request: ArchiveReviewWorkspaceRequest,
config: &CodeReviewPluginConfig,
) -> Result<ArchiveReviewWorkspaceResponse, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let workspace_id = request.workspace_id;
let archived = with_database(&repo_root, config, move |database| {
Box::pin(async move {
CodeReviewDb::new(database)
.archive_workspace(&workspace_id, now_ms())
.await
})
})?;
Ok(ArchiveReviewWorkspaceResponse { archived })
}
fn materialize_review_workspace_for_request(
request: MaterializeReviewWorkspaceRequest,
) -> Result<MaterializeReviewWorkspaceResponse, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let mut surfaces = Vec::new();
let mut additions = 0_u32;
let mut deletions = 0_u32;
let mut diagnostics = Vec::new();
for source in request
.workspace
.sources
.iter()
.filter(|source| source.included)
{
let before = surfaces.len();
if let Err(error) = materialize_source(
&repo_root,
source,
&mut surfaces,
&mut additions,
&mut deletions,
) {
let (code, message) = actionable_materialization_error(source, &error);
diagnostics.push(source_diagnostic(
source,
ReviewSourceDiagnosticSeverity::Error,
code,
message,
));
continue;
}
if surfaces.len() == before {
diagnostics.push(source_diagnostic(
source,
ReviewSourceDiagnosticSeverity::Info,
"no_surfaces",
"source produced no reviewable surfaces",
));
}
}
let repository_files = repository_review_files(&repo_root)?
.into_iter()
.filter_map(|file| file.new_path.or(file.old_path))
.collect::<Vec<_>>();
let repository_branches = repository_review_branches(&repo_root)?;
let repository_commits = repository_review_commits(&repo_root)?;
Ok(MaterializeReviewWorkspaceResponse {
materialization: ReviewWorkspaceMaterialization {
workspace: request.workspace,
surfaces,
diagnostics,
repository_files,
repository_branches,
repository_commits,
additions,
deletions,
},
})
}
fn actionable_materialization_error(
source: &ReviewSource,
error: &ReviewError,
) -> (&'static str, String) {
let recovery = match &source.kind {
ReviewSourceKind::LastCommit | ReviewSourceKind::Commit { .. } => {
"Verify the revision exists locally and fetch it if needed."
}
ReviewSourceKind::CommitRange { .. } => {
"Verify both revisions exist locally; fetch missing refs or edit the range."
}
ReviewSourceKind::BranchCompare { .. } => {
"Verify both branches exist locally; fetch missing branches or edit the comparison."
}
ReviewSourceKind::WorkingTreeAndIndex => {
"Create an initial commit first, or review staged and unstaged changes separately."
}
ReviewSourceKind::File { .. } | ReviewSourceKind::FileRange { .. } => {
"Verify the repository-relative path and range, then edit or remove this source."
}
ReviewSourceKind::WorkingTreeUnstaged
| ReviewSourceKind::IndexStaged
| ReviewSourceKind::Repository => "Verify the repository state, then refresh this source.",
};
let code = match source.kind {
ReviewSourceKind::CommitRange { .. } | ReviewSourceKind::Commit { .. } => {
"invalid_revision"
}
ReviewSourceKind::BranchCompare { .. } => "missing_branch",
ReviewSourceKind::LastCommit | ReviewSourceKind::WorkingTreeAndIndex => "missing_head",
ReviewSourceKind::File { .. } | ReviewSourceKind::FileRange { .. } => "invalid_file_source",
ReviewSourceKind::WorkingTreeUnstaged
| ReviewSourceKind::IndexStaged
| ReviewSourceKind::Repository => "materialize_failed",
};
(code, format!("{error}. {recovery}"))
}
fn source_diagnostic(
source: &ReviewSource,
severity: ReviewSourceDiagnosticSeverity,
code: &str,
message: impl Into<String>,
) -> ReviewSourceDiagnostic {
ReviewSourceDiagnostic {
source_id: source.id.clone(),
severity,
code: code.to_string(),
message: message.into(),
}
}
fn materialize_source(
repo_root: &Path,
source: &ReviewSource,
surfaces: &mut Vec<ReviewSurface>,
additions: &mut u32,
deletions: &mut u32,
) -> Result<(), ReviewError> {
match review_target_from_source_kind(&source.kind) {
Some(target) => {
let request = CreateReviewRequest {
repo_path: repo_root.to_path_buf(),
target,
};
let summary = create_review_summary(&request)?;
for file in summary.files {
*additions = additions.saturating_add(file.additions);
*deletions = deletions.saturating_add(file.deletions);
let path = file
.new_path
.clone()
.or_else(|| file.old_path.clone())
.unwrap_or_default();
surfaces.push(ReviewSurface {
id: surface_id(&source.id, &path, ReviewSurfaceKind::Diff),
source_id: source.id.clone(),
path,
kind: ReviewSurfaceKind::Diff,
file: Some(file),
});
}
}
None => materialize_context_source(repo_root, source, surfaces)?,
}
Ok(())
}
fn materialize_context_source(
repo_root: &Path,
source: &ReviewSource,
surfaces: &mut Vec<ReviewSurface>,
) -> Result<(), ReviewError> {
match &source.kind {
ReviewSourceKind::File { path } => {
surfaces.push(file_surface_for_path(repo_root, source, path, None)?);
}
ReviewSourceKind::FileRange { path, start, end } => {
surfaces.push(file_surface_for_path(
repo_root,
source,
path,
Some((*start, *end)),
)?);
}
ReviewSourceKind::Repository => {
for file in repository_review_files(repo_root)? {
let path = file.display_path().to_string();
surfaces.push(ReviewSurface {
id: surface_id(&source.id, &path, ReviewSurfaceKind::File),
source_id: source.id.clone(),
path,
kind: ReviewSurfaceKind::File,
file: Some(file),
});
}
}
ReviewSourceKind::Commit { rev } => surfaces.push(ReviewSurface {
id: surface_id(&source.id, rev, ReviewSurfaceKind::File),
source_id: source.id.clone(),
path: rev.clone(),
kind: ReviewSurfaceKind::File,
file: None,
}),
ReviewSourceKind::WorkingTreeUnstaged
| ReviewSourceKind::IndexStaged
| ReviewSourceKind::WorkingTreeAndIndex
| ReviewSourceKind::LastCommit
| ReviewSourceKind::CommitRange { .. }
| ReviewSourceKind::BranchCompare { .. } => {}
}
Ok(())
}
fn file_surface_for_path(
repo_root: &Path,
source: &ReviewSource,
path: &str,
range: Option<(u32, u32)>,
) -> Result<ReviewSurface, ReviewError> {
let file = review_file_for_repository_path(repo_root, path, range)?;
Ok(ReviewSurface {
id: surface_id(&source.id, path, ReviewSurfaceKind::File),
source_id: source.id.clone(),
path: path.to_string(),
kind: ReviewSurfaceKind::File,
file: Some(file),
})
}
fn review_file_for_repository_path(
repo_root: &Path,
path: &str,
range: Option<(u32, u32)>,
) -> Result<ReviewFile, ReviewError> {
let response = repository_file_get(&RepositoryFileRequest {
repo_path: repo_root.to_path_buf(),
file_path: path.to_string(),
})?;
let lines = response
.content
.as_deref()
.map_or_else(Vec::new, |content| file_review_lines(content, range));
Ok(ReviewFile {
old_path: None,
new_path: Some(path.to_string()),
status: ReviewFileStatus::Unknown,
additions: 0,
deletions: 0,
hunks: vec![ReviewHunk {
old_start: range.map_or(1, |(start, _)| start),
old_count: u32::try_from(lines.len()).unwrap_or(u32::MAX),
new_start: range.map_or(1, |(start, _)| start),
new_count: u32::try_from(lines.len()).unwrap_or(u32::MAX),
heading: response.unavailable_reason,
lines,
}],
is_binary: response.is_binary,
})
}
fn file_review_lines(content: &str, range: Option<(u32, u32)>) -> Vec<ReviewLine> {
content
.lines()
.enumerate()
.filter_map(|(index, line)| {
let line_number = u32::try_from(index.saturating_add(1)).unwrap_or(u32::MAX);
if let Some((start, end)) = range
&& (line_number < start || line_number > end)
{
return None;
}
Some(ReviewLine {
kind: ReviewLineKind::Context,
old_line: Some(line_number),
new_line: Some(line_number),
content: line.to_string(),
})
})
.collect()
}
fn review_target_from_source_kind(kind: &ReviewSourceKind) -> Option<ReviewTarget> {
match kind {
ReviewSourceKind::WorkingTreeUnstaged => Some(ReviewTarget::WorkingTreeUnstaged),
ReviewSourceKind::IndexStaged => Some(ReviewTarget::IndexStaged),
ReviewSourceKind::WorkingTreeAndIndex => Some(ReviewTarget::WorkingTreeAndIndex),
ReviewSourceKind::LastCommit => Some(ReviewTarget::LastCommit),
ReviewSourceKind::CommitRange {
base,
head,
merge_base,
} => Some(ReviewTarget::CommitRange {
base: base.clone(),
head: head.clone(),
merge_base: *merge_base,
}),
ReviewSourceKind::BranchCompare {
base_branch,
head_branch,
merge_base,
} => Some(ReviewTarget::BranchCompare {
base_branch: base_branch.clone(),
head_branch: head_branch.clone(),
merge_base: *merge_base,
}),
ReviewSourceKind::Commit { rev } => Some(ReviewTarget::CommitRange {
base: format!("{rev}^"),
head: rev.clone(),
merge_base: false,
}),
ReviewSourceKind::File { .. }
| ReviewSourceKind::FileRange { .. }
| ReviewSourceKind::Repository => None,
}
}
fn workspace_legacy_review_key(workspace: &ReviewWorkspace) -> Result<Option<String>, ReviewError> {
let Some(target) = workspace
.sources
.iter()
.find(|source| source.included)
.and_then(|source| review_target_from_source_kind(&source.kind))
else {
return Ok(None);
};
review_key(&workspace.repo_root, &target).map(Some)
}
fn list_drafts_for_request(
request: &ListDraftsRequest,
config: &CodeReviewPluginConfig,
) -> Result<ListDraftsResponse, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let review_key = review_key_for_scope(&repo_root, &request.target, request.scope.as_ref())?;
let drafts = with_database(&repo_root, config, move |database| {
Box::pin(async move { CodeReviewDb::new(database).list_drafts(&review_key).await })
})?;
Ok(ListDraftsResponse { drafts })
}
fn list_ai_exchanges_for_request(
request: &ListReviewAiExchangesRequest,
config: &CodeReviewPluginConfig,
) -> Result<ListReviewAiExchangesResponse, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let review_key = review_key_for_scope(&repo_root, &request.target, request.scope.as_ref())?;
let exchanges = with_database(&repo_root, config, move |database| {
Box::pin(async move {
CodeReviewDb::new(database)
.list_ai_exchanges(&review_key)
.await
})
})?;
Ok(ListReviewAiExchangesResponse { exchanges })
}
fn save_ai_exchange_for_request(
request: SaveReviewAiExchangeRequest,
config: &CodeReviewPluginConfig,
) -> Result<SaveReviewAiExchangeResponse, ReviewError> {
if request.exchange.schema_version
!= bcode_code_review_models::REVIEW_AI_EXCHANGE_SCHEMA_VERSION
{
return Err(ReviewError::InvalidRequest(format!(
"unsupported review AI exchange schema version: {}",
request.exchange.schema_version
)));
}
let repo_root = resolve_repo_root(&request.repo_path)?;
let db_repo_root = repo_root.clone();
let review_key = review_key_for_scope(&repo_root, &request.target, request.scope.as_ref())?;
let target_kind = target_kind(&request.target).to_string();
let target_json = serde_json::to_string(&request.target)?;
let exchange = request.exchange;
let exchange = with_database(&repo_root, config, move |database| {
Box::pin(async move {
CodeReviewDb::new(database)
.save_ai_exchange(
&review_key,
&db_repo_root,
&target_kind,
&target_json,
exchange,
)
.await
})
})?;
Ok(SaveReviewAiExchangeResponse { exchange })
}
fn delete_ai_exchange_for_request(
request: DeleteReviewAiExchangeRequest,
config: &CodeReviewPluginConfig,
) -> Result<DeleteReviewAiExchangeResponse, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let review_key = review_key_for_scope(&repo_root, &request.target, request.scope.as_ref())?;
let exchange_id = request.exchange_id;
let deleted = with_database(&repo_root, config, move |database| {
Box::pin(async move {
CodeReviewDb::new(database)
.delete_ai_exchange(&review_key, &exchange_id)
.await
})
})?;
Ok(DeleteReviewAiExchangeResponse { deleted })
}
fn list_suggestions_for_request(
request: &ListReviewSuggestionsRequest,
config: &CodeReviewPluginConfig,
) -> Result<ListReviewSuggestionsResponse, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let review_key = review_key_for_scope(&repo_root, &request.target, request.scope.as_ref())?;
let suggestions = with_database(&repo_root, config, move |database| {
Box::pin(async move {
CodeReviewDb::new(database)
.list_suggestions(&review_key)
.await
})
})?;
Ok(ListReviewSuggestionsResponse { suggestions })
}
fn save_suggestion_for_request(
request: SaveReviewSuggestionRequest,
config: &CodeReviewPluginConfig,
) -> Result<SaveReviewSuggestionResponse, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let db_repo_root = repo_root.clone();
let review_key = review_key_for_scope(&repo_root, &request.target, request.scope.as_ref())?;
let target_kind = target_kind(&request.target).to_string();
let target_json = serde_json::to_string(&request.target)?;
let suggestion = with_database(&repo_root, config, move |database| {
Box::pin(async move {
CodeReviewDb::new(database)
.save_suggestion(
&review_key,
&db_repo_root,
&target_kind,
&target_json,
request.suggestion,
)
.await
})
})?;
Ok(SaveReviewSuggestionResponse { suggestion })
}
fn save_draft_for_request(
request: SaveDraftRequest,
config: &CodeReviewPluginConfig,
) -> Result<SaveDraftResponse, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let db_repo_root = repo_root.clone();
let review_key = review_key_for_scope(&repo_root, &request.target, request.scope.as_ref())?;
let target_kind = target_kind(&request.target).to_string();
let target_json = serde_json::to_string(&request.target)?;
let draft = with_database(&repo_root, config, move |database| {
Box::pin(async move {
CodeReviewDb::new(database)
.save_draft(
&review_key,
&db_repo_root,
&target_kind,
&target_json,
request.anchor,
&request.body,
)
.await
})
})?;
Ok(SaveDraftResponse { draft })
}
fn delete_draft_for_request(
request: DeleteDraftRequest,
config: &CodeReviewPluginConfig,
) -> Result<DeleteDraftResponse, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let deleted = with_database(&repo_root, config, move |database| {
Box::pin(async move {
CodeReviewDb::new(database)
.delete_draft(&request.comment_id)
.await
})
})?;
Ok(DeleteDraftResponse { deleted })
}
fn update_draft_for_request(
request: UpdateDraftRequest,
config: &CodeReviewPluginConfig,
) -> Result<UpdateDraftResponse, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let result = with_database(&repo_root, config, move |database| {
Box::pin(async move {
CodeReviewDb::new(database)
.update_draft(&request.comment_id, &request.body)
.await
})
})?;
Ok(result)
}
fn link_thread_session_for_request(
request: LinkThreadSessionRequest,
config: &CodeReviewPluginConfig,
) -> Result<LinkThreadSessionResponse, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let db_repo_root = repo_root.clone();
let review_key = review_key_for_scope(&repo_root, &request.target, request.scope.as_ref())?;
let target_kind = target_kind(&request.target).to_string();
let target_json = serde_json::to_string(&request.target)?;
let response = with_database(&repo_root, config, move |database| {
Box::pin(async move {
CodeReviewDb::new(database)
.link_thread_session(
&review_key,
&db_repo_root,
&target_kind,
&target_json,
&request.anchor,
&request.session_id,
)
.await
})
})?;
Ok(response)
}
fn resolve_thread_for_request(
request: ResolveThreadRequest,
config: &CodeReviewPluginConfig,
) -> Result<ResolveThreadResponse, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let db_repo_root = repo_root.clone();
let review_key = review_key_for_scope(&repo_root, &request.target, request.scope.as_ref())?;
let target_kind = target_kind(&request.target).to_string();
let target_json = serde_json::to_string(&request.target)?;
let response = with_database(&repo_root, config, move |database| {
Box::pin(async move {
CodeReviewDb::new(database)
.resolve_thread(
&review_key,
&db_repo_root,
&target_kind,
&target_json,
&request.anchor,
request.resolved,
)
.await
})
})?;
Ok(response)
}
fn threads_from_drafts(drafts: Vec<DraftComment>) -> Vec<ReviewThread> {
let mut threads: BTreeMap<String, ReviewThread> = BTreeMap::new();
for draft in drafts {
let entry = threads
.entry(draft.thread_id.clone())
.or_insert_with(|| ReviewThread {
thread_id: draft.thread_id.clone(),
anchor: draft.anchor.clone(),
session_id: draft.session_id.clone(),
resolved_at_ms: draft.resolved_at_ms,
comments: Vec::new(),
});
if entry.session_id.is_none() {
entry.session_id.clone_from(&draft.session_id);
}
if entry.resolved_at_ms.is_none() {
entry.resolved_at_ms = draft.resolved_at_ms;
}
entry.comments.push(draft);
}
threads.into_values().collect()
}
fn anchors_match(left: &DraftAnchor, right: &DraftAnchor) -> bool {
left.file_path == right.file_path
&& left.diff_row == right.diff_row
&& left.end_diff_row == right.end_diff_row
&& left.old_line == right.old_line
&& left.new_line == right.new_line
}
fn context_for_anchor(
summary: &ReviewSummary,
anchor: &DraftAnchor,
) -> (Vec<ReviewBundleLine>, Vec<String>, Vec<String>) {
match anchor.kind {
ReviewAnchorKind::Review => (Vec::new(), Vec::new(), Vec::new()),
ReviewAnchorKind::File => file_context_for_anchor(summary, anchor),
ReviewAnchorKind::Range => {
if anchor.is_file_anchor {
file_context_for_anchor(summary, anchor)
} else {
diff_context_for_anchor(summary, anchor)
}
}
}
}
fn file_context_for_anchor(
summary: &ReviewSummary,
anchor: &DraftAnchor,
) -> (Vec<ReviewBundleLine>, Vec<String>, Vec<String>) {
let Some(file) = summary
.files
.iter()
.find(|file| file.display_path() == anchor.file_path)
else {
return (Vec::new(), Vec::new(), Vec::new());
};
let lines = bundle_file_lines(file);
let rendered_lines: Vec<String> = lines.iter().map(render_bundle_file_line).collect();
let start =
usize::try_from(anchor.start_diff_row.unwrap_or(anchor.diff_row)).unwrap_or(usize::MAX);
let end = usize::try_from(anchor.end_diff_row.unwrap_or(anchor.diff_row)).unwrap_or(start);
let selected_structured_lines = lines
.iter()
.filter(|line| {
usize::try_from(line.diff_row).is_ok_and(|index| (start..=end).contains(&index))
})
.cloned()
.collect();
let selected_lines = rendered_lines
.iter()
.enumerate()
.filter_map(|(index, line)| (start..=end).contains(&index).then_some(line.clone()))
.collect();
(selected_structured_lines, selected_lines, rendered_lines)
}
fn diff_context_for_anchor(
summary: &ReviewSummary,
anchor: &DraftAnchor,
) -> (Vec<ReviewBundleLine>, Vec<String>, Vec<String>) {
let Some(file) = summary
.files
.iter()
.find(|file| file.display_path() == anchor.file_path)
else {
return (Vec::new(), Vec::new(), Vec::new());
};
let lines = bundle_diff_lines(file);
let rendered_lines: Vec<String> = lines.iter().map(render_bundle_line).collect();
let start =
usize::try_from(anchor.start_diff_row.unwrap_or(anchor.diff_row)).unwrap_or(usize::MAX);
let end = usize::try_from(anchor.end_diff_row.unwrap_or(anchor.diff_row)).unwrap_or(start);
let selected_structured_lines = lines
.iter()
.filter(|line| {
usize::try_from(line.diff_row).is_ok_and(|index| (start..=end).contains(&index))
})
.cloned()
.collect();
let selected_lines = rendered_lines
.iter()
.enumerate()
.filter_map(|(index, line)| (start..=end).contains(&index).then_some(line.clone()))
.collect();
let mut row = 0usize;
for hunk in &file.hunks {
let hunk_start = row;
let hunk_lines = rendered_hunk_lines(hunk);
let hunk_end = hunk_start
.saturating_add(hunk_lines.len())
.saturating_sub(1);
if start <= hunk_end && end >= hunk_start {
return (selected_structured_lines, selected_lines, hunk_lines);
}
row = row.saturating_add(hunk_lines.len());
}
(selected_structured_lines, selected_lines, Vec::new())
}
fn hydrate_bundle_line_anchor_metadata(lines: &mut [ReviewBundleLine], anchor: &DraftAnchor) {
for line in lines {
line.surface_id.clone_from(&anchor.surface_id);
line.source_id.clone_from(&anchor.source_id);
}
}
fn bundle_file_lines(file: &ReviewFile) -> Vec<ReviewBundleLine> {
let mut diff_row = 0_u64;
let mut lines = Vec::new();
for hunk in &file.hunks {
if let Some(heading) = &hunk.heading {
lines.push(ReviewBundleLine {
file_path: file.display_path().to_string(),
kind: ReviewLineKind::Context,
old_line: None,
new_line: None,
diff_row,
content: format!("# {heading}"),
surface_id: None,
source_id: None,
});
diff_row = diff_row.saturating_add(1);
}
for line in &hunk.lines {
lines.push(ReviewBundleLine {
file_path: file.display_path().to_string(),
kind: ReviewLineKind::Context,
old_line: None,
new_line: line.new_line.or(line.old_line),
diff_row,
content: line.content.clone(),
surface_id: None,
source_id: None,
});
diff_row = diff_row.saturating_add(1);
}
}
lines
}
fn render_bundle_file_line(line: &ReviewBundleLine) -> String {
let line_number = line
.new_line
.or(line.old_line)
.map_or_else(|| " ".to_string(), |line| format!("{line:>5}"));
format!("{line_number} {}", line.content)
}
fn bundle_diff_lines(file: &ReviewFile) -> Vec<ReviewBundleLine> {
let mut diff_row = 0_u64;
let mut lines = Vec::new();
for hunk in &file.hunks {
lines.push(ReviewBundleLine {
file_path: file.display_path().to_string(),
kind: ReviewLineKind::Context,
old_line: None,
new_line: None,
diff_row,
content: format!(
"@@ -{},{} +{},{} @@{}",
hunk.old_start,
hunk.old_count,
hunk.new_start,
hunk.new_count,
hunk.heading
.as_ref()
.map_or(String::new(), |heading| format!(" {heading}")),
),
surface_id: None,
source_id: None,
});
diff_row = diff_row.saturating_add(1);
for line in &hunk.lines {
lines.push(ReviewBundleLine {
file_path: file.display_path().to_string(),
kind: line.kind,
old_line: line.old_line,
new_line: line.new_line,
diff_row,
content: line.content.clone(),
surface_id: None,
source_id: None,
});
diff_row = diff_row.saturating_add(1);
}
}
lines
}
fn render_bundle_line(line: &ReviewBundleLine) -> String {
let marker = match line.kind {
ReviewLineKind::Context => ' ',
ReviewLineKind::Added => '+',
ReviewLineKind::Removed => '-',
};
format!("{marker}{}", line.content)
}
fn rendered_diff_lines(file: &ReviewFile) -> Vec<String> {
file.hunks.iter().flat_map(rendered_hunk_lines).collect()
}
fn rendered_hunk_lines(hunk: &ReviewHunk) -> Vec<String> {
let mut lines = Vec::with_capacity(hunk.lines.len().saturating_add(1));
lines.push(format!(
"@@ -{},{} +{},{} @@{}",
hunk.old_start,
hunk.old_count,
hunk.new_start,
hunk.new_count,
hunk.heading
.as_ref()
.map_or(String::new(), |heading| format!(" {heading}")),
));
lines.extend(hunk.lines.iter().map(|line| {
let marker = match line.kind {
ReviewLineKind::Context => ' ',
ReviewLineKind::Added => '+',
ReviewLineKind::Removed => '-',
};
format!("{marker}{}", line.content)
}));
lines
}
fn resolve_repo_root(repo_path: &Path) -> Result<PathBuf, ReviewError> {
if !repo_path.is_absolute() {
return Err(ReviewError::InvalidRequest(format!(
"repo_path must be absolute, but received: {}",
repo_path.display()
)));
}
if !repo_path.is_dir() {
return Err(ReviewError::InvalidRequest(format!(
"repo_path is not a directory: {}",
display_from_current_dir(repo_path)
)));
}
let repo_root = git_output(repo_path, &["rev-parse", "--show-toplevel"])?;
Ok(PathBuf::from(repo_root.trim()))
}
fn repository_file_get(
request: &RepositoryFileRequest,
) -> Result<RepositoryFileResponse, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let relative_path = Path::new(&request.file_path);
if relative_path.is_absolute()
|| relative_path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Err(ReviewError::InvalidRequest(
"file_path must be repository-relative".to_string(),
));
}
let path = repo_root.join(relative_path);
let metadata = std::fs::metadata(&path)?;
let size_bytes = metadata.len();
let mtime_ms = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX));
if size_bytes > MAX_REPOSITORY_FILE_BYTES {
return Ok(RepositoryFileResponse {
file_path: request.file_path.clone(),
content: None,
size_bytes,
mtime_ms,
is_binary: false,
unavailable_reason: Some(format!(
"file is larger than {MAX_REPOSITORY_FILE_BYTES} bytes"
)),
});
}
let bytes = std::fs::read(&path)?;
let is_binary = bytes.contains(&0);
if is_binary {
return Ok(RepositoryFileResponse {
file_path: request.file_path.clone(),
content: None,
size_bytes,
mtime_ms,
is_binary: true,
unavailable_reason: Some("binary file".to_string()),
});
}
let content = String::from_utf8(bytes).map_err(|error| {
ReviewError::InvalidRequest(format!("file is not valid UTF-8: {error}"))
})?;
Ok(RepositoryFileResponse {
file_path: request.file_path.clone(),
content: Some(content),
size_bytes,
mtime_ms,
is_binary: false,
unavailable_reason: None,
})
}
fn create_review_summary(request: &CreateReviewRequest) -> Result<ReviewSummary, ReviewError> {
let repo_root = resolve_repo_root(&request.repo_path)?;
let files = if matches!(request.target, ReviewTarget::Repository) {
repository_review_files(&repo_root)?
} else {
let diff = diff_for_target(&repo_root, &request.target)?;
parse_unified_diff(&diff)?
};
let additions = files.iter().map(|file| file.additions).sum();
let deletions = files.iter().map(|file| file.deletions).sum();
Ok(ReviewSummary {
title: target_title(&request.target),
repo_root,
files,
additions,
deletions,
})
}
struct CodeReviewDb<'a> {
db: &'a dyn Database,
}
impl<'a> CodeReviewDb<'a> {
const fn new(db: &'a dyn Database) -> Self {
Self { db }
}
async fn list_workspaces(
&self,
repo_root: &Path,
include_archived: bool,
) -> Result<Vec<ReviewWorkspace>, ReviewError> {
let rows = self
.db
.select("review_workspaces")
.columns(&[
"workspace_id",
"repo_root",
"title",
"sources_json",
"created_at_ms",
"updated_at_ms",
"archived_at_ms",
"viewed_files_json",
"presentation_state_json",
])
.filter(Box::new(where_eq(
"repo_root",
repo_root.display().to_string(),
)))
.execute(self.db)
.await?;
let mut workspaces = Vec::new();
for row in rows {
let archived_at_ms = optional_i64(&row, "archived_at_ms").map(i64_to_u64);
if archived_at_ms.is_some() && !include_archived {
continue;
}
workspaces.push(workspace_from_row(&row)?);
}
workspaces.sort_by_key(|workspace| std::cmp::Reverse(workspace.updated_at_ms));
Ok(workspaces)
}
async fn get_workspace(
&self,
workspace_id: &str,
) -> Result<Option<ReviewWorkspace>, ReviewError> {
let Some(row) = self
.db
.select("review_workspaces")
.columns(&[
"workspace_id",
"repo_root",
"title",
"sources_json",
"created_at_ms",
"updated_at_ms",
"archived_at_ms",
"viewed_files_json",
"presentation_state_json",
])
.filter(Box::new(where_eq("workspace_id", workspace_id)))
.execute_first(self.db)
.await?
else {
return Ok(None);
};
Ok(Some(workspace_from_row(&row)?))
}
async fn save_workspace(&self, workspace: &ReviewWorkspace) -> Result<(), ReviewError> {
let sources_json = serde_json::to_string(&workspace.sources)?;
let viewed_files_json = serde_json::to_string(&workspace.viewed_files)?;
let presentation_state_json = workspace
.presentation_state
.as_ref()
.map(serde_json::to_string)
.transpose()?;
if self.get_workspace(&workspace.id).await?.is_some() {
self.db
.update("review_workspaces")
.value("repo_root", workspace.repo_root.display().to_string())
.value("title", workspace.title.clone())
.value("sources_json", sources_json)
.value("viewed_files_json", viewed_files_json.clone())
.value("presentation_state_json", presentation_state_json.clone())
.value(
"updated_at_ms",
u64_to_i64(workspace.updated_at_ms.unwrap_or_else(now_ms)),
)
.value("archived_at_ms", optional_u64(workspace.archived_at_ms))
.filter(Box::new(where_eq("workspace_id", workspace.id.clone())))
.execute(self.db)
.await?;
return Ok(());
}
self.db
.insert("review_workspaces")
.value("workspace_id", workspace.id.clone())
.value("repo_root", workspace.repo_root.display().to_string())
.value("title", workspace.title.clone())
.value("sources_json", sources_json)
.value("viewed_files_json", viewed_files_json)
.value("presentation_state_json", presentation_state_json)
.value(
"created_at_ms",
u64_to_i64(workspace.created_at_ms.unwrap_or_else(now_ms)),
)
.value(
"updated_at_ms",
u64_to_i64(workspace.updated_at_ms.unwrap_or_else(now_ms)),
)
.value("archived_at_ms", optional_u64(workspace.archived_at_ms))
.execute(self.db)
.await?;
Ok(())
}
async fn save_external_import(
&self,
record: &ExternalReviewImportRecord,
) -> Result<(), ReviewError> {
let threads_json = serde_json::to_string(&record.threads)?;
let warnings_json = serde_json::to_string(&record.warnings)?;
let existing = self
.db
.select("review_external_imports")
.columns(&["workspace_id"])
.filter(Box::new(where_eq(
"workspace_id",
record.workspace_id.clone(),
)))
.filter(Box::new(where_eq(
"importer_id",
record.importer_id.clone(),
)))
.execute_first(self.db)
.await?;
if existing.is_some() {
self.db
.update("review_external_imports")
.value("threads_json", threads_json)
.value("warnings_json", warnings_json)
.value("updated_at_ms", u64_to_i64(record.updated_at_ms))
.filter(Box::new(where_eq(
"workspace_id",
record.workspace_id.clone(),
)))
.filter(Box::new(where_eq(
"importer_id",
record.importer_id.clone(),
)))
.execute(self.db)
.await?;
return Ok(());
}
self.db
.insert("review_external_imports")
.value("workspace_id", record.workspace_id.clone())
.value("importer_id", record.importer_id.clone())
.value("threads_json", threads_json)
.value("warnings_json", warnings_json)
.value("updated_at_ms", u64_to_i64(record.updated_at_ms))
.execute(self.db)
.await?;
Ok(())
}
async fn list_external_imports(
&self,
workspace_id: &str,
) -> Result<Vec<ExternalReviewImportRecord>, ReviewError> {
let rows = self
.db
.select("review_external_imports")
.columns(&[
"workspace_id",
"importer_id",
"threads_json",
"warnings_json",
"updated_at_ms",
])
.filter(Box::new(where_eq("workspace_id", workspace_id)))
.execute(self.db)
.await?;
rows.iter().map(external_import_from_row).collect()
}
async fn save_publish_record(&self, record: &ReviewPublishRecord) -> Result<(), ReviewError> {
self.db
.insert("review_publish_records")
.value("record_id", record.id.clone())
.value("workspace_id", record.workspace_id.clone())
.value("review_id", record.review_id.clone())
.value("publisher_id", record.publisher_id.clone())
.value("submitted", i64::from(record.submitted))
.value(
"published_thread_anchors_json",
serde_json::to_string(&record.published_thread_anchors)?,
)
.value("output", record.output.clone())
.value("message", record.message.clone())
.value("created_at_ms", u64_to_i64(record.created_at_ms))
.execute(self.db)
.await?;
Ok(())
}
async fn list_publish_records(
&self,
workspace_id: &str,
) -> Result<Vec<ReviewPublishRecord>, ReviewError> {
let rows = self
.db
.select("review_publish_records")
.columns(&[
"record_id",
"workspace_id",
"review_id",
"publisher_id",
"submitted",
"published_thread_anchors_json",
"output",
"message",
"created_at_ms",
])
.filter(Box::new(where_eq("workspace_id", workspace_id.to_string())))
.execute(self.db)
.await?;
let mut records = rows
.iter()
.map(publish_record_from_row)
.collect::<Result<Vec<_>, _>>()?;
records.sort_by_key(|record| record.created_at_ms);
Ok(records)
}
async fn last_publish_record(
&self,
workspace_id: Option<&str>,
) -> Result<Option<ReviewPublishRecord>, ReviewError> {
let Some(workspace_id) = workspace_id else {
return Ok(None);
};
let rows = self
.db
.select("review_publish_records")
.columns(&[
"record_id",
"workspace_id",
"review_id",
"publisher_id",
"submitted",
"published_thread_anchors_json",
"output",
"message",
"created_at_ms",
])
.filter(Box::new(where_eq("workspace_id", workspace_id.to_string())))
.execute(self.db)
.await?;
rows.iter()
.max_by_key(|row| required_i64(row, "created_at_ms").unwrap_or(0))
.map(publish_record_from_row)
.transpose()
}
async fn archive_workspace(&self, workspace_id: &str, now: u64) -> Result<bool, ReviewError> {
if self.get_workspace(workspace_id).await?.is_none() {
return Ok(false);
}
self.db
.update("review_workspaces")
.value("archived_at_ms", u64_to_i64(now))
.value("updated_at_ms", u64_to_i64(now))
.filter(Box::new(where_eq("workspace_id", workspace_id.to_string())))
.execute(self.db)
.await?;
Ok(true)
}
async fn list_ai_exchanges(
&self,
review_key: &str,
) -> Result<Vec<ReviewAiExchange>, ReviewError> {
let rows = self
.db
.select("review_ai_exchanges")
.columns(&[
"schema_version",
"exchange_id",
"anchor_json",
"question",
"session_id",
"status",
"error",
"created_at_ms",
"updated_at_ms",
])
.filter(Box::new(where_eq("review_key", review_key)))
.execute(self.db)
.await?;
let mut exchanges = rows
.iter()
.map(ai_exchange_from_row)
.collect::<Result<Vec<_>, _>>()?;
exchanges.sort_by_key(|exchange| (exchange.created_at_ms, exchange.exchange_id.clone()));
Ok(exchanges)
}
async fn delete_ai_exchange(
&self,
review_key: &str,
exchange_id: &str,
) -> Result<bool, ReviewError> {
let existing = self
.db
.select("review_ai_exchanges")
.columns(&["exchange_id"])
.filter(Box::new(where_eq("review_key", review_key.to_string())))
.filter(Box::new(where_eq("exchange_id", exchange_id.to_string())))
.execute_first(self.db)
.await?;
if existing.is_none() {
return Ok(false);
}
self.db
.delete("review_ai_exchanges")
.filter(Box::new(where_eq("review_key", review_key.to_string())))
.filter(Box::new(where_eq("exchange_id", exchange_id.to_string())))
.execute(self.db)
.await?;
Ok(true)
}
async fn save_ai_exchange(
&self,
review_key: &str,
repo_root: &Path,
target_kind: &str,
target_json: &str,
mut exchange: ReviewAiExchange,
) -> Result<ReviewAiExchange, ReviewError> {
let now = now_ms();
self.ensure_review(review_key, repo_root, target_kind, target_json, now)
.await?;
let existing = self
.db
.select("review_ai_exchanges")
.columns(&["exchange_id", "created_at_ms"])
.filter(Box::new(where_eq(
"exchange_id",
exchange.exchange_id.clone(),
)))
.execute_first(self.db)
.await?;
exchange.updated_at_ms = now;
if let Some(row) = existing {
exchange.created_at_ms = i64_to_u64(required_i64(&row, "created_at_ms")?);
self.db
.update("review_ai_exchanges")
.value("schema_version", i64::from(exchange.schema_version))
.value("anchor_json", serde_json::to_string(&exchange.anchor)?)
.value("question", exchange.question.clone())
.value("session_id", exchange.session_id.clone())
.value("status", ai_exchange_status_to_str(exchange.status))
.value("error", exchange.error.clone())
.value("updated_at_ms", u64_to_i64(now))
.filter(Box::new(where_eq(
"exchange_id",
exchange.exchange_id.clone(),
)))
.execute(self.db)
.await?;
} else {
exchange.created_at_ms = now;
self.db
.insert("review_ai_exchanges")
.value("schema_version", i64::from(exchange.schema_version))
.value("exchange_id", exchange.exchange_id.clone())
.value("review_key", review_key.to_string())
.value("anchor_json", serde_json::to_string(&exchange.anchor)?)
.value("question", exchange.question.clone())
.value("session_id", exchange.session_id.clone())
.value("status", ai_exchange_status_to_str(exchange.status))
.value("error", exchange.error.clone())
.value("created_at_ms", u64_to_i64(now))
.value("updated_at_ms", u64_to_i64(now))
.execute(self.db)
.await?;
}
Ok(exchange)
}
async fn list_suggestions(
&self,
review_key: &str,
) -> Result<Vec<ReviewSuggestion>, ReviewError> {
let rows = self
.db
.select("review_suggestions")
.columns(&[
"suggestion_id",
"anchor_json",
"body",
"session_id",
"status",
"rationale",
"created_at_ms",
"updated_at_ms",
])
.filter(Box::new(where_eq("review_key", review_key)))
.execute(self.db)
.await?;
rows.iter().map(suggestion_from_row).collect()
}
async fn save_suggestion(
&self,
review_key: &str,
repo_root: &Path,
target_kind: &str,
target_json: &str,
mut suggestion: ReviewSuggestion,
) -> Result<ReviewSuggestion, ReviewError> {
let now = now_ms();
self.ensure_review(review_key, repo_root, target_kind, target_json, now)
.await?;
let existing = self
.db
.select("review_suggestions")
.columns(&["suggestion_id", "created_at_ms"])
.filter(Box::new(where_eq(
"suggestion_id",
suggestion.suggestion_id.clone(),
)))
.execute_first(self.db)
.await?;
suggestion.updated_at_ms = now;
if let Some(row) = existing {
suggestion.created_at_ms = i64_to_u64(required_i64(&row, "created_at_ms")?);
self.db
.update("review_suggestions")
.value("anchor_json", serde_json::to_string(&suggestion.anchor)?)
.value("body", suggestion.body.clone())
.value("session_id", suggestion.session_id.clone())
.value("status", suggestion_status_to_str(suggestion.status))
.value("rationale", suggestion.rationale.clone())
.value("updated_at_ms", u64_to_i64(now))
.filter(Box::new(where_eq(
"suggestion_id",
suggestion.suggestion_id.clone(),
)))
.execute(self.db)
.await?;
} else {
suggestion.created_at_ms = now;
self.db
.insert("review_suggestions")
.value("suggestion_id", suggestion.suggestion_id.clone())
.value("review_key", review_key.to_string())
.value("anchor_json", serde_json::to_string(&suggestion.anchor)?)
.value("body", suggestion.body.clone())
.value("session_id", suggestion.session_id.clone())
.value("status", suggestion_status_to_str(suggestion.status))
.value("rationale", suggestion.rationale.clone())
.value("created_at_ms", u64_to_i64(now))
.value("updated_at_ms", u64_to_i64(now))
.execute(self.db)
.await?;
}
Ok(suggestion)
}
async fn list_drafts(&self, review_key: &str) -> Result<Vec<DraftComment>, ReviewError> {
let thread_rows = self
.db
.select("draft_threads")
.columns(&[
"thread_id",
"anchor_kind",
"session_id",
"file_path",
"diff_row",
"start_diff_row",
"end_diff_row",
"old_start",
"old_end",
"new_start",
"new_end",
"old_line",
"new_line",
"line_kind",
"thread_kind",
"severity",
"resolved_at_ms",
"is_file_anchor",
"surface_id",
"source_id",
])
.filter(Box::new(where_eq("review_key", review_key)))
.execute(self.db)
.await?;
let mut drafts = Vec::new();
for thread in thread_rows {
let thread_id = required_text(&thread, "thread_id")?;
let session_id = optional_text(&thread, "session_id");
let thread_kind = thread_kind_from_str(
optional_text(&thread, "thread_kind")
.as_deref()
.unwrap_or("note"),
)?;
let severity = thread_severity_from_str(
optional_text(&thread, "severity")
.as_deref()
.unwrap_or("info"),
)?;
let resolved_at_ms = optional_i64(&thread, "resolved_at_ms").map(i64_to_u64);
let anchor = DraftAnchor {
kind: anchor_kind_from_str(
optional_text(&thread, "anchor_kind")
.as_deref()
.unwrap_or("range"),
)?,
file_path: required_text(&thread, "file_path")?,
diff_row: i64_to_u64(required_i64(&thread, "diff_row")?),
start_diff_row: optional_i64(&thread, "start_diff_row").map(i64_to_u64),
end_diff_row: optional_i64(&thread, "end_diff_row").map(i64_to_u64),
old_start: optional_i64(&thread, "old_start").map(i64_to_u32),
old_end: optional_i64(&thread, "old_end").map(i64_to_u32),
new_start: optional_i64(&thread, "new_start").map(i64_to_u32),
new_end: optional_i64(&thread, "new_end").map(i64_to_u32),
old_line: optional_i64(&thread, "old_line").map(i64_to_u32),
new_line: optional_i64(&thread, "new_line").map(i64_to_u32),
line_kind: line_kind_from_str(&required_text(&thread, "line_kind")?)?,
is_file_anchor: optional_bool(&thread, "is_file_anchor"),
surface_id: optional_text(&thread, "surface_id"),
source_id: optional_text(&thread, "source_id"),
};
let comment_rows = self
.db
.select("draft_comments")
.columns(&["comment_id", "body", "created_at_ms", "updated_at_ms"])
.filter(Box::new(where_eq("thread_id", thread_id.clone())))
.execute(self.db)
.await?;
drafts.extend(
comment_rows
.into_iter()
.map(|row| {
comment_from_row(
&row,
&thread_id,
&anchor,
session_id.clone(),
resolved_at_ms,
thread_kind,
severity,
)
})
.collect::<Result<Vec<_>, _>>()?,
);
}
Ok(drafts)
}
async fn draft_counts(&self, review_key: &str) -> Result<(usize, usize), ReviewError> {
let thread_rows = self
.db
.select("draft_threads")
.columns(&["thread_id"])
.filter(Box::new(where_eq("review_key", review_key)))
.execute(self.db)
.await?;
let thread_count = thread_rows.len();
let mut draft_count = 0usize;
for thread in thread_rows {
let thread_id = required_text(&thread, "thread_id")?;
draft_count = draft_count.saturating_add(
self.db
.select("draft_comments")
.columns(&["comment_id"])
.filter(Box::new(where_eq("thread_id", thread_id)))
.execute(self.db)
.await?
.len(),
);
}
Ok((thread_count, draft_count))
}
async fn save_draft(
&self,
review_key: &str,
repo_root: &Path,
target_kind: &str,
target_json: &str,
anchor: DraftAnchor,
body: &str,
) -> Result<DraftComment, ReviewError> {
let now = now_ms();
let thread_id = thread_id(review_key, &anchor)?;
let comment_id = comment_id(&thread_id, body, now);
self.ensure_review(review_key, repo_root, target_kind, target_json, now)
.await?;
self.ensure_thread(review_key, &thread_id, &anchor, now)
.await?;
self.db
.insert("draft_comments")
.value("comment_id", comment_id.clone())
.value("thread_id", thread_id.clone())
.value("body", body.to_string())
.value("created_at_ms", u64_to_i64(now))
.value("updated_at_ms", u64_to_i64(now))
.execute(self.db)
.await?;
Ok(DraftComment {
comment_id,
thread_id,
anchor,
body: body.to_string(),
created_at_ms: now,
updated_at_ms: now,
session_id: None,
resolved_at_ms: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
})
}
async fn delete_draft(&self, comment_id: &str) -> Result<bool, ReviewError> {
let Some(row) = self
.db
.select("draft_comments")
.columns(&["thread_id"])
.filter(Box::new(where_eq("comment_id", comment_id)))
.execute_first(self.db)
.await?
else {
return Ok(false);
};
let thread_id = required_text(&row, "thread_id")?;
self.db
.delete("draft_comments")
.filter(Box::new(where_eq("comment_id", comment_id)))
.execute(self.db)
.await?;
let remaining = self
.db
.select("draft_comments")
.columns(&["comment_id"])
.filter(Box::new(where_eq("thread_id", thread_id.clone())))
.execute_first(self.db)
.await?;
if remaining.is_none() {
self.db
.delete("draft_threads")
.filter(Box::new(where_eq("thread_id", thread_id)))
.execute(self.db)
.await?;
}
Ok(true)
}
async fn link_thread_session(
&self,
review_key: &str,
repo_root: &Path,
target_kind: &str,
target_json: &str,
anchor: &DraftAnchor,
session_id: &str,
) -> Result<LinkThreadSessionResponse, ReviewError> {
let now = now_ms();
let thread_id = thread_id(review_key, anchor)?;
self.ensure_review(review_key, repo_root, target_kind, target_json, now)
.await?;
self.ensure_thread(review_key, &thread_id, anchor, now)
.await?;
self.db
.update("draft_threads")
.value("session_id", session_id.to_string())
.value("updated_at_ms", u64_to_i64(now))
.filter(Box::new(where_eq("thread_id", thread_id.clone())))
.execute(self.db)
.await?;
Ok(LinkThreadSessionResponse { thread_id })
}
async fn update_draft(
&self,
comment_id: &str,
body: &str,
) -> Result<UpdateDraftResponse, ReviewError> {
let exists = self
.db
.select("draft_comments")
.columns(&["comment_id"])
.filter(Box::new(where_eq("comment_id", comment_id)))
.execute_first(self.db)
.await?
.is_some();
if !exists {
return Ok(UpdateDraftResponse {
updated: false,
updated_at_ms: None,
});
}
let now = now_ms();
self.db
.update("draft_comments")
.value("body", body.to_string())
.value("updated_at_ms", u64_to_i64(now))
.filter(Box::new(where_eq("comment_id", comment_id)))
.execute(self.db)
.await?;
Ok(UpdateDraftResponse {
updated: true,
updated_at_ms: Some(now),
})
}
async fn resolve_thread(
&self,
review_key: &str,
repo_root: &Path,
target_kind: &str,
target_json: &str,
anchor: &DraftAnchor,
resolved: bool,
) -> Result<ResolveThreadResponse, ReviewError> {
let now = now_ms();
let thread_id = thread_id(review_key, anchor)?;
self.ensure_review(review_key, repo_root, target_kind, target_json, now)
.await?;
self.ensure_thread(review_key, &thread_id, anchor, now)
.await?;
let resolved_at_ms = resolved.then_some(now);
self.db
.update("draft_threads")
.value("resolved_at_ms", resolved_at_ms.map(u64_to_i64))
.value("updated_at_ms", u64_to_i64(now))
.filter(Box::new(where_eq("thread_id", thread_id.clone())))
.execute(self.db)
.await?;
Ok(ResolveThreadResponse {
thread_id,
resolved_at_ms,
})
}
async fn ensure_review(
&self,
review_key: &str,
repo_root: &Path,
target_kind: &str,
target_json: &str,
now: u64,
) -> Result<(), ReviewError> {
if self
.db
.select("reviews")
.columns(&["review_key"])
.filter(Box::new(where_eq("review_key", review_key)))
.execute_first(self.db)
.await?
.is_some()
{
self.db
.update("reviews")
.value("updated_at_ms", u64_to_i64(now))
.filter(Box::new(where_eq("review_key", review_key)))
.execute(self.db)
.await?;
return Ok(());
}
self.db
.insert("reviews")
.value("review_key", review_key.to_string())
.value("repo_root", repo_root.display().to_string())
.value("target_kind", target_kind.to_string())
.value("target_json", target_json.to_string())
.value("created_at_ms", u64_to_i64(now))
.value("updated_at_ms", u64_to_i64(now))
.execute(self.db)
.await?;
Ok(())
}
async fn ensure_thread(
&self,
review_key: &str,
thread_id: &str,
anchor: &DraftAnchor,
now: u64,
) -> Result<(), ReviewError> {
if self
.db
.select("draft_threads")
.columns(&["thread_id"])
.filter(Box::new(where_eq("thread_id", thread_id)))
.execute_first(self.db)
.await?
.is_some()
{
self.db
.update("draft_threads")
.value("updated_at_ms", u64_to_i64(now))
.filter(Box::new(where_eq("thread_id", thread_id)))
.execute(self.db)
.await?;
return Ok(());
}
self.db
.insert("draft_threads")
.value("thread_id", thread_id.to_string())
.value("review_key", review_key.to_string())
.value("anchor_kind", anchor_kind_str(anchor.kind))
.value("file_path", anchor.file_path.clone())
.value("diff_row", u64_to_i64(anchor.diff_row))
.value("start_diff_row", optional_u64(anchor.start_diff_row))
.value("end_diff_row", optional_u64(anchor.end_diff_row))
.value("old_start", optional_u32(anchor.old_start))
.value("old_end", optional_u32(anchor.old_end))
.value("new_start", optional_u32(anchor.new_start))
.value("new_end", optional_u32(anchor.new_end))
.value("old_line", optional_u32(anchor.old_line))
.value("new_line", optional_u32(anchor.new_line))
.value("line_kind", line_kind_str(anchor.line_kind))
.value("thread_kind", thread_kind_str(ReviewThreadKind::Note))
.value("severity", thread_severity_str(ReviewThreadSeverity::Info))
.value("is_file_anchor", anchor.is_file_anchor)
.value("surface_id", anchor.surface_id.clone())
.value("source_id", anchor.source_id.clone())
.value("resolved_at_ms", Option::<i64>::None)
.value("created_at_ms", u64_to_i64(now))
.value("updated_at_ms", u64_to_i64(now))
.execute(self.db)
.await?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct StatePaths {
state_root: PathBuf,
database_path: PathBuf,
}
fn state_paths(repo_root: &Path, config: &CodeReviewPluginConfig) -> StatePaths {
let state_root = env::var_os(CODE_REVIEW_STATE_DIR_ENV)
.map_or_else(|| configured_state_root(repo_root, config), PathBuf::from);
let database_path = state_root.join(DATABASE_FILE_NAME);
StatePaths {
state_root,
database_path,
}
}
fn configured_state_root(repo_root: &Path, config: &CodeReviewPluginConfig) -> PathBuf {
if let Some(state_dir) = &config.state_dir {
if state_dir.is_absolute() {
return state_dir.clone();
}
return repo_root.join(state_dir);
}
match config.state_location {
CodeReviewStateLocation::User => {
bcode_config::default_state_dir().join(DEFAULT_STATE_SUBDIR)
}
CodeReviewStateLocation::Repo => repo_root.join(DEFAULT_REPO_STATE_ROOT),
}
}
fn with_database<T>(
repo_root: &Path,
config: &CodeReviewPluginConfig,
operation: impl for<'a> FnOnce(
&'a dyn Database,
) -> Pin<Box<dyn Future<Output = Result<T, ReviewError>> + 'a>>
+ Send
+ 'static,
) -> Result<T, ReviewError>
where
T: Send + 'static,
{
let paths = state_paths(repo_root, config);
std::fs::create_dir_all(&paths.state_root)?;
std::thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("current-thread Tokio runtime should build");
runtime.block_on(async {
let database = open_database(&paths.database_path).await?;
run_migrations(database.as_ref()).await?;
operation(database.as_ref()).await
})
})
.join()
.map_err(|_| ReviewError::InvalidRequest("database worker panicked".to_string()))?
}
async fn open_database(path: &Path) -> Result<Box<dyn Database>, ReviewError> {
let mut attempt = 0_u32;
let mut delay = DATABASE_OPEN_INITIAL_RETRY_DELAY;
loop {
match switchy::database_connection::builder()
.turso()
.with_path(path)
.with_busy_timeout(DATABASE_BUSY_TIMEOUT)
.with_multiprocess_wal(false)
.build()
.await
{
Ok(db) => return Ok(db),
Err(error)
if is_database_lock_error(&error) && attempt < DATABASE_OPEN_RETRY_ATTEMPTS =>
{
attempt = attempt.saturating_add(1);
tokio::time::sleep(delay).await;
delay = delay.saturating_mul(2).min(DATABASE_OPEN_MAX_RETRY_DELAY);
}
Err(error) => return Err(error.into()),
}
}
}
fn is_database_lock_error(error: &switchy::database_connection::InitTursoError) -> bool {
let message = error.to_string().to_ascii_lowercase();
message.contains("locking error")
|| message.contains("failed locking file")
|| message.contains("database is locked")
|| message.contains("busy")
}
async fn run_migrations(database: &dyn Database) -> Result<(), ReviewError> {
reconcile_legacy_code_review_migrations(database).await?;
let runner = MigrationRunner::new(Box::new(code_review_migrations()))
.with_table_name(MIGRATIONS_TABLE.to_string());
runner.run(database).await?;
Ok(())
}
async fn reconcile_legacy_code_review_migrations(
database: &dyn Database,
) -> Result<(), ReviewError> {
let has_draft_threads = database.table_exists("draft_threads").await?;
let has_review_workspaces = database.table_exists("review_workspaces").await?;
if !has_draft_threads && !has_review_workspaces {
return Ok(());
}
let tracker = VersionTracker::with_table_name(MIGRATIONS_TABLE.to_string());
tracker.ensure_table_exists(database).await?;
if has_draft_threads {
if database
.column_exists("draft_threads", "anchor_kind")
.await?
&& !tracker
.is_migration_applied(database, "011_thread_anchor_kind_column")
.await?
{
tracker
.record_migration(database, "011_thread_anchor_kind_column")
.await?;
}
if database
.column_exists("draft_threads", "thread_kind")
.await?
&& database.column_exists("draft_threads", "severity").await?
&& !tracker
.is_migration_applied(database, "012_thread_metadata_columns")
.await?
{
tracker
.record_migration(database, "012_thread_metadata_columns")
.await?;
}
if database
.column_exists("draft_threads", "start_diff_row")
.await?
&& !tracker
.is_migration_applied(database, "005_thread_range_columns")
.await?
{
tracker
.record_migration(database, "005_thread_range_columns")
.await?;
}
if database
.column_exists("draft_threads", "is_file_anchor")
.await?
&& !tracker
.is_migration_applied(database, "006_thread_file_anchor_column")
.await?
{
tracker
.record_migration(database, "006_thread_file_anchor_column")
.await?;
}
if database
.column_exists("draft_threads", "surface_id")
.await?
&& !tracker
.is_migration_applied(database, "007_thread_surface_anchor_columns")
.await?
{
tracker
.record_migration(database, "007_thread_surface_anchor_columns")
.await?;
}
}
if has_review_workspaces
&& database
.column_exists("review_workspaces", "viewed_files_json")
.await?
&& !tracker
.is_migration_applied(database, "008_workspace_viewed_files_column")
.await?
{
tracker
.record_migration(database, "008_workspace_viewed_files_column")
.await?;
}
Ok(())
}
fn thread_resolved_column_migration() -> CodeMigration<'static> {
CodeMigration::new(
"008_thread_resolved_column".to_string(),
Box::new(alter_table("draft_threads").add_column(
"resolved_at_ms".to_string(),
DataType::BigInt,
true,
None,
)),
None,
)
}
fn workspace_table_migration() -> CodeMigration<'static> {
CodeMigration::new(
"006_review_workspaces_table".to_string(),
Box::new(
create_table("review_workspaces")
.if_not_exists(true)
.column(text_column("workspace_id"))
.column(text_column("repo_root"))
.column(text_column("title"))
.column(text_column("sources_json"))
.column(int_column("created_at_ms"))
.column(int_column("updated_at_ms"))
.column(nullable_int_column("archived_at_ms"))
.primary_key("workspace_id"),
),
None,
)
}
fn workspace_viewed_files_column_migration() -> CodeMigration<'static> {
CodeMigration::new(
"008_workspace_viewed_files_column".to_string(),
Box::new(alter_table("review_workspaces").add_column(
"viewed_files_json".to_string(),
DataType::Text,
false,
Some(DatabaseValue::String("[]".to_string())),
)),
None,
)
}
fn publish_records_table_migration() -> CodeMigration<'static> {
CodeMigration::new(
"007_review_publish_records_table".to_string(),
Box::new(
create_table("review_publish_records")
.if_not_exists(true)
.column(text_column("record_id"))
.column(nullable_text_column("workspace_id"))
.column(text_column("review_id"))
.column(text_column("publisher_id"))
.column(int_column("submitted"))
.column(nullable_text_column("output"))
.column(text_column("message"))
.column(int_column("created_at_ms"))
.primary_key("record_id"),
),
None,
)
}
fn thread_anchor_kind_column_migration() -> CodeMigration<'static> {
CodeMigration::new(
"011_thread_anchor_kind_column".to_string(),
Box::new(alter_table("draft_threads").add_column(
"anchor_kind".to_string(),
DataType::Text,
false,
Some(DatabaseValue::String("range".to_string())),
)),
None,
)
}
fn thread_metadata_columns_migration() -> CodeMigration<'static> {
CodeMigration::new(
"012_thread_metadata_columns".to_string(),
Box::new(
alter_table("draft_threads")
.add_column(
"thread_kind".to_string(),
DataType::Text,
false,
Some(DatabaseValue::String("note".to_string())),
)
.add_column(
"severity".to_string(),
DataType::Text,
false,
Some(DatabaseValue::String("info".to_string())),
),
),
None,
)
}
fn code_review_migrations() -> CodeMigrationSource<'static> {
let mut source = CodeMigrationSource::new();
source.add_migration(CodeMigration::new(
"001_reviews_table".to_string(),
Box::new(
create_table("reviews")
.if_not_exists(true)
.column(text_column("review_key"))
.column(text_column("repo_root"))
.column(text_column("target_kind"))
.column(text_column("target_json"))
.column(int_column("created_at_ms"))
.column(int_column("updated_at_ms"))
.primary_key("review_key"),
),
None,
));
source.add_migration(CodeMigration::new(
"002_draft_threads_table".to_string(),
Box::new(
create_table("draft_threads")
.if_not_exists(true)
.column(text_column("thread_id"))
.column(text_column("review_key"))
.column(text_column("file_path"))
.column(int_column("diff_row"))
.column(nullable_int_column("old_line"))
.column(nullable_int_column("new_line"))
.column(text_column("line_kind"))
.column(int_column("created_at_ms"))
.column(int_column("updated_at_ms"))
.primary_key("thread_id"),
),
None,
));
source.add_migration(CodeMigration::new(
"003_draft_comments_table".to_string(),
Box::new(
create_table("draft_comments")
.if_not_exists(true)
.column(text_column("comment_id"))
.column(text_column("thread_id"))
.column(text_column("body"))
.column(int_column("created_at_ms"))
.column(int_column("updated_at_ms"))
.primary_key("comment_id"),
),
None,
));
source.add_migration(CodeMigration::new(
"004_thread_session_column".to_string(),
Box::new(alter_table("draft_threads").add_column(
"session_id".to_string(),
DataType::Text,
true,
None,
)),
None,
));
source.add_migration(CodeMigration::new(
"005_thread_range_columns".to_string(),
Box::new(
alter_table("draft_threads")
.add_column("start_diff_row".to_string(), DataType::BigInt, true, None)
.add_column("end_diff_row".to_string(), DataType::BigInt, true, None)
.add_column("old_start".to_string(), DataType::BigInt, true, None)
.add_column("old_end".to_string(), DataType::BigInt, true, None)
.add_column("new_start".to_string(), DataType::BigInt, true, None)
.add_column("new_end".to_string(), DataType::BigInt, true, None),
),
None,
));
source.add_migration(CodeMigration::new(
"006_thread_file_anchor_column".to_string(),
Box::new(alter_table("draft_threads").add_column(
"is_file_anchor".to_string(),
DataType::Bool,
false,
Some(DatabaseValue::Bool(false)),
)),
None,
));
source.add_migration(thread_surface_anchor_columns_migration());
source.add_migration(thread_resolved_column_migration());
source.add_migration(workspace_table_migration());
source.add_migration(workspace_viewed_files_column_migration());
source.add_migration(publish_records_table_migration());
source.add_migration(thread_anchor_kind_column_migration());
source.add_migration(thread_metadata_columns_migration());
source.add_migration(review_suggestions_table_migration());
source.add_migration(publish_record_thread_anchors_migration());
source.add_migration(external_review_imports_table_migration());
source.add_migration(review_ai_exchanges_table_migration());
source.add_migration(workspace_presentation_state_column_migration());
source
}
fn workspace_presentation_state_column_migration() -> CodeMigration<'static> {
CodeMigration::new(
"017_workspace_presentation_state_column".to_string(),
Box::new(alter_table("review_workspaces").add_column(
"presentation_state_json".to_string(),
DataType::Text,
true,
None,
)),
None,
)
}
fn review_ai_exchanges_table_migration() -> CodeMigration<'static> {
CodeMigration::new(
"016_review_ai_exchanges_table".to_string(),
Box::new(
create_table("review_ai_exchanges")
.if_not_exists(true)
.column(int_column("schema_version"))
.column(text_column("exchange_id"))
.column(text_column("review_key"))
.column(text_column("anchor_json"))
.column(text_column("question"))
.column(nullable_text_column("session_id"))
.column(text_column("status"))
.column(nullable_text_column("error"))
.column(int_column("created_at_ms"))
.column(int_column("updated_at_ms"))
.primary_key("exchange_id"),
),
None,
)
}
fn external_review_imports_table_migration() -> CodeMigration<'static> {
CodeMigration::new(
"015_external_review_imports_table".to_string(),
Box::new(
create_table("review_external_imports")
.if_not_exists(true)
.column(text_column("workspace_id"))
.column(text_column("importer_id"))
.column(text_column("threads_json"))
.column(text_column("warnings_json"))
.column(int_column("updated_at_ms"))
.primary_key("workspace_id")
.primary_key("importer_id"),
),
None,
)
}
fn publish_record_thread_anchors_migration() -> CodeMigration<'static> {
CodeMigration::new(
"014_publish_record_thread_anchors".to_string(),
Box::new(alter_table("review_publish_records").add_column(
"published_thread_anchors_json".to_string(),
DataType::Text,
false,
Some(DatabaseValue::String("[]".to_string())),
)),
None,
)
}
fn review_suggestions_table_migration() -> CodeMigration<'static> {
CodeMigration::new(
"013_review_suggestions_table".to_string(),
Box::new(
create_table("review_suggestions")
.if_not_exists(true)
.column(text_column("suggestion_id"))
.column(text_column("review_key"))
.column(text_column("anchor_json"))
.column(text_column("body"))
.column(nullable_text_column("session_id"))
.column(text_column("status"))
.column(nullable_text_column("rationale"))
.column(int_column("created_at_ms"))
.column(int_column("updated_at_ms"))
.primary_key("suggestion_id"),
),
None,
)
}
fn thread_surface_anchor_columns_migration() -> CodeMigration<'static> {
CodeMigration::new(
"007_thread_surface_anchor_columns".to_string(),
Box::new(
alter_table("draft_threads")
.add_column("surface_id".to_string(), DataType::Text, true, None)
.add_column("source_id".to_string(), DataType::Text, true, None),
),
None,
)
}
fn text_column(name: &str) -> Column {
Column {
name: name.to_string(),
nullable: false,
auto_increment: false,
data_type: DataType::Text,
default: None,
}
}
fn nullable_text_column(name: &str) -> Column {
Column {
name: name.to_string(),
nullable: true,
auto_increment: false,
data_type: DataType::Text,
default: None,
}
}
fn int_column(name: &str) -> Column {
Column {
name: name.to_string(),
nullable: false,
auto_increment: false,
data_type: DataType::BigInt,
default: None,
}
}
fn nullable_int_column(name: &str) -> Column {
Column {
name: name.to_string(),
nullable: true,
auto_increment: false,
data_type: DataType::BigInt,
default: None,
}
}
fn external_import_from_row(row: &Row) -> Result<ExternalReviewImportRecord, ReviewError> {
Ok(ExternalReviewImportRecord {
workspace_id: required_text(row, "workspace_id")?,
importer_id: required_text(row, "importer_id")?,
threads: serde_json::from_str(&required_text(row, "threads_json")?)?,
warnings: serde_json::from_str(&required_text(row, "warnings_json")?)?,
updated_at_ms: i64_to_u64(required_i64(row, "updated_at_ms")?),
})
}
fn publish_record_from_row(row: &Row) -> Result<ReviewPublishRecord, ReviewError> {
Ok(ReviewPublishRecord {
id: required_text(row, "record_id")?,
workspace_id: optional_text(row, "workspace_id"),
review_id: required_text(row, "review_id")?,
publisher_id: required_text(row, "publisher_id")?,
submitted: required_i64(row, "submitted")? != 0,
published_thread_anchors: optional_text(row, "published_thread_anchors_json")
.map_or_else(|| Ok(Vec::new()), |json| serde_json::from_str(&json))?,
output: optional_text(row, "output"),
message: required_text(row, "message")?,
created_at_ms: i64_to_u64(required_i64(row, "created_at_ms")?),
})
}
fn ai_exchange_from_row(row: &Row) -> Result<ReviewAiExchange, ReviewError> {
let schema_version = u16::try_from(required_i64(row, "schema_version")?).map_err(|_| {
ReviewError::InvalidRequest("review AI exchange schema version is out of range".to_string())
})?;
if schema_version != bcode_code_review_models::REVIEW_AI_EXCHANGE_SCHEMA_VERSION {
return Err(ReviewError::InvalidRequest(format!(
"unsupported review AI exchange schema version: {schema_version}"
)));
}
Ok(ReviewAiExchange {
schema_version,
exchange_id: required_text(row, "exchange_id")?,
anchor: serde_json::from_str(&required_text(row, "anchor_json")?)?,
question: required_text(row, "question")?,
session_id: optional_text(row, "session_id"),
status: ai_exchange_status_from_str(&required_text(row, "status")?)?,
error: optional_text(row, "error"),
created_at_ms: i64_to_u64(required_i64(row, "created_at_ms")?),
updated_at_ms: i64_to_u64(required_i64(row, "updated_at_ms")?),
})
}
const fn ai_exchange_status_to_str(status: ReviewAiExchangeStatus) -> &'static str {
match status {
ReviewAiExchangeStatus::Pending => "pending",
ReviewAiExchangeStatus::Linked => "linked",
ReviewAiExchangeStatus::Failed => "failed",
}
}
fn ai_exchange_status_from_str(value: &str) -> Result<ReviewAiExchangeStatus, ReviewError> {
match value {
"pending" => Ok(ReviewAiExchangeStatus::Pending),
"linked" => Ok(ReviewAiExchangeStatus::Linked),
"failed" => Ok(ReviewAiExchangeStatus::Failed),
_ => Err(ReviewError::InvalidRequest(format!(
"unsupported review AI exchange status: {value}"
))),
}
}
fn suggestion_from_row(row: &Row) -> Result<ReviewSuggestion, ReviewError> {
Ok(ReviewSuggestion {
suggestion_id: required_text(row, "suggestion_id")?,
anchor: serde_json::from_str(&required_text(row, "anchor_json")?)?,
body: required_text(row, "body")?,
session_id: optional_text(row, "session_id"),
status: suggestion_status_from_str(&required_text(row, "status")?)?,
rationale: optional_text(row, "rationale"),
created_at_ms: i64_to_u64(required_i64(row, "created_at_ms")?),
updated_at_ms: i64_to_u64(required_i64(row, "updated_at_ms")?),
})
}
const fn suggestion_status_to_str(status: ReviewSuggestionStatus) -> &'static str {
match status {
ReviewSuggestionStatus::Suggested => "suggested",
ReviewSuggestionStatus::Refining => "refining",
ReviewSuggestionStatus::Accepted => "accepted",
ReviewSuggestionStatus::Rejected => "rejected",
}
}
fn suggestion_status_from_str(value: &str) -> Result<ReviewSuggestionStatus, ReviewError> {
match value {
"suggested" => Ok(ReviewSuggestionStatus::Suggested),
"refining" => Ok(ReviewSuggestionStatus::Refining),
"accepted" => Ok(ReviewSuggestionStatus::Accepted),
"rejected" => Ok(ReviewSuggestionStatus::Rejected),
_ => Err(ReviewError::InvalidRequest(format!(
"unsupported review suggestion status: {value}"
))),
}
}
fn workspace_from_row(row: &Row) -> Result<ReviewWorkspace, ReviewError> {
let sources: Vec<ReviewSource> = serde_json::from_str(&required_text(row, "sources_json")?)?;
let viewed_files = optional_text(row, "viewed_files_json")
.map_or_else(|| Ok(BTreeSet::new()), |json| serde_json::from_str(&json))?;
Ok(ReviewWorkspace {
id: required_text(row, "workspace_id")?,
title: required_text(row, "title")?,
repo_root: PathBuf::from(required_text(row, "repo_root")?),
sources,
created_at_ms: Some(i64_to_u64(required_i64(row, "created_at_ms")?)),
updated_at_ms: Some(i64_to_u64(required_i64(row, "updated_at_ms")?)),
viewed_files,
archived_at_ms: optional_i64(row, "archived_at_ms").map(i64_to_u64),
presentation_state: optional_text(row, "presentation_state_json")
.map_or_else(|| Ok(None), |json| serde_json::from_str(&json).map(Some))?,
})
}
fn comment_from_row(
row: &Row,
thread_id: &str,
anchor: &DraftAnchor,
session_id: Option<String>,
resolved_at_ms: Option<u64>,
thread_kind: ReviewThreadKind,
severity: ReviewThreadSeverity,
) -> Result<DraftComment, ReviewError> {
Ok(DraftComment {
comment_id: required_text(row, "comment_id")?,
thread_id: thread_id.to_string(),
anchor: anchor.clone(),
body: required_text(row, "body")?,
created_at_ms: i64_to_u64(required_i64(row, "created_at_ms")?),
updated_at_ms: i64_to_u64(required_i64(row, "updated_at_ms")?),
session_id,
resolved_at_ms,
thread_kind,
severity,
})
}
fn required_text(row: &Row, column: &'static str) -> Result<String, ReviewError> {
row.get(column)
.and_then(|value| value.as_str().map(ToString::to_string))
.ok_or(ReviewError::MissingColumn(column))
}
fn optional_text(row: &Row, column: &'static str) -> Option<String> {
row.get(column)
.and_then(|value| value.as_str().map(ToString::to_string))
}
fn required_i64(row: &Row, column: &'static str) -> Result<i64, ReviewError> {
row.get(column)
.and_then(|value| value.as_i64())
.ok_or(ReviewError::MissingColumn(column))
}
fn optional_i64(row: &Row, column: &'static str) -> Option<i64> {
row.get(column).and_then(|value| value.as_i64())
}
fn optional_bool(row: &Row, column: &'static str) -> bool {
row.get(column)
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
fn surface_id(source_id: &str, path: &str, kind: ReviewSurfaceKind) -> String {
let mut hasher = Sha256::new();
hasher.update(source_id.as_bytes());
hasher.update(b"\0");
hasher.update(path.as_bytes());
hasher.update(b"\0");
hasher.update(format!("{kind:?}").as_bytes());
format!("surface-{}", hex::encode(hasher.finalize()))
}
fn workspace_id(repo_root: &Path, title: &str, now: u64) -> String {
let mut hasher = Sha256::new();
hasher.update(repo_root.display().to_string().as_bytes());
hasher.update(b"\0");
hasher.update(title.as_bytes());
hasher.update(b"\0");
hasher.update(now.to_string().as_bytes());
format!("workspace-{}", hex::encode(hasher.finalize()))
}
fn workspace_review_key(workspace: &ReviewWorkspace) -> String {
format!("review-workspace-{}", workspace.id)
}
fn review_key_for_scope(
repo_root: &Path,
target: &ReviewTarget,
scope: Option<&ReviewScope>,
) -> Result<String, ReviewError> {
match scope {
Some(ReviewScope::Workspace { workspace_id, .. }) => {
Ok(format!("review-workspace-{workspace_id}"))
}
Some(ReviewScope::Target { target }) => review_key(repo_root, target),
None => review_key(repo_root, target),
}
}
fn review_key(repo_root: &Path, target: &ReviewTarget) -> Result<String, ReviewError> {
let mut hasher = Sha256::new();
hasher.update(repo_root.display().to_string().as_bytes());
hasher.update(b"\0");
hasher.update(serde_json::to_string(target)?.as_bytes());
Ok(format!("review-{}", hex::encode(hasher.finalize())))
}
fn thread_id(review_key: &str, anchor: &DraftAnchor) -> Result<String, ReviewError> {
let mut hasher = Sha256::new();
hasher.update(review_key.as_bytes());
hasher.update(b"\0");
hasher.update(serde_json::to_string(anchor)?.as_bytes());
Ok(format!("thread-{}", hex::encode(hasher.finalize())))
}
fn comment_id(thread_id: &str, body: &str, now: u64) -> String {
let mut hasher = Sha256::new();
hasher.update(thread_id.as_bytes());
hasher.update(b"\0");
hasher.update(body.as_bytes());
hasher.update(b"\0");
hasher.update(now.to_string().as_bytes());
format!("comment-{}", hex::encode(hasher.finalize()))
}
const fn target_kind(target: &ReviewTarget) -> &'static str {
match target {
ReviewTarget::WorkingTreeUnstaged => "working_tree_unstaged",
ReviewTarget::IndexStaged => "index_staged",
ReviewTarget::WorkingTreeAndIndex => "working_tree_and_index",
ReviewTarget::LastCommit => "last_commit",
ReviewTarget::CommitRange { .. } => "commit_range",
ReviewTarget::BranchCompare { .. } => "branch_compare",
ReviewTarget::Repository => "repository",
}
}
const fn thread_kind_str(kind: ReviewThreadKind) -> &'static str {
match kind {
ReviewThreadKind::Note => "note",
ReviewThreadKind::Question => "question",
ReviewThreadKind::Todo => "todo",
ReviewThreadKind::Finding => "finding",
ReviewThreadKind::Summary => "summary",
}
}
fn thread_kind_from_str(value: &str) -> Result<ReviewThreadKind, ReviewError> {
match value {
"note" => Ok(ReviewThreadKind::Note),
"question" => Ok(ReviewThreadKind::Question),
"todo" => Ok(ReviewThreadKind::Todo),
"finding" => Ok(ReviewThreadKind::Finding),
"summary" => Ok(ReviewThreadKind::Summary),
_ => Err(ReviewError::InvalidRequest(format!(
"unknown thread kind: {value}"
))),
}
}
const fn thread_severity_str(severity: ReviewThreadSeverity) -> &'static str {
match severity {
ReviewThreadSeverity::Info => "info",
ReviewThreadSeverity::Nit => "nit",
ReviewThreadSeverity::Warning => "warning",
ReviewThreadSeverity::Blocker => "blocker",
}
}
fn thread_severity_from_str(value: &str) -> Result<ReviewThreadSeverity, ReviewError> {
match value {
"info" => Ok(ReviewThreadSeverity::Info),
"nit" => Ok(ReviewThreadSeverity::Nit),
"warning" => Ok(ReviewThreadSeverity::Warning),
"blocker" => Ok(ReviewThreadSeverity::Blocker),
_ => Err(ReviewError::InvalidRequest(format!(
"unknown thread severity: {value}"
))),
}
}
const fn anchor_kind_str(kind: ReviewAnchorKind) -> &'static str {
match kind {
ReviewAnchorKind::Review => "review",
ReviewAnchorKind::File => "file",
ReviewAnchorKind::Range => "range",
}
}
fn anchor_kind_from_str(value: &str) -> Result<ReviewAnchorKind, ReviewError> {
match value {
"review" => Ok(ReviewAnchorKind::Review),
"file" => Ok(ReviewAnchorKind::File),
"range" => Ok(ReviewAnchorKind::Range),
_ => Err(ReviewError::InvalidRequest(format!(
"unknown anchor kind: {value}"
))),
}
}
const fn line_kind_str(kind: ReviewLineKind) -> &'static str {
match kind {
ReviewLineKind::Context => "context",
ReviewLineKind::Added => "added",
ReviewLineKind::Removed => "removed",
}
}
fn line_kind_from_str(value: &str) -> Result<ReviewLineKind, ReviewError> {
match value {
"context" => Ok(ReviewLineKind::Context),
"added" => Ok(ReviewLineKind::Added),
"removed" => Ok(ReviewLineKind::Removed),
_ => Err(ReviewError::InvalidRequest(format!(
"unknown line kind: {value}"
))),
}
}
fn optional_u32(value: Option<u32>) -> DatabaseValue {
DatabaseValue::Int64Opt(value.map(i64::from))
}
fn optional_u64(value: Option<u64>) -> DatabaseValue {
DatabaseValue::Int64Opt(value.map(u64_to_i64))
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
})
}
fn u64_to_i64(value: u64) -> i64 {
i64::try_from(value).unwrap_or(i64::MAX)
}
fn i64_to_u64(value: i64) -> u64 {
u64::try_from(value).unwrap_or(0)
}
fn i64_to_u32(value: i64) -> u32 {
u32::try_from(value).unwrap_or(0)
}
fn repository_review_files(repo_root: &Path) -> Result<Vec<ReviewFile>, ReviewError> {
let output = git_output(repo_root, &["ls-files"])?;
Ok(output
.lines()
.filter(|line| !line.trim().is_empty())
.map(|path| ReviewFile {
old_path: None,
new_path: Some(path.to_string()),
status: ReviewFileStatus::Unknown,
additions: 0,
deletions: 0,
hunks: Vec::new(),
is_binary: false,
})
.collect())
}
fn repository_review_branches(repo_root: &Path) -> Result<Vec<String>, ReviewError> {
let output = git_output(
repo_root,
&[
"for-each-ref",
"--format=%(refname:short)",
"refs/heads",
"refs/remotes",
],
)?;
Ok(output
.lines()
.map(str::trim)
.filter(|branch| !branch.is_empty() && !branch.ends_with("/HEAD"))
.map(ToString::to_string)
.collect())
}
fn repository_review_commits(repo_root: &Path) -> Result<Vec<ReviewRepositoryCommit>, ReviewError> {
let output = git_output(
repo_root,
&["log", "--max-count=50", "--format=%H%x1f%h%x1f%s", "--all"],
)?;
Ok(output
.lines()
.filter_map(|line| {
let mut parts = line.split('\u{1f}');
let rev = parts.next()?.to_string();
let short_rev = parts.next()?.to_string();
let subject = parts.next().unwrap_or_default().to_string();
Some(ReviewRepositoryCommit {
rev,
short_rev,
subject,
})
})
.collect())
}
fn diff_for_target(repo_root: &Path, target: &ReviewTarget) -> Result<String, ReviewError> {
match target {
ReviewTarget::WorkingTreeUnstaged => git_output(repo_root, &["diff", "--find-renames"]),
ReviewTarget::IndexStaged => git_output(repo_root, &["diff", "--cached", "--find-renames"]),
ReviewTarget::WorkingTreeAndIndex => {
git_output(repo_root, &["diff", "HEAD", "--find-renames"])
}
ReviewTarget::LastCommit => {
git_output(repo_root, &["show", "--format=", "--find-renames", "HEAD"])
}
ReviewTarget::CommitRange {
base,
head,
merge_base,
} => git_output(
repo_root,
&[
"diff",
"--find-renames",
&range_spec(base, head, *merge_base),
],
),
ReviewTarget::BranchCompare {
base_branch,
head_branch,
merge_base,
} => git_output(
repo_root,
&[
"diff",
"--find-renames",
&range_spec(base_branch, head_branch, *merge_base),
],
),
ReviewTarget::Repository => Ok(String::new()),
}
}
fn target_title(target: &ReviewTarget) -> String {
match target {
ReviewTarget::WorkingTreeUnstaged => "Unstaged Changes".to_string(),
ReviewTarget::IndexStaged => "Staged Changes".to_string(),
ReviewTarget::WorkingTreeAndIndex => "Staged + Unstaged Changes".to_string(),
ReviewTarget::LastCommit => "Last Commit".to_string(),
ReviewTarget::CommitRange {
base,
head,
merge_base,
} => format!("{base}{}{head}", if *merge_base { "..." } else { ".." }),
ReviewTarget::BranchCompare {
base_branch,
head_branch,
merge_base,
} => format!(
"{base_branch}{}{head_branch}",
if *merge_base { "..." } else { ".." }
),
ReviewTarget::Repository => "Repository Review".to_string(),
}
}
fn range_spec(base: &str, head: &str, merge_base: bool) -> String {
format!("{base}{}{head}", if merge_base { "..." } else { ".." })
}
fn git_output(repo_root: &Path, args: &[&str]) -> Result<String, ReviewError> {
let output = Command::new("git")
.args(args)
.current_dir(repo_root)
.output()?;
if output.status.success() {
return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
}
Err(ReviewError::Git(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
))
}
fn parse_unified_diff(diff: &str) -> Result<Vec<ReviewFile>, ReviewError> {
let mut files = Vec::new();
let mut current_file: Option<ReviewFile> = None;
let mut current_hunk: Option<ReviewHunk> = None;
let mut old_line = 0u32;
let mut new_line = 0u32;
for line in diff.lines() {
if line.starts_with("diff --git ") {
push_hunk(&mut current_file, current_hunk.take());
push_file(&mut files, current_file.take());
current_file = Some(file_from_diff_git_line(line));
continue;
}
let Some(file) = current_file.as_mut() else {
continue;
};
if line.starts_with("new file mode ") {
file.status = ReviewFileStatus::Added;
continue;
}
if line.starts_with("deleted file mode ") {
file.status = ReviewFileStatus::Deleted;
continue;
}
if line.starts_with("similarity index ") {
file.status = ReviewFileStatus::Renamed;
continue;
}
if let Some(path) = line.strip_prefix("rename from ") {
file.old_path = Some(path.to_string());
file.status = ReviewFileStatus::Renamed;
continue;
}
if let Some(path) = line.strip_prefix("rename to ") {
file.new_path = Some(path.to_string());
file.status = ReviewFileStatus::Renamed;
continue;
}
if line.starts_with("Binary files ") {
file.is_binary = true;
continue;
}
if let Some(path) = line.strip_prefix("--- ") {
if path != "/dev/null" {
file.old_path = Some(strip_diff_path_prefix(path).to_string());
}
continue;
}
if let Some(path) = line.strip_prefix("+++ ") {
if path != "/dev/null" {
file.new_path = Some(strip_diff_path_prefix(path).to_string());
}
continue;
}
if line.starts_with("@@ ") {
push_hunk(&mut current_file, current_hunk.take());
let hunk = parse_hunk_header(line)?;
old_line = hunk.old_start;
new_line = hunk.new_start;
current_hunk = Some(hunk);
continue;
}
let Some(hunk) = current_hunk.as_mut() else {
continue;
};
if let Some(content) = line.strip_prefix('+') {
hunk.lines.push(ReviewLine {
kind: ReviewLineKind::Added,
old_line: None,
new_line: Some(new_line),
content: content.to_string(),
});
file.additions = file.additions.saturating_add(1);
new_line = new_line.saturating_add(1);
} else if let Some(content) = line.strip_prefix('-') {
hunk.lines.push(ReviewLine {
kind: ReviewLineKind::Removed,
old_line: Some(old_line),
new_line: None,
content: content.to_string(),
});
file.deletions = file.deletions.saturating_add(1);
old_line = old_line.saturating_add(1);
} else if let Some(content) = line.strip_prefix(' ') {
hunk.lines.push(ReviewLine {
kind: ReviewLineKind::Context,
old_line: Some(old_line),
new_line: Some(new_line),
content: content.to_string(),
});
old_line = old_line.saturating_add(1);
new_line = new_line.saturating_add(1);
}
}
push_hunk(&mut current_file, current_hunk.take());
push_file(&mut files, current_file.take());
Ok(files)
}
fn file_from_diff_git_line(line: &str) -> ReviewFile {
let rest = line.strip_prefix("diff --git ").unwrap_or_default();
let mut parts = rest.split_whitespace();
let old_path = parts.next().map(strip_diff_path_prefix).map(str::to_string);
let new_path = parts.next().map(strip_diff_path_prefix).map(str::to_string);
ReviewFile {
old_path,
new_path,
status: ReviewFileStatus::Modified,
additions: 0,
deletions: 0,
hunks: Vec::new(),
is_binary: false,
}
}
fn parse_hunk_header(line: &str) -> Result<ReviewHunk, ReviewError> {
let Some(rest) = line.strip_prefix("@@ ") else {
return Err(ReviewError::Parse(format!("invalid hunk header: {line}")));
};
let Some((ranges, heading)) = rest.split_once(" @@") else {
return Err(ReviewError::Parse(format!("invalid hunk header: {line}")));
};
let mut ranges = ranges.split_whitespace();
let old_range = ranges
.next()
.ok_or_else(|| ReviewError::Parse(format!("missing old range: {line}")))?;
let new_range = ranges
.next()
.ok_or_else(|| ReviewError::Parse(format!("missing new range: {line}")))?;
let (old_start, old_count) = parse_hunk_range(old_range, '-')?;
let (new_start, new_count) = parse_hunk_range(new_range, '+')?;
let heading = heading.trim();
Ok(ReviewHunk {
old_start,
old_count,
new_start,
new_count,
heading: (!heading.is_empty()).then(|| heading.to_string()),
lines: Vec::new(),
})
}
fn parse_hunk_range(range: &str, prefix: char) -> Result<(u32, u32), ReviewError> {
let Some(range) = range.strip_prefix(prefix) else {
return Err(ReviewError::Parse(format!(
"hunk range missing '{prefix}' prefix: {range}"
)));
};
let (start, count) = range.split_once(',').unwrap_or((range, "1"));
let start = start
.parse::<u32>()
.map_err(|error| ReviewError::Parse(format!("invalid hunk start '{start}': {error}")))?;
let count = count
.parse::<u32>()
.map_err(|error| ReviewError::Parse(format!("invalid hunk count '{count}': {error}")))?;
Ok((start, count))
}
fn push_hunk(file: &mut Option<ReviewFile>, hunk: Option<ReviewHunk>) {
if let (Some(file), Some(hunk)) = (file, hunk) {
file.hunks.push(hunk);
}
}
fn push_file(files: &mut Vec<ReviewFile>, file: Option<ReviewFile>) {
if let Some(file) = file {
files.push(file);
}
}
fn strip_diff_path_prefix(path: &str) -> &str {
path.strip_prefix("a/")
.or_else(|| path.strip_prefix("b/"))
.unwrap_or(path)
}
fn json_response<T: Serialize>(value: &T) -> ServiceResponse {
match ServiceResponse::json(value) {
Ok(response) => response,
Err(error) => ServiceResponse::error("encode_failed", error.to_string()),
}
}
#[cfg(feature = "static-bundled")]
#[must_use]
pub fn static_plugin() -> bcode_plugin_sdk::StaticPluginVtable {
let mut vtable = bcode_plugin_sdk::static_plugin_vtable!(
CodeReviewPlugin,
include_str!("../bcode-plugin.toml")
);
vtable.cli_registration = Some(cli::registration);
vtable
}
#[cfg(not(feature = "static-bundled"))]
bcode_plugin_sdk::export_plugin!(CodeReviewPlugin, include_str!("../bcode-plugin.toml"));
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn materialization_reports_invalid_revision_and_missing_branch() {
let temp = tempfile::tempdir().expect("tempdir");
let repo = initialized_test_repository(temp.path());
let workspace = test_workspace(
&repo,
vec![
test_source(
"invalid-revision",
ReviewSourceKind::CommitRange {
base: "does-not-exist".to_string(),
head: "HEAD".to_string(),
merge_base: false,
},
),
test_source(
"missing-branch",
ReviewSourceKind::BranchCompare {
base_branch: "does-not-exist".to_string(),
head_branch: "HEAD".to_string(),
merge_base: true,
},
),
],
);
let response =
materialize_review_workspace_for_request(MaterializeReviewWorkspaceRequest {
repo_path: repo,
workspace,
})
.expect("materialization returns per-source diagnostics");
let diagnostics = response.materialization.diagnostics;
assert!(diagnostics.iter().any(|diagnostic| {
diagnostic.source_id == "invalid-revision"
&& diagnostic.code == "invalid_revision"
&& diagnostic.message.contains("Verify both revisions")
}));
assert!(diagnostics.iter().any(|diagnostic| {
diagnostic.source_id == "missing-branch"
&& diagnostic.code == "missing_branch"
&& diagnostic.message.contains("Verify both branches")
}));
}
#[test]
fn materialization_reports_empty_diff_without_failing_workspace() {
let temp = tempfile::tempdir().expect("tempdir");
let repo = initialized_test_repository(temp.path());
let workspace = test_workspace(
&repo,
vec![test_source(
"empty-unstaged",
ReviewSourceKind::WorkingTreeUnstaged,
)],
);
let response =
materialize_review_workspace_for_request(MaterializeReviewWorkspaceRequest {
repo_path: repo,
workspace,
})
.expect("empty diff is a successful materialization");
assert!(response.materialization.surfaces.is_empty());
assert!(
response
.materialization
.diagnostics
.iter()
.any(|diagnostic| {
diagnostic.source_id == "empty-unstaged"
&& diagnostic.code == "no_surfaces"
&& diagnostic.severity == ReviewSourceDiagnosticSeverity::Info
})
);
}
#[test]
fn multi_source_materialization_preserves_success_when_one_source_fails() {
let temp = tempfile::tempdir().expect("tempdir");
let repo = initialized_test_repository(temp.path());
let workspace = test_workspace(
&repo,
vec![
test_source("valid", ReviewSourceKind::LastCommit),
test_source(
"invalid",
ReviewSourceKind::BranchCompare {
base_branch: "does-not-exist".to_string(),
head_branch: "HEAD".to_string(),
merge_base: true,
},
),
],
);
let response =
materialize_review_workspace_for_request(MaterializeReviewWorkspaceRequest {
repo_path: repo,
workspace,
})
.expect("partial workspace materialization");
assert!(
response
.materialization
.surfaces
.iter()
.any(|surface| surface.source_id == "valid")
);
assert!(
response
.materialization
.diagnostics
.iter()
.any(|diagnostic| {
diagnostic.source_id == "invalid"
&& diagnostic.severity == ReviewSourceDiagnosticSeverity::Error
})
);
}
fn initialized_test_repository(repo: &Path) -> PathBuf {
git_test(repo, &["init", "--quiet"]);
git_test(repo, &["config", "user.email", "review@example.test"]);
git_test(repo, &["config", "user.name", "Review Test"]);
std::fs::write(repo.join("file.txt"), "base\n").expect("write base");
git_test(repo, &["add", "file.txt"]);
git_test(repo, &["commit", "--quiet", "-m", "base"]);
std::fs::write(repo.join("file.txt"), "base\nhead\n").expect("write head");
git_test(repo, &["add", "file.txt"]);
git_test(repo, &["commit", "--quiet", "-m", "head"]);
repo.to_path_buf()
}
fn test_workspace(repo: &Path, sources: Vec<ReviewSource>) -> ReviewWorkspace {
ReviewWorkspace {
id: "workspace".to_string(),
title: "Test review".to_string(),
repo_root: repo.to_path_buf(),
sources,
created_at_ms: None,
updated_at_ms: None,
viewed_files: BTreeSet::new(),
archived_at_ms: None,
presentation_state: None,
}
}
fn test_source(id: &str, kind: ReviewSourceKind) -> ReviewSource {
ReviewSource {
id: id.to_string(),
label: id.to_string(),
kind,
included: true,
}
}
#[test]
fn all_common_git_targets_materialize_expected_changes() {
let temp = tempfile::tempdir().expect("tempdir");
let repo = temp.path();
git_test(repo, &["init", "--quiet"]);
git_test(repo, &["config", "user.email", "review@example.test"]);
git_test(repo, &["config", "user.name", "Review Test"]);
std::fs::write(repo.join("file.txt"), "base\n").expect("write base");
git_test(repo, &["add", "file.txt"]);
git_test(repo, &["commit", "--quiet", "-m", "base"]);
let base = git_test_output(repo, &["rev-parse", "HEAD"]);
git_test(repo, &["branch", "base-branch"]);
std::fs::write(repo.join("file.txt"), "base\ncommit\n").expect("write commit");
git_test(repo, &["add", "file.txt"]);
git_test(repo, &["commit", "--quiet", "-m", "head"]);
let head = git_test_output(repo, &["rev-parse", "HEAD"]);
git_test(repo, &["branch", "head-branch"]);
std::fs::write(repo.join("file.txt"), "base\ncommit\nstaged\n").expect("write staged");
git_test(repo, &["add", "file.txt"]);
std::fs::write(repo.join("file.txt"), "base\ncommit\nstaged\nunstaged\n")
.expect("write unstaged");
let targets = [
ReviewTarget::WorkingTreeUnstaged,
ReviewTarget::IndexStaged,
ReviewTarget::WorkingTreeAndIndex,
ReviewTarget::LastCommit,
ReviewTarget::CommitRange {
base,
head,
merge_base: false,
},
ReviewTarget::BranchCompare {
base_branch: "base-branch".to_string(),
head_branch: "head-branch".to_string(),
merge_base: true,
},
];
for target in targets {
let summary = create_review_summary(&CreateReviewRequest {
repo_path: repo.to_path_buf(),
target,
})
.expect("materialize target");
assert!(!summary.files.is_empty());
}
}
fn git_test(repo: &Path, args: &[&str]) {
assert!(
Command::new("git")
.args(args)
.current_dir(repo)
.status()
.expect("run git")
.success()
);
}
fn git_test_output(repo: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.expect("run git");
assert!(output.status.success());
String::from_utf8(output.stdout)
.expect("utf8 git output")
.trim()
.to_string()
}
#[test]
fn invalid_target_diagnostics_include_actionable_recovery() {
let cases = [
(
ReviewSourceKind::CommitRange {
base: "missing".to_string(),
head: "HEAD".to_string(),
merge_base: false,
},
"invalid_revision",
"Verify both revisions",
),
(
ReviewSourceKind::BranchCompare {
base_branch: "missing".to_string(),
head_branch: "HEAD".to_string(),
merge_base: true,
},
"missing_branch",
"Verify both branches",
),
(
ReviewSourceKind::WorkingTreeAndIndex,
"missing_head",
"Create an initial commit",
),
];
for (kind, expected_code, expected_recovery) in cases {
let source = ReviewSource {
id: "source".to_string(),
label: "source".to_string(),
included: true,
kind,
};
let (code, message) = actionable_materialization_error(
&source,
&ReviewError::Git("ambiguous argument".to_string()),
);
assert_eq!(code, expected_code);
assert!(message.contains(expected_recovery));
}
}
#[test]
fn external_import_persistence_round_trips_and_replaces_snapshot_atomically() {
let temp = tempfile::tempdir().expect("tempdir");
let repo = temp.path().join("repo");
std::fs::create_dir_all(&repo).expect("create repo");
assert!(
Command::new("git")
.args(["init", "--quiet"])
.current_dir(&repo)
.status()
.expect("git init")
.success()
);
let config = CodeReviewPluginConfig {
state_location: CodeReviewStateLocation::User,
state_dir: Some(temp.path().join("state")),
};
let thread = bcode_code_review_models::ExternalReviewThread {
identity: bcode_code_review_models::ExternalReviewIdentity {
provider_id: "github".to_string(),
external_id: "thread:1".to_string(),
url: None,
},
state: bcode_code_review_models::ExternalReviewThreadState::Open,
mapping: bcode_code_review_models::ExternalReviewAnchorMapping {
status: bcode_code_review_models::ExternalAnchorMappingStatus::Unmappable,
anchor: None,
message: Some("outdated".to_string()),
},
comments: Vec::new(),
};
let saved = save_external_import_for_request(
SaveExternalReviewImportRequest {
repo_path: repo.clone(),
workspace_id: "workspace-1".to_string(),
import: bcode_code_review_models::ExternalReviewImportResponse {
importer_id: "github_pr_review".to_string(),
threads: vec![thread.clone()],
warnings: vec!["first warning".to_string()],
},
},
&config,
)
.expect("save import");
assert_eq!(saved.record.threads, vec![thread]);
let replacement = save_external_import_for_request(
SaveExternalReviewImportRequest {
repo_path: repo.clone(),
workspace_id: "workspace-1".to_string(),
import: bcode_code_review_models::ExternalReviewImportResponse {
importer_id: "github_pr_review".to_string(),
threads: Vec::new(),
warnings: vec!["replacement".to_string()],
},
},
&config,
)
.expect("replace import");
let listed = list_external_imports_for_request(
&ListExternalReviewImportsRequest {
repo_path: repo,
workspace_id: "workspace-1".to_string(),
},
&config,
)
.expect("list imports");
assert_eq!(listed.records, vec![replacement.record]);
assert!(listed.records[0].threads.is_empty());
assert_eq!(listed.records[0].warnings, vec!["replacement"]);
}
#[test]
fn publish_record_thread_membership_round_trips() {
let temp = tempfile::tempdir().expect("tempdir");
let repo = temp.path().join("repo");
std::fs::create_dir_all(&repo).expect("create repo");
assert!(
Command::new("git")
.args(["init", "--quiet"])
.current_dir(&repo)
.status()
.expect("git init")
.success()
);
let config = CodeReviewPluginConfig {
state_location: CodeReviewStateLocation::User,
state_dir: Some(temp.path().join("state")),
};
let workspace = ReviewWorkspace {
id: "workspace-publish".to_string(),
title: "Publish".to_string(),
repo_root: repo.clone(),
sources: Vec::new(),
created_at_ms: None,
updated_at_ms: None,
viewed_files: BTreeSet::new(),
archived_at_ms: None,
presentation_state: None,
};
let anchor = DraftAnchor {
kind: ReviewAnchorKind::Review,
file_path: "<review>".to_string(),
diff_row: 0,
start_diff_row: None,
end_diff_row: None,
old_start: None,
old_end: None,
new_start: None,
new_end: None,
old_line: None,
new_line: None,
line_kind: ReviewLineKind::Context,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
let request = ServiceRequest {
interface_id: CODE_REVIEW_SERVICE_INTERFACE_ID.to_string(),
operation: OP_REVIEW_PUBLISH_RECORD_SAVE.to_string(),
payload: serde_json::to_vec(&SavePublishRecordRequest {
workspace: workspace.clone(),
review_id: "review-1".to_string(),
publisher_id: "github".to_string(),
submitted: true,
published_thread_anchors: vec![anchor.clone()],
output: None,
message: "submitted".to_string(),
})
.expect("serialize request"),
};
save_publish_record_for_request(&request, &config).expect("save publish record");
let records = list_publish_records_for_request(
&ListPublishRecordsRequest {
repo_path: repo,
workspace_id: workspace.id,
},
&config,
)
.expect("list publish records");
assert_eq!(records.records.len(), 1);
assert_eq!(records.records[0].published_thread_anchors, vec![anchor]);
}
#[test]
fn review_bundle_excludes_requested_thread_anchors() {
let temp = tempfile::tempdir().expect("tempdir");
let repo = temp.path().join("repo");
std::fs::create_dir_all(&repo).expect("create repo");
assert!(
Command::new("git")
.args(["init", "--quiet"])
.current_dir(&repo)
.status()
.expect("git init")
.success()
);
let config = CodeReviewPluginConfig {
state_location: CodeReviewStateLocation::User,
state_dir: Some(temp.path().join("state")),
};
let anchor = DraftAnchor {
kind: ReviewAnchorKind::Review,
file_path: "<review>".to_string(),
diff_row: 0,
start_diff_row: None,
end_diff_row: None,
old_start: None,
old_end: None,
new_start: None,
new_end: None,
old_line: None,
new_line: None,
line_kind: ReviewLineKind::Context,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
save_draft_for_request(
SaveDraftRequest {
repo_path: repo.clone(),
target: ReviewTarget::Repository,
scope: None,
anchor: anchor.clone(),
body: "Overall finding".to_string(),
},
&config,
)
.expect("save draft");
let bundle = review_bundle_for_request(
PublishReviewRequest {
repo_path: repo,
target: ReviewTarget::Repository,
workspace: None,
excluded_thread_anchors: vec![anchor],
publisher_id: "local_markdown".to_string(),
options: serde_json::Value::Null,
},
&config,
)
.expect("bundle");
assert!(bundle.threads.is_empty());
}
#[test]
fn ai_exchange_delete_is_explicit_and_idempotent() {
let temp = tempfile::tempdir().expect("tempdir");
let repo = temp.path().join("repo");
std::fs::create_dir_all(&repo).expect("repo");
git_test(&repo, &["init", "--quiet"]);
let config = CodeReviewPluginConfig {
state_location: CodeReviewStateLocation::User,
state_dir: Some(temp.path().join("state")),
};
let exchange = ReviewAiExchange {
schema_version: bcode_code_review_models::REVIEW_AI_EXCHANGE_SCHEMA_VERSION,
exchange_id: "exchange-delete".to_string(),
anchor: DraftAnchor {
kind: ReviewAnchorKind::Review,
file_path: "<review>".to_string(),
diff_row: 0,
start_diff_row: None,
end_diff_row: None,
old_start: None,
old_end: None,
new_start: None,
new_end: None,
old_line: None,
new_line: None,
line_kind: ReviewLineKind::Context,
is_file_anchor: false,
surface_id: None,
source_id: None,
},
question: "delete me".to_string(),
session_id: None,
status: ReviewAiExchangeStatus::Pending,
error: None,
created_at_ms: 0,
updated_at_ms: 0,
};
save_ai_exchange_for_request(
SaveReviewAiExchangeRequest {
repo_path: repo.clone(),
target: ReviewTarget::Repository,
scope: None,
exchange,
},
&config,
)
.expect("save exchange");
let request = || DeleteReviewAiExchangeRequest {
repo_path: repo.clone(),
target: ReviewTarget::Repository,
scope: None,
exchange_id: "exchange-delete".to_string(),
};
assert!(
delete_ai_exchange_for_request(request(), &config)
.expect("delete")
.deleted
);
assert!(
!delete_ai_exchange_for_request(request(), &config)
.expect("duplicate delete")
.deleted
);
}
#[test]
fn workspace_presentation_state_round_trips_and_defaults_for_legacy_rows() {
let temp = tempfile::tempdir().expect("tempdir");
let repo = temp.path().join("repo");
std::fs::create_dir_all(&repo).expect("repo");
git_test(&repo, &["init", "--quiet"]);
let config = CodeReviewPluginConfig {
state_location: CodeReviewStateLocation::User,
state_dir: Some(temp.path().join("state")),
};
let mut workspace = test_workspace(&repo, Vec::new());
workspace.presentation_state =
Some(bcode_code_review_models::ReviewWorkspacePresentationState {
schema_version:
bcode_code_review_models::REVIEW_WORKSPACE_PRESENTATION_SCHEMA_VERSION,
selected_path: Some("src/lib.rs".to_string()),
selected_thread_key: Some("thread:1".to_string()),
selected_line: Some(42),
sidebar_mode: "threads".to_string(),
sidebar_visible: true,
thread_filter: "open".to_string(),
show_resolved_threads: false,
hide_viewed_files: true,
collapsed_thread_keys: BTreeSet::from(["thread:2".to_string()]),
expanded_agent_answer_keys: BTreeSet::from(["thread:1".to_string()]),
});
update_review_workspace_for_request(
UpdateReviewWorkspaceRequest {
repo_path: repo.clone(),
workspace: workspace.clone(),
},
&config,
)
.expect("save workspace");
let loaded = get_review_workspace_for_request(
GetReviewWorkspaceRequest {
repo_path: repo,
workspace_id: workspace.id.clone(),
},
&config,
)
.expect("get workspace")
.workspace
.expect("workspace");
assert_eq!(loaded.presentation_state, workspace.presentation_state);
let legacy: ReviewWorkspace = serde_json::from_value(serde_json::json!({
"id": "legacy",
"title": "Legacy",
"repo_root": "/repo",
"sources": [],
"viewed_files": []
}))
.expect("legacy workspace");
assert!(legacy.presentation_state.is_none());
}
#[test]
fn ai_exchange_persistence_is_separate_from_publishable_drafts() {
let temp = tempfile::tempdir().expect("tempdir");
let repo = temp.path().join("repo");
std::fs::create_dir_all(&repo).expect("create repo");
assert!(
Command::new("git")
.args(["init", "--quiet"])
.current_dir(&repo)
.status()
.expect("git init")
.success()
);
let config = CodeReviewPluginConfig {
state_location: CodeReviewStateLocation::User,
state_dir: Some(temp.path().join("state")),
};
let anchor = DraftAnchor {
kind: ReviewAnchorKind::Review,
file_path: "<review>".to_string(),
diff_row: 0,
start_diff_row: None,
end_diff_row: None,
old_start: None,
old_end: None,
new_start: None,
new_end: None,
old_line: None,
new_line: None,
line_kind: ReviewLineKind::Context,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
let exchange = ReviewAiExchange {
schema_version: bcode_code_review_models::REVIEW_AI_EXCHANGE_SCHEMA_VERSION,
exchange_id: "exchange-1".to_string(),
anchor,
question: "Is this safe?".to_string(),
session_id: Some("session-1".to_string()),
status: ReviewAiExchangeStatus::Linked,
error: None,
created_at_ms: 0,
updated_at_ms: 0,
};
let saved = save_ai_exchange_for_request(
SaveReviewAiExchangeRequest {
repo_path: repo.clone(),
target: ReviewTarget::Repository,
scope: None,
exchange,
},
&config,
)
.expect("save exchange")
.exchange;
assert!(saved.created_at_ms > 0);
let listed = list_ai_exchanges_for_request(
&ListReviewAiExchangesRequest {
repo_path: repo.clone(),
target: ReviewTarget::Repository,
scope: None,
},
&config,
)
.expect("list exchanges");
assert_eq!(listed.exchanges, vec![saved]);
let bundle = review_bundle_for_request(
PublishReviewRequest {
repo_path: repo,
target: ReviewTarget::Repository,
workspace: None,
excluded_thread_anchors: Vec::new(),
publisher_id: "local_markdown".to_string(),
options: serde_json::Value::Null,
},
&config,
)
.expect("bundle");
assert!(bundle.threads.is_empty());
}
#[test]
fn suggestion_persistence_round_trips_and_updates_lifecycle() {
let temp = tempfile::tempdir().expect("tempdir");
let repo = temp.path().join("repo");
std::fs::create_dir_all(&repo).expect("create repo");
let init = Command::new("git")
.args(["init", "--quiet"])
.current_dir(&repo)
.status()
.expect("run git init");
assert!(init.success());
let config = CodeReviewPluginConfig {
state_location: CodeReviewStateLocation::User,
state_dir: Some(temp.path().join("state")),
};
let anchor = DraftAnchor {
kind: ReviewAnchorKind::Range,
file_path: "src/lib.rs".to_string(),
diff_row: 2,
start_diff_row: Some(2),
end_diff_row: Some(2),
old_start: None,
old_end: None,
new_start: Some(1),
new_end: Some(1),
old_line: None,
new_line: Some(1),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
let scope = Some(ReviewScope::Workspace {
workspace_id: "workspace-1".to_string(),
target: ReviewTarget::WorkingTreeUnstaged,
});
let suggestion = ReviewSuggestion {
suggestion_id: "suggestion-1".to_string(),
anchor,
body: "Handle this error.".to_string(),
session_id: Some("session-1".to_string()),
status: ReviewSuggestionStatus::Suggested,
rationale: Some("AI review".to_string()),
created_at_ms: 0,
updated_at_ms: 0,
};
let saved = save_suggestion_for_request(
SaveReviewSuggestionRequest {
repo_path: repo.clone(),
target: ReviewTarget::WorkingTreeUnstaged,
scope: scope.clone(),
suggestion,
},
&config,
)
.expect("save suggestion")
.suggestion;
assert!(saved.created_at_ms > 0);
assert_eq!(saved.created_at_ms, saved.updated_at_ms);
let listed = list_suggestions_for_request(
&ListReviewSuggestionsRequest {
repo_path: repo.clone(),
target: ReviewTarget::WorkingTreeUnstaged,
scope: scope.clone(),
},
&config,
)
.expect("list suggestions");
assert_eq!(listed.suggestions, vec![saved.clone()]);
let mut rejected = saved.clone();
rejected.status = ReviewSuggestionStatus::Rejected;
let updated = save_suggestion_for_request(
SaveReviewSuggestionRequest {
repo_path: repo.clone(),
target: ReviewTarget::WorkingTreeUnstaged,
scope: scope.clone(),
suggestion: rejected,
},
&config,
)
.expect("update suggestion")
.suggestion;
assert_eq!(updated.status, ReviewSuggestionStatus::Rejected);
assert_eq!(updated.created_at_ms, saved.created_at_ms);
assert!(updated.updated_at_ms >= saved.updated_at_ms);
let reopened = list_suggestions_for_request(
&ListReviewSuggestionsRequest {
repo_path: repo,
target: ReviewTarget::WorkingTreeUnstaged,
scope,
},
&config,
)
.expect("reopen suggestions");
assert_eq!(reopened.suggestions, vec![updated]);
}
#[test]
fn repo_root_resolution_rejects_relative_paths() {
for relative in [".", "./nested", "nested", ".."] {
let error = resolve_repo_root(Path::new(relative))
.expect_err("relative repo_path should be rejected");
match error {
ReviewError::InvalidRequest(message) => assert!(
message.contains("must be absolute"),
"expected an absolute-path rejection for {relative:?}, got {message}"
),
other => panic!("expected InvalidRequest for {relative:?}, got {other:?}"),
}
}
}
#[test]
fn repo_root_resolution_accepts_absolute_repository() {
let temp = tempfile::tempdir().expect("tempdir");
let repo = initialized_test_repository(temp.path());
let repo_root = resolve_repo_root(&repo).expect("absolute repository resolves");
assert!(repo_root.is_absolute());
}
#[test]
fn default_state_path_uses_user_bcode_state_dir() {
let config = CodeReviewPluginConfig::default();
let state_paths = state_paths(Path::new("/repo"), &config);
assert_eq!(
state_paths.state_root,
bcode_config::default_state_dir().join(DEFAULT_STATE_SUBDIR)
);
}
#[test]
fn repo_state_location_uses_repo_local_directory() {
let config = CodeReviewPluginConfig {
state_location: CodeReviewStateLocation::Repo,
state_dir: None,
};
let state_paths = state_paths(Path::new("/repo"), &config);
assert_eq!(
state_paths.state_root,
PathBuf::from("/repo/.bcode/code-review")
);
}
#[test]
fn explicit_relative_state_dir_is_repo_relative() {
let config = CodeReviewPluginConfig {
state_location: CodeReviewStateLocation::User,
state_dir: Some(PathBuf::from(".bcode/code-review")),
};
let state_paths = state_paths(Path::new("/repo"), &config);
assert_eq!(
state_paths.state_root,
PathBuf::from("/repo/.bcode/code-review")
);
}
#[test]
fn explicit_absolute_state_dir_is_used() {
let config = CodeReviewPluginConfig {
state_location: CodeReviewStateLocation::User,
state_dir: Some(PathBuf::from("/state/code-review")),
};
let state_paths = state_paths(Path::new("/repo"), &config);
assert_eq!(state_paths.state_root, PathBuf::from("/state/code-review"));
}
#[test]
fn parses_modified_file_diff() {
let diff = "diff --git a/src/lib.rs b/src/lib.rs\nindex 1111111..2222222 100644\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1,2 +1,3 @@\n line one\n-old\n+new\n+extra\n";
let files = parse_unified_diff(diff).expect("parse diff");
assert_eq!(files.len(), 1);
assert_eq!(files[0].display_path(), "src/lib.rs");
assert_eq!(files[0].status, ReviewFileStatus::Modified);
assert_eq!(files[0].additions, 2);
assert_eq!(files[0].deletions, 1);
assert_eq!(files[0].hunks.len(), 1);
assert_eq!(files[0].hunks[0].lines.len(), 4);
assert_eq!(files[0].hunks[0].lines[0].old_line, Some(1));
assert_eq!(files[0].hunks[0].lines[0].new_line, Some(1));
}
#[test]
fn parses_rename_diff() {
let diff = "diff --git a/old.rs b/new.rs\nsimilarity index 90%\nrename from old.rs\nrename to new.rs\n--- a/old.rs\n+++ b/new.rs\n@@ -1 +1 @@\n-old\n+new\n";
let files = parse_unified_diff(diff).expect("parse diff");
assert_eq!(files.len(), 1);
assert_eq!(files[0].old_path.as_deref(), Some("old.rs"));
assert_eq!(files[0].new_path.as_deref(), Some("new.rs"));
assert_eq!(files[0].status, ReviewFileStatus::Renamed);
assert_eq!(files[0].additions, 1);
assert_eq!(files[0].deletions, 1);
}
#[test]
fn parses_single_line_hunk_range() {
let hunk = parse_hunk_header("@@ -4 +4,2 @@ fn main()").expect("parse hunk");
assert_eq!(hunk.old_start, 4);
assert_eq!(hunk.old_count, 1);
assert_eq!(hunk.new_start, 4);
assert_eq!(hunk.new_count, 2);
assert_eq!(hunk.heading.as_deref(), Some("fn main()"));
}
#[test]
fn publisher_registry_lists_builtin_publishers() {
let publishers = builtin_publishers();
assert_eq!(publishers.len(), 3);
assert!(
publishers
.iter()
.any(|publisher| publisher.id == "local_markdown")
);
assert!(
publishers
.iter()
.any(|publisher| publisher.id == "markdown_file")
);
assert!(
publishers
.iter()
.any(|publisher| publisher.id == "json_file")
);
}
#[test]
fn local_markdown_publisher_preview_is_local_review_report() {
let bundle = ReviewBundle {
review_id: "review-1".to_string(),
title: "Review".to_string(),
repo_root: PathBuf::from("/repo"),
target: ReviewTarget::WorkingTreeUnstaged,
surfaces: Vec::new(),
files: vec![ReviewFileSummary {
path: "src/lib.rs".to_string(),
status: ReviewFileStatus::Modified,
additions: 2,
deletions: 1,
hunks: 1,
is_binary: false,
}],
threads: vec![ReviewBundleThread {
thread_id: "thread-1".to_string(),
anchor: DraftAnchor {
kind: ReviewAnchorKind::Range,
file_path: "src/lib.rs".to_string(),
diff_row: 3,
old_line: None,
new_line: Some(2),
start_diff_row: Some(3),
end_diff_row: Some(3),
old_start: None,
old_end: None,
new_start: Some(2),
new_end: Some(2),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
},
comments: vec![DraftComment {
comment_id: "comment-1".to_string(),
thread_id: "thread-1".to_string(),
anchor: DraftAnchor {
kind: ReviewAnchorKind::Range,
file_path: "src/lib.rs".to_string(),
diff_row: 3,
old_line: None,
new_line: Some(2),
start_diff_row: Some(3),
end_diff_row: Some(3),
old_start: None,
old_end: None,
new_start: Some(2),
new_end: Some(2),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
},
body: "Needs local follow-up".to_string(),
created_at_ms: 1,
updated_at_ms: 1,
session_id: Some("session-1".to_string()),
resolved_at_ms: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}],
session_id: Some("session-1".to_string()),
resolved_at_ms: None,
selected_lines: Vec::new(),
selected_diff_lines: vec!["+let x = 1;".to_string()],
hunk_context: Vec::new(),
}],
generated_at_ms: 1,
};
let preview = with_publisher("local_markdown", |publisher| {
publisher.preview(&bundle, &serde_json::json!({}))
})
.expect("local markdown preview");
assert!(preview.contains("# Local review: Review"));
assert!(preview.contains("* Open threads: `1`"));
assert!(preview.contains("## File threads"));
assert!(preview.contains("### `src/lib.rs` row 3 (open)"));
assert!(preview.contains("* Bcode session: `session-1`"));
assert!(preview.contains("Needs local follow-up"));
}
#[test]
fn local_markdown_groups_review_level_threads() {
let bundle = ReviewBundle {
review_id: "review-1".to_string(),
title: "Review".to_string(),
repo_root: PathBuf::from("/repo"),
target: ReviewTarget::WorkingTreeUnstaged,
surfaces: Vec::new(),
files: Vec::new(),
threads: vec![ReviewBundleThread {
thread_id: "thread-review".to_string(),
anchor: DraftAnchor {
kind: ReviewAnchorKind::Review,
file_path: "<review>".to_string(),
diff_row: 0,
old_line: None,
new_line: None,
start_diff_row: None,
end_diff_row: None,
old_start: None,
old_end: None,
new_start: None,
new_end: None,
line_kind: ReviewLineKind::Context,
is_file_anchor: false,
surface_id: None,
source_id: None,
},
comments: vec![DraftComment {
comment_id: "comment-review".to_string(),
thread_id: "thread-review".to_string(),
anchor: DraftAnchor {
kind: ReviewAnchorKind::Review,
file_path: "<review>".to_string(),
diff_row: 0,
old_line: None,
new_line: None,
start_diff_row: None,
end_diff_row: None,
old_start: None,
old_end: None,
new_start: None,
new_end: None,
line_kind: ReviewLineKind::Context,
is_file_anchor: false,
surface_id: None,
source_id: None,
},
body: "Overall review summary".to_string(),
created_at_ms: 1,
updated_at_ms: 1,
session_id: None,
resolved_at_ms: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}],
session_id: None,
resolved_at_ms: None,
selected_lines: Vec::new(),
selected_diff_lines: Vec::new(),
hunk_context: Vec::new(),
}],
generated_at_ms: 1,
};
let preview = with_publisher("local_markdown", |publisher| {
publisher.preview(&bundle, &serde_json::json!({}))
})
.expect("local markdown preview");
assert!(preview.contains("## Review-level threads"));
assert!(preview.contains("### Review (open)"));
assert!(preview.contains("Overall review summary"));
assert!(!preview.contains("## File threads"));
}
#[test]
fn json_publisher_preview_is_review_bundle_json() {
let bundle = ReviewBundle {
review_id: "review-1".to_string(),
title: "Review".to_string(),
repo_root: PathBuf::from("/repo"),
target: ReviewTarget::WorkingTreeUnstaged,
surfaces: Vec::new(),
files: Vec::new(),
threads: Vec::new(),
generated_at_ms: 1,
};
let preview = with_publisher("json_file", |publisher| {
publisher.preview(&bundle, &serde_json::json!({}))
})
.expect("json preview");
let decoded: ReviewBundle = serde_json::from_str(&preview).expect("valid bundle json");
assert_eq!(decoded.review_id, "review-1");
}
#[test]
fn unsupported_publisher_returns_error() {
let error = with_publisher("missing", |publisher| {
publisher.preview(
&ReviewBundle {
review_id: "review-1".to_string(),
title: "Review".to_string(),
repo_root: PathBuf::from("/repo"),
target: ReviewTarget::WorkingTreeUnstaged,
surfaces: Vec::new(),
files: Vec::new(),
threads: Vec::new(),
generated_at_ms: 1,
},
&serde_json::json!({}),
)
})
.expect_err("missing publisher should fail");
assert!(matches!(error, ReviewError::UnsupportedPublisher(_)));
}
#[test]
fn markdown_export_writes_selected_publish_bundle_to_requested_file() {
let temp = tempfile::tempdir().expect("tempdir");
let output = temp.path().join("selected-review.md");
let bundle = ReviewBundle {
review_id: "review-selection".to_string(),
title: "Selected review".to_string(),
repo_root: temp.path().to_path_buf(),
target: ReviewTarget::WorkingTreeUnstaged,
surfaces: Vec::new(),
files: Vec::new(),
threads: vec![ReviewBundleThread {
thread_id: "thread-1".to_string(),
anchor: DraftAnchor {
kind: ReviewAnchorKind::Range,
file_path: "src/lib.rs".to_string(),
diff_row: 1,
start_diff_row: None,
end_diff_row: None,
old_start: None,
old_end: None,
new_start: Some(42),
new_end: Some(42),
old_line: None,
new_line: Some(42),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
},
comments: vec![DraftComment {
comment_id: "comment-1".to_string(),
thread_id: "thread-1".to_string(),
anchor: DraftAnchor {
kind: ReviewAnchorKind::Range,
file_path: "src/lib.rs".to_string(),
diff_row: 1,
start_diff_row: None,
end_diff_row: None,
old_start: None,
old_end: None,
new_start: Some(42),
new_end: Some(42),
old_line: None,
new_line: Some(42),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
},
body: "Selected actionable comment".to_string(),
created_at_ms: 1,
updated_at_ms: 1,
session_id: None,
resolved_at_ms: None,
thread_kind: ReviewThreadKind::Finding,
severity: ReviewThreadSeverity::Warning,
}],
resolved_at_ms: None,
session_id: None,
selected_lines: Vec::new(),
selected_diff_lines: Vec::new(),
hunk_context: Vec::new(),
}],
generated_at_ms: 1,
};
let response = with_publisher("markdown_file", |publisher| {
publisher.submit(&bundle, &serde_json::json!({ "output_path": output }))
})
.expect("markdown export");
let exported = std::fs::read_to_string(&output).expect("exported markdown");
assert!(response.submitted);
assert_eq!(response.output.as_deref(), output.to_str());
assert!(exported.contains("Selected review"));
assert!(exported.contains("Selected actionable comment"));
assert!(!exported.contains("review AI exchange"));
}
#[test]
fn output_path_override_is_used() {
let bundle = ReviewBundle {
review_id: "review-1".to_string(),
title: "Review".to_string(),
repo_root: PathBuf::from("/repo"),
target: ReviewTarget::WorkingTreeUnstaged,
surfaces: Vec::new(),
files: Vec::new(),
threads: Vec::new(),
generated_at_ms: 1,
};
let path = publish_output_path(
&bundle,
&serde_json::json!({ "output_path": "custom/review.json" }),
"json",
);
assert_eq!(path, PathBuf::from("custom/review.json"));
}
}
#[cfg(test)]
mod command_registration_tests {
use super::*;
#[test]
fn code_review_plugin_registers_diff_toggle_from_plugin_code() {
extern "C" fn register_command(
payload: *const u8,
payload_len: usize,
user_data: *mut std::ffi::c_void,
) {
assert!(!payload.is_null());
assert!(!user_data.is_null());
let bytes = unsafe { std::slice::from_raw_parts(payload, payload_len) };
let contribution = serde_json::from_slice::<CommandContribution>(bytes)
.expect("command contribution should decode");
let registry = unsafe { &mut *(user_data.cast::<bcode_command::CommandRegistry>()) };
registry.register(contribution);
}
let mut plugin = CodeReviewPlugin;
let mut registry = bcode_command::CommandRegistry::new();
plugin
.register_commands(CommandRegistrar::new(
Some(register_command),
std::ptr::from_mut(&mut registry).cast::<std::ffi::c_void>(),
))
.expect("code review plugin should register commands");
let commands = registry.commands_for_surface(&CommandSurface::Palette);
assert!(commands.iter().any(|command| {
command.id == "diff.toggle"
&& command.action
== CommandAction::Plugin {
plugin_id: "bcode.code_review".to_string(),
command_id: "diff.toggle".to_string(),
}
}));
}
}