difflore-cli 0.1.0

Your AI coding agent, taught by your team's PR reviews — a local-first, open-source MCP server that turns past review comments into rules your agent follows automatically.
Documentation
use difflore_core::cloud::api_types::{
    ImportedCommentUpload, ImportedReviewUpload, UploadImportedReviewsRequest,
};
use difflore_core::github_import::ImportProgress;
use difflore_core::reviews::{self, ReviewItemWithComments};
use sqlx::SqlitePool;

use crate::commands::util::exit_err;
use crate::runtime::CommandContext;
use crate::style;

const CLOUD_IMPORT_MAX_REVIEWS_PER_BATCH: usize = 20;
const CLOUD_IMPORT_MAX_COMMENTS_PER_BATCH: usize = 20;

pub(super) fn build_upload_batches(
    reviews: &[ImportedReviewUpload],
) -> Vec<Vec<ImportedReviewUpload>> {
    let mut batches: Vec<Vec<ImportedReviewUpload>> = Vec::new();
    let mut current: Vec<ImportedReviewUpload> = Vec::new();
    let mut current_comments = 0usize;

    for review in reviews {
        for comments in review.comments.chunks(CLOUD_IMPORT_MAX_COMMENTS_PER_BATCH) {
            if comments.is_empty() {
                continue;
            }
            let mut split = review.clone();
            split.comments = comments.to_vec();
            let split_comments = split.comments.len();

            if !current.is_empty()
                && (current.len() >= CLOUD_IMPORT_MAX_REVIEWS_PER_BATCH
                    || current_comments + split_comments > CLOUD_IMPORT_MAX_COMMENTS_PER_BATCH)
            {
                batches.push(std::mem::take(&mut current));
                current_comments = 0;
            }

            current_comments += split_comments;
            current.push(split);
        }
    }

    if !current.is_empty() {
        batches.push(current);
    }

    batches
}

pub(super) fn source_repo_from_metadata(
    metadata: Option<&str>,
    repo_full_name: &str,
) -> Option<String> {
    let value: serde_json::Value = serde_json::from_str(metadata?).ok()?;
    let source_repo = value
        .get("sourceRepoFullName")
        .or_else(|| value.get("sourceRepo"))
        .and_then(serde_json::Value::as_str)
        .map(str::trim)
        .filter(|s| !s.is_empty())?;

    if source_repo == repo_full_name {
        None
    } else {
        Some(source_repo.to_owned())
    }
}

pub(super) fn imported_review_upload(
    item: &ReviewItemWithComments,
) -> Option<ImportedReviewUpload> {
    let repo_full_name = item.item.repo_full_name.clone()?;
    let comments: Vec<ImportedCommentUpload> = item
        .comments
        .iter()
        .map(|c| ImportedCommentUpload {
            file_path: None,
            line_number: c.line_number.unwrap_or(0),
            content: c.content.clone(),
            author: c.author.clone(),
            comment_url: c.comment_url.clone().unwrap_or_default(),
            thread_id: c.thread_id.clone(),
            occurred_at: Some(c.created_at.clone()),
        })
        .collect();

    if comments.is_empty() {
        return None;
    }

    Some(ImportedReviewUpload {
        repo_full_name: repo_full_name.clone(),
        source_repo_full_name: source_repo_from_metadata(
            item.item.metadata.as_deref(),
            &repo_full_name,
        ),
        pr_number: item.item.pr_number.unwrap_or(0),
        pr_title: Some(item.item.file_path.clone()),
        comments,
    })
}

pub(super) const fn cloud_upload_next_step_commands() -> &'static [(&'static str, &'static str)] {
    &[
        ("difflore cloud sync", "# pull the new rules down"),
        ("difflore status", "# see the shortest local proof path"),
        (
            "difflore recall --diff",
            "# prove your agent can recall them",
        ),
        (
            "difflore cloud impact",
            "# show recall and accepted-proof value",
        ),
        (
            "difflore fix --preview",
            "# optional local patch suggestions",
        ),
    ]
}

pub(super) fn print_next_steps(uploaded_reviews: usize) {
    println!(
        "{} Uploaded {} reviews to cloud for AI extraction.",
        style::emerald(style::sym::OK),
        uploaded_reviews
    );
    println!();
    println!(
        "  {} Cloud is extracting patterns. In a few minutes:",
        style::emerald(style::sym::TIP),
    );
    for (cmd, hint) in cloud_upload_next_step_commands() {
        println!("    {} {}", style::cmd(cmd), style::pewter(hint));
    }
}

pub(super) async fn run_upload(
    ctx: &CommandContext,
    db: &SqlitePool,
    repo: &str,
    import_result: &ImportProgress,
    json: bool,
) -> Result<usize, String> {
    if import_result.comments_imported == 0 && import_result.comments_skipped > 0 {
        eprintln!(
            "No new review comments imported from {repo}; retrying cloud upload from local imported comments."
        );
    }

    let items = match reviews::list_by_source_with_comments(
        db,
        reviews::ReviewSourceInput {
            source: "github".into(),
        },
    )
    .await
    {
        Ok(v) => v,
        Err(e) => exit_err(&format!("failed to load imported reviews: {e}")),
    };

    if items.is_empty() {
        eprintln!("No imported reviews to upload.");
        return Ok(0);
    }

    let upload_reviews: Vec<ImportedReviewUpload> = items
        .iter()
        .filter(|item| item.item.repo_full_name.as_deref() == Some(repo))
        .filter_map(imported_review_upload)
        .collect();

    if upload_reviews.is_empty() {
        eprintln!("No reviews with comments to upload.");
        return Ok(0);
    }

    let cloud = ctx.cloud().await;

    // Pre-flight: every batch will fail with a generic "failed batch" line if
    // the user isn't logged in. Bail with a clear actionable message instead
    // of looping through N silent auth failures.
    if !cloud.is_logged_in() {
        style::report_error(
            "`--upload` requires a cloud session, but no token is on this machine.",
            "",
            &[
                style::Hint::try_("difflore cloud login".to_owned()),
                style::Hint::try_(
                    "rerun `difflore import-reviews --upload` once login completes".to_owned(),
                ),
            ],
        );
        return Err("not logged in to cloud — `--upload` cannot proceed".to_owned());
    }

    let total_reviews = upload_reviews.len();
    let batches = build_upload_batches(&upload_reviews);
    let total_batches = batches.len();
    let mut uploaded_reviews = 0usize;
    let mut failed_batches = 0usize;

    for (idx, batch) in batches.into_iter().enumerate() {
        let req = UploadImportedReviewsRequest { reviews: batch };
        let comments = req.reviews.iter().map(|r| r.comments.len()).sum::<usize>();
        if cloud.upload_imported_reviews(&req).await {
            uploaded_reviews += req.reviews.len();
            eprintln!(
                "  uploaded batch {}/{} ({} reviews, {} comments)",
                idx + 1,
                total_batches,
                req.reviews.len(),
                comments
            );
        } else {
            failed_batches += 1;
            eprintln!(
                "  failed batch {}/{} ({} reviews, {} comments)",
                idx + 1,
                total_batches,
                req.reviews.len(),
                comments
            );
        }
    }

    if failed_batches == 0 {
        // Honor --json: success summary belongs only in the structured payload
        // emitted by `print_import_json`. Printing the human-friendly banner
        // here would prepend non-JSON text to stdout and break any pipeline
        // doing `... --json | jq`. Per-batch progress already routes to stderr.
        if !json {
            print_next_steps(uploaded_reviews);
        }
    } else {
        style::report_error(
            "Cloud upload was incomplete; local import succeeded but extraction is not fully queued.",
            "",
            &[
                style::Hint::try_("difflore cloud status".to_owned()),
                style::Hint::try_(
                    "rerun `difflore import-reviews --upload` to retry failed batches".to_owned(),
                ),
            ],
        );
        return Err(format!(
            "uploaded {uploaded_reviews}/{total_reviews} imported reviews; {failed_batches}/{total_batches} cloud batches failed",
        ));
    }
    Ok(uploaded_reviews)
}