From 0381f252bc4535c7bf9be1d42196eaa072a52ed4 Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Wed, 4 Nov 2026 00:09:29 +0530 Subject: [PATCH 1/2] Add autter badge: a README badge with the repo's AI-written share `autter badge` prints the share of added lines written by AI over the last 90 days (or --since / --all-time) and a paste-ready Markdown snippet. The number is sum(ai_accepted) / sum(git_diff_added_lines) over commits with an authorship note, the same ratio each uploaded commit summary carries as ai_percent; commits without a note are counted so the output can report coverage. --json prints a shields.io endpoint payload (schemaVersion, label, message, color, plus the figures) and --write stores it, by default in .autter/badge.json, so a committed file can back the badge without an account. When the repository is linked to Autter and the user is signed in, the snippet uses the live badge behind POST /cli/repo-badge-link; otherwise it falls back to a static shields.io badge within 5s. Co-Authored-By: Claude Fable 5.1 --- src/api/data_plane.rs | 15 +- src/api/types.rs | 14 + src/authorship/mod.rs | 1 + src/authorship/repo_share.rs | 173 ++++++ src/commands/arg_parser.rs | 36 ++ src/commands/autter_handlers.rs | 4 + src/commands/badge.rs | 530 ++++++++++++++++++ src/commands/mod.rs | 1 + tests/integration/badge.rs | 347 ++++++++++++ tests/integration/main.rs | 1 + ..._badge_markdown_snippet_without_cloud.snap | 12 + 11 files changed, 1133 insertions(+), 1 deletion(-) create mode 100644 src/authorship/repo_share.rs create mode 100644 src/commands/badge.rs create mode 100644 tests/integration/badge.rs create mode 100644 tests/integration/snapshots/integration__badge__badge_markdown_snippet_without_cloud.snap diff --git a/src/api/data_plane.rs b/src/api/data_plane.rs index b53a3ec..db494ca 100644 --- a/src/api/data_plane.rs +++ b/src/api/data_plane.rs @@ -5,7 +5,9 @@ //! endpoints on the server. use crate::api::client::ApiClient; -use crate::api::types::{CommitAuthorshipSummary, FileChangeCount, FileChangeUploadResponse}; +use crate::api::types::{ + CommitAuthorshipSummary, FileChangeCount, FileChangeUploadResponse, RepoBadgeLink, +}; use crate::error::AutterError; fn require_success( @@ -61,6 +63,17 @@ impl ApiClient { serde_json::from_str(&body).map_err(AutterError::JsonError) } + /// Ask for (or create) the public token behind the repository's live + /// `AI-written` badge, served at `/api/public/repos//badge.svg`. + pub fn create_repo_badge_link(&self, repo_url: &str) -> Result { + let request = serde_json::json!({ "repo_url": repo_url }); + let body = require_success( + self.context().post_json("/cli/repo-badge-link", &request)?, + "Repo badge link", + )?; + serde_json::from_str(&body).map_err(AutterError::JsonError) + } + /// Probe the server-side data plane without exposing or opening an org DB /// connection from the CLI. pub fn check_data_plane(&self) -> Result<(), AutterError> { diff --git a/src/api/types.rs b/src/api/types.rs index 74ba9eb..29345f2 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -171,6 +171,20 @@ pub struct CommitAuthorshipSummary { pub hunks: serde_json::Value, } +/// Response of `POST /cli/repo-badge-link`: the public token behind a +/// repository's live `AI-written` badge. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RepoBadgeLink { + /// Public token that names the repository in the badge route. + pub token: String, + /// Full badge URL, when the server prefers to state it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub badge_url: Option, + /// The repository's Autter page, which the badge links to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page_url: Option, +} + /// Aggregated file-change count uploaded by the daemon. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FileChangeCount { diff --git a/src/authorship/mod.rs b/src/authorship/mod.rs index 091326d..9e99b81 100644 --- a/src/authorship/mod.rs +++ b/src/authorship/mod.rs @@ -16,6 +16,7 @@ pub mod post_commit; pub mod prompt_utils; pub mod range_authorship; pub mod rebase_authorship; +pub mod repo_share; pub mod secrets; pub mod squash_recovery; pub mod stats; diff --git a/src/authorship/repo_share.rs b/src/authorship/repo_share.rs new file mode 100644 index 0000000..b39f743 --- /dev/null +++ b/src/authorship/repo_share.rs @@ -0,0 +1,173 @@ +//! Repo-wide AI share: the number behind `autter badge`. +//! +//! The share is `sum(ai_accepted) / sum(git_diff_added_lines)` over the +//! commits in a window, the same ratio each per-commit summary carries as +//! `ai_percent` when it is uploaded to the Autter cloud (see +//! `post_commit::upload_commit_authorship_summary`). Commits without an +//! authorship note add nothing to either sum but are counted, so callers can +//! report coverage (`commits_with_authorship / commits`). Merge commits are +//! left out of the walk: they attest no lines of their own and would only +//! lower the coverage figure. + +use crate::authorship::stats::stats_for_commit_stats_with_authorship; +use crate::error::AutterError; +use crate::git::notes_api::{commits_with_notes, read_authorship}; +use crate::git::repository::{Repository, exec_git}; +use serde::{Deserialize, Serialize}; + +/// Days of history the default window covers. +pub const DEFAULT_WINDOW_DAYS: i64 = 90; + +/// Most commits one computation walks. Keeps `autter badge` bounded on large +/// repositories; the result says when the cap was hit. +pub const MAX_COMMITS: usize = 2000; + +/// Which commits on the current branch to count. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Window { + /// Commits at or after a `git log --since` value: a date such as + /// `2025-08-05` or a duration such as `30 days ago`. + Since(String), + /// Every commit reachable from HEAD. + AllTime, +} + +impl Window { + /// The default window: the last [`DEFAULT_WINDOW_DAYS`] days. + pub fn default_window() -> Self { + Self::Since(default_since_date()) + } +} + +/// The ISO date [`DEFAULT_WINDOW_DAYS`] days before today (UTC). +pub fn default_since_date() -> String { + (chrono::Utc::now() - chrono::Duration::days(DEFAULT_WINDOW_DAYS)) + .format("%Y-%m-%d") + .to_string() +} + +/// The repo-wide share and the figures behind it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RepoShare { + /// AI-accepted lines as a percentage of added lines, rounded to two + /// decimals. Zero when no line was added in the window. + pub ai_percent: f64, + /// Lines accepted from an AI agent unchanged, summed over the window. + pub ai_accepted: u64, + /// Lines added by the counted commits (ignored files excluded). + pub added_lines: u64, + /// Non-merge commits in the window, including those without a note. + pub commits: usize, + /// Commits in the window that have an authorship note. + pub commits_with_authorship: usize, + /// The `--since` bound the window used, or `None` for all time. + pub since: Option, + /// True when the walk stopped at [`MAX_COMMITS`] before the window ended. + pub capped: bool, +} + +impl RepoShare { + /// True when there is nothing to report a share of. + pub fn has_data(&self) -> bool { + self.added_lines > 0 + } +} + +/// Percentage `part` is of `total`, rounded to two decimals; zero when `total` is zero. +pub fn percent(part: u64, total: u64) -> f64 { + if total == 0 { + 0.0 + } else { + ((part as f64 / total as f64) * 10000.0).round() / 100.0 + } +} + +/// Compute the repo-wide AI share for the commits in `window` on the current +/// branch, skipping files that match `ignore_patterns`. +pub fn repo_share( + repo: &Repository, + window: &Window, + ignore_patterns: &[String], +) -> Result { + let since = match window { + Window::Since(since) => Some(since.clone()), + Window::AllTime => None, + }; + let (shas, capped) = list_commits(repo, window)?; + let with_notes = commits_with_notes(repo, &shas)?; + + let mut ai_accepted = 0u64; + let mut added_lines = 0u64; + let mut commits_with_authorship = 0usize; + for sha in &shas { + if !with_notes.contains(sha) { + continue; + } + let Some(log) = read_authorship(repo, sha) else { + continue; + }; + let stats = stats_for_commit_stats_with_authorship(repo, sha, ignore_patterns, Some(&log))?; + commits_with_authorship += 1; + ai_accepted += u64::from(stats.ai_accepted); + added_lines += u64::from(stats.git_diff_added_lines); + } + + Ok(RepoShare { + ai_percent: percent(ai_accepted, added_lines), + ai_accepted, + added_lines, + commits: shas.len(), + commits_with_authorship, + since, + capped, + }) +} + +/// Non-merge commits reachable from HEAD within `window`, newest first, and +/// whether the list was cut at [`MAX_COMMITS`]. An unborn branch yields none. +fn list_commits(repo: &Repository, window: &Window) -> Result<(Vec, bool), AutterError> { + if repo.head().and_then(|head| head.target()).is_err() { + return Ok((Vec::new(), false)); + } + let mut args = repo.global_args_for_exec(); + args.push("log".to_string()); + args.push("--format=%H".to_string()); + args.push("--no-merges".to_string()); + args.push(format!("--max-count={}", MAX_COMMITS + 1)); + if let Window::Since(since) = window { + args.push(format!("--since={since}")); + } + args.push("HEAD".to_string()); + let output = exec_git(&args)?; + let mut shas: Vec = String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect(); + let capped = shas.len() > MAX_COMMITS; + shas.truncate(MAX_COMMITS); + Ok((shas, capped)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn percent_rounds_to_two_decimals_and_handles_zero() { + assert_eq!(percent(0, 0), 0.0); + assert_eq!(percent(1, 3), 33.33); + assert_eq!(percent(2, 3), 66.67); + assert_eq!(percent(5, 5), 100.0); + } + + #[test] + fn default_window_is_an_iso_date() { + let Window::Since(date) = Window::default_window() else { + panic!("default window must be a --since bound"); + }; + assert_eq!(date.len(), 10); + assert!(chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_ok()); + } +} diff --git a/src/commands/arg_parser.rs b/src/commands/arg_parser.rs index 430bf8c..bc90bb4 100644 --- a/src/commands/arg_parser.rs +++ b/src/commands/arg_parser.rs @@ -357,6 +357,42 @@ autter stats [commit] -i, --interactive Browse recent commits full-screen by AI share, with each commit's breakdown by tool and model", }, + HelpEntry { + name: "badge", + aliases: &[], + summary: "README badge with the repo's AI-written share", + body: "\ +autter badge [--since | --all-time] [--json] [--write [path]] + + Print the share of this repository's added lines that were written by AI + (AI-accepted lines / added lines, over commits with an authorship note) and + a README badge snippet reading `AI-written | 72%`. The snippet is copied to + the clipboard when stderr is a terminal. + + By default the last 90 days of non-merge commits on the current branch are + counted. Commits without an authorship note add nothing to the share but + are counted, so the output reports coverage. At most 2000 commits are + walked. Files matching .autter-ignore are left out. + + --since Count commits since a date (2025-08-05) or duration + (30 days ago); anything `git log --since` accepts + --all-time Count every commit reachable from HEAD + --ignore Also ignore files matching the given pattern(s) + --json Print the shields.io endpoint payload: schemaVersion, + label, message and color, plus ai_percent, ai_accepted, + added_lines, commits, commits_with_authorship, since + and capped + --write [path] Also write that payload to a file (default + .autter/badge.json) that shields.io can read once it is + committed: https://img.shields.io/endpoint?url= + + Badge colors: no data lightgrey; under 25% light blue (74c0fc); + 25-49% blue (007ec6); 50-74% indigo (5b5bd6); 75% and up teal (2ee6c5). + + When the repository is linked to Autter and you are signed in, the snippet + uses a live badge served by Autter; otherwise it is a static shields.io + badge with the local number.", + }, HelpEntry { name: "file-changes", aliases: &[], diff --git a/src/commands/autter_handlers.rs b/src/commands/autter_handlers.rs index dd1de04..0b5fc40 100644 --- a/src/commands/autter_handlers.rs +++ b/src/commands/autter_handlers.rs @@ -112,6 +112,7 @@ pub fn handle_autter(args: &[String]) { | "ask" | "logs" | "threads" + | "badge" | "memory" | "context" | "mcp" @@ -196,6 +197,9 @@ pub fn handle_autter(args: &[String]) { } handle_stats(&args[1..]); } + "badge" => { + commands::badge::handle_badge(&args[1..]); + } "file-changes" => { commands::file_changes::handle_file_changes(&args[1..]); } diff --git a/src/commands/badge.rs b/src/commands/badge.rs new file mode 100644 index 0000000..2c8f8c6 --- /dev/null +++ b/src/commands/badge.rs @@ -0,0 +1,530 @@ +//! `autter badge`: a README badge reading `AI-written | 72%`. +//! +//! The number is the repo-wide share from `authorship::repo_share`. The +//! snippet is one of: +//! +//! * a live badge served by Autter, when the repository is linked to an +//! organization and the user is signed in (`POST /cli/repo-badge-link` +//! hands back the public token behind `/api/public/repos//badge.svg`); +//! * a static shields.io badge carrying the local number, for everyone else. +//! +//! `--json` prints the share as a shields.io endpoint payload, and `--write` +//! stores that payload in the repository (default `.autter/badge.json`) so a +//! committed file can back the badge without an account. + +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::api::client::{ + ApiClient, ApiContext, access_token_for_org, resolve_org_for_repo_cached, +}; +use crate::authorship::ignore::effective_ignore_patterns; +use crate::authorship::repo_share::{RepoShare, Window, repo_share}; +use crate::commands::arg_parser::{self, ScanMode}; +use crate::error::AutterError; +use crate::git::repository::{Repository, find_repository}; +use crate::repo_url::resolve_repo_url_from_repo; + +/// Badge label, left of the separator. +pub const LABEL: &str = "AI-written"; +/// Where a static badge links to. +pub const SITE_URL: &str = "https://autter.dev"; +/// Default `--write` target, relative to the repository root. +pub const DEFAULT_WRITE_PATH: &str = ".autter/badge.json"; +/// How long the live-badge lookup may take before the static snippet is used. +const CLOUD_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug, Default, PartialEq)] +struct Options { + window: Option, + write: Option, + ignore_patterns: Vec, +} + +fn parse_options(args: &[String]) -> Result { + let mut options = Options::default(); + let mut index = 0; + while index < args.len() { + let arg = args[index].as_str(); + match arg { + "--since" => { + let value = args + .get(index + 1) + .ok_or("--since requires a date or duration")?; + set_window(&mut options, Window::Since(value.clone()))?; + index += 2; + } + "--all-time" => { + set_window(&mut options, Window::AllTime)?; + index += 1; + } + "--write" => { + let next = args.get(index + 1).filter(|next| !next.starts_with('-')); + options.write = Some(PathBuf::from( + next.map(String::as_str).unwrap_or(DEFAULT_WRITE_PATH), + )); + index += if next.is_some() { 2 } else { 1 }; + } + "--ignore" => { + index += 1; + let start = index; + while index < args.len() && !args[index].starts_with("--") { + options.ignore_patterns.push(args[index].clone()); + index += 1; + } + if index == start { + return Err("--ignore requires at least one pattern".into()); + } + } + _ => { + if let Some(value) = arg.strip_prefix("--since=") { + set_window(&mut options, Window::Since(value.to_string()))?; + } else if let Some(path) = arg.strip_prefix("--write=") { + options.write = Some(PathBuf::from(path)); + } else { + return Err(format!("unknown argument: {arg}")); + } + index += 1; + } + } + } + Ok(options) +} + +fn set_window(options: &mut Options, window: Window) -> Result<(), String> { + if options.window.is_some() { + return Err("use only one of --since and --all-time".into()); + } + options.window = Some(window); + Ok(()) +} + +/// Shields color for a share: lightgrey with no data, then four steps from +/// light blue up to Autter's teal. +pub fn badge_color(share: &RepoShare) -> &'static str { + if !share.has_data() { + "lightgrey" + } else if share.ai_percent < 25.0 { + "74c0fc" + } else if share.ai_percent < 50.0 { + "007ec6" + } else if share.ai_percent < 75.0 { + "5b5bd6" + } else { + "2ee6c5" + } +} + +/// Badge message, right of the separator. +pub fn badge_message(share: &RepoShare) -> String { + if share.has_data() { + format!("{}%", share.ai_percent.round() as u64) + } else { + "no data".to_string() + } +} + +/// The share as a shields.io endpoint payload, with the figures behind it. +/// Field order is the written order: the shields keys come first. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BadgePayload { + #[serde(rename = "schemaVersion")] + pub schema_version: u32, + pub label: String, + pub message: String, + pub color: String, + pub ai_percent: f64, + pub ai_accepted: u64, + pub added_lines: u64, + pub commits: usize, + pub commits_with_authorship: usize, + pub since: Option, + pub capped: bool, +} + +/// The shields.io endpoint payload for a share. +pub fn badge_payload(share: &RepoShare) -> BadgePayload { + BadgePayload { + schema_version: 1, + label: LABEL.to_string(), + message: badge_message(share), + color: badge_color(share).to_string(), + ai_percent: share.ai_percent, + ai_accepted: share.ai_accepted, + added_lines: share.added_lines, + commits: share.commits, + commits_with_authorship: share.commits_with_authorship, + since: share.since.clone(), + capped: share.capped, + } +} + +/// A shields.io static badge URL for the share. +pub fn static_badge_url(share: &RepoShare) -> String { + format!( + "https://img.shields.io/badge/{}-{}-{}", + shields_segment(LABEL), + shields_segment(&badge_message(share)), + badge_color(share) + ) +} + +/// Escape one segment of a shields.io static badge path: `-` becomes `--`, +/// `_` becomes `__`, and the rest is percent-encoded. +fn shields_segment(text: &str) -> String { + let escaped = text.replace('-', "--").replace('_', "__").replace(' ', "_"); + url::form_urlencoded::byte_serialize(escaped.as_bytes()) + .collect::() + .replace('+', "%20") +} + +/// A shields.io endpoint badge URL reading the payload at `raw_url`. +pub fn endpoint_badge_url(raw_url: &str) -> String { + let encoded: String = url::form_urlencoded::byte_serialize(raw_url.as_bytes()).collect(); + format!("https://img.shields.io/endpoint?url={encoded}") +} + +/// Markdown for a badge image linking to `link`. +pub fn markdown_snippet(badge_url: &str, link: &str) -> String { + format!("[![{LABEL}]({badge_url})]({link})") +} + +/// The raw-file URL GitHub serves for `path` on `branch`, when the remote is a +/// GitHub repository. +pub fn github_raw_url(repo_url: &str, branch: &str, path: &str) -> Option { + let rest = repo_url.strip_prefix("https://github.com/")?; + let (owner, name) = rest.split_once('/')?; + if owner.is_empty() || name.is_empty() || name.contains('/') { + return None; + } + Some(format!( + "https://raw.githubusercontent.com/{owner}/{name}/{branch}/{}", + path.trim_start_matches('/') + )) +} + +/// A live badge served by Autter. +#[derive(Debug, Clone, PartialEq, Eq)] +struct CloudBadge { + badge_url: String, + page_url: String, +} + +/// Resolve the live badge for `repo`, or say in one line why the static +/// snippet is used instead. Never waits longer than [`CLOUD_TIMEOUT`]. +fn cloud_badge(repo: &Repository) -> Result { + let repo_url = resolve_repo_url_from_repo(repo) + .ok_or_else(|| "this repository has no remote to link to Autter".to_string())?; + if !crate::config::platform_sync_enabled_now() { + return Err("autter is in local mode".to_string()); + } + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let _ = sender.send(request_cloud_badge(&repo_url)); + }); + match receiver.recv_timeout(CLOUD_TIMEOUT) { + Ok(result) => result, + Err(_) => Err(format!( + "Autter did not answer within {}s", + CLOUD_TIMEOUT.as_secs() + )), + } +} + +fn request_cloud_badge(repo_url: &str) -> Result { + let org = resolve_org_for_repo_cached(repo_url).ok_or_else(|| { + "sign in with `autter login` and link this repository to an organization".to_string() + })?; + let token = access_token_for_org(&org) + .ok_or_else(|| "could not get an access token; run `autter login` again".to_string())?; + let client = + ApiClient::new(ApiContext::with_auth(None, token).with_timeout(CLOUD_TIMEOUT.as_secs())); + let link = client + .create_repo_badge_link(repo_url) + .map_err(|e| e.to_string())?; + let base = client.context().base_url.trim_end_matches('/').to_string(); + Ok(CloudBadge { + badge_url: link + .badge_url + .unwrap_or_else(|| format!("{base}/api/public/repos/{}/badge.svg", link.token)), + page_url: link + .page_url + .unwrap_or_else(crate::commands::login::web_app_url), + }) +} + +/// The README snippet and, when the live badge is unavailable, why. +fn snippet_for( + repo: &Repository, + share: &RepoShare, + written: Option<&str>, +) -> (String, Vec) { + let mut notes = Vec::new(); + match cloud_badge(repo) { + Ok(cloud) => (markdown_snippet(&cloud.badge_url, &cloud.page_url), notes), + Err(reason) => { + if let Some(path) = written { + let branch = repo + .head() + .ok() + .and_then(|head| head.shorthand().ok()) + .unwrap_or_else(|| "main".to_string()); + if let Some(raw) = resolve_repo_url_from_repo(repo) + .and_then(|url| github_raw_url(&url, &branch, path)) + { + notes.push(format!( + "Static badge: {LABEL} reads {path} once it is committed and pushed ({reason})." + )); + return (markdown_snippet(&endpoint_badge_url(&raw), SITE_URL), notes); + } + notes.push(format!( + "Static badge: commit {path} and point shields.io at its raw URL: https://img.shields.io/endpoint?url= ({reason})." + )); + } else { + notes.push(format!( + "Static badge with today's number ({reason}). Run `autter badge --write` for one that updates from a committed file." + )); + } + (markdown_snippet(&static_badge_url(share), SITE_URL), notes) + } + } +} + +fn write_payload( + repo: &Repository, + path: &Path, + payload: &BadgePayload, +) -> Result { + let target = if path.is_absolute() { + path.to_path_buf() + } else { + repo.workdir()?.join(path) + }; + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + let mut text = serde_json::to_string_pretty(payload)?; + text.push('\n'); + std::fs::write(&target, text)?; + Ok(target) +} + +fn window_description(share: &RepoShare) -> String { + match &share.since { + Some(since) => format!("since {since}"), + None => "all time".to_string(), + } +} + +/// `autter badge [--since | --all-time] [--json] [--write [path]]`. +pub fn handle_badge(args: &[String]) { + let pre = match arg_parser::pre_parse(args, ScanMode::Full, false) { + Ok(pre) => pre, + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(crate::commands::EXIT_USAGE_ERROR); + } + }; + if pre.flags.help { + arg_parser::print_command_help("badge"); + return; + } + arg_parser::merge_global_flags(&pre.flags); + let options = match parse_options(&pre.rest) { + Ok(options) => options, + Err(e) => { + eprintln!("error: {e}"); + arg_parser::print_command_help("badge"); + std::process::exit(crate::commands::EXIT_USAGE_ERROR); + } + }; + if let Err(e) = run(options, arg_parser::json()) { + eprintln!("Badge failed: {e}"); + std::process::exit(crate::commands::EXIT_RUNTIME_ERROR); + } +} + +fn run(options: Options, json_output: bool) -> Result<(), AutterError> { + let repo = find_repository(&Vec::::new())?; + let window = options.window.unwrap_or_else(Window::default_window); + let patterns = effective_ignore_patterns(&repo, &options.ignore_patterns, &[]); + let share = repo_share(&repo, &window, &patterns)?; + let payload = badge_payload(&share); + + let written = match &options.write { + Some(path) => Some(write_payload(&repo, path, &payload)?), + None => None, + }; + + if json_output { + println!("{}", serde_json::to_string(&payload)?); + return Ok(()); + } + + println!( + "{LABEL}: {} ({} AI-accepted of {} added lines)", + badge_message(&share), + share.ai_accepted, + share.added_lines + ); + println!( + "Window: {} non-merge commits {}, {} with authorship notes", + share.commits, + window_description(&share), + share.commits_with_authorship + ); + if share.capped { + println!( + "Note: only the newest {} commits were counted.", + crate::authorship::repo_share::MAX_COMMITS + ); + } + if let Some(target) = &written { + println!("Wrote {}", target.display()); + } + + // The written file's path inside the repository, as shields.io would fetch it. + let written_in_repo = written.as_deref().and_then(|target| { + let workdir = repo.workdir().ok()?; + let relative = target.strip_prefix(&workdir).ok()?; + Some(crate::utils::normalize_to_posix( + &relative.to_string_lossy(), + )) + }); + let (snippet, notes) = snippet_for(&repo, &share, written_in_repo.as_deref()); + let copied = std::io::IsTerminal::is_terminal(&std::io::stderr()) + && crate::ui::clipboard::request_copy(&snippet); + println!(); + println!( + "README snippet{}:", + if copied { " (copied to clipboard)" } else { "" } + ); + println!(); + println!(" {snippet}"); + for note in notes { + println!(); + println!("{note}"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn share(ai_accepted: u64, added_lines: u64) -> RepoShare { + RepoShare { + ai_percent: crate::authorship::repo_share::percent(ai_accepted, added_lines), + ai_accepted, + added_lines, + commits: 3, + commits_with_authorship: 2, + since: Some("2025-08-05".to_string()), + capped: false, + } + } + + fn args(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn colors_step_from_grey_to_teal() { + assert_eq!(badge_color(&share(0, 0)), "lightgrey"); + assert_eq!(badge_color(&share(1, 10)), "74c0fc"); + assert_eq!(badge_color(&share(25, 100)), "007ec6"); + assert_eq!(badge_color(&share(50, 100)), "5b5bd6"); + assert_eq!(badge_color(&share(75, 100)), "2ee6c5"); + assert_eq!(badge_color(&share(100, 100)), "2ee6c5"); + } + + #[test] + fn message_rounds_to_whole_percent() { + assert_eq!(badge_message(&share(724, 1000)), "72%"); + assert_eq!(badge_message(&share(725, 1000)), "73%"); + assert_eq!(badge_message(&share(0, 0)), "no data"); + } + + #[test] + fn json_is_a_shields_endpoint_payload() { + let value = serde_json::to_value(badge_payload(&share(72, 100))).unwrap(); + assert_eq!(value["schemaVersion"], 1); + assert_eq!(value["label"], LABEL); + assert_eq!(value["message"], "72%"); + assert_eq!(value["color"], "5b5bd6"); + assert_eq!(value["ai_percent"], 72.0); + assert_eq!(value["since"], "2025-08-05"); + } + + #[test] + fn static_badge_escapes_the_label_dash() { + assert_eq!( + static_badge_url(&share(72, 100)), + "https://img.shields.io/badge/AI--written-72%25-5b5bd6" + ); + assert_eq!( + static_badge_url(&share(0, 0)), + "https://img.shields.io/badge/AI--written-no_data-lightgrey" + ); + } + + #[test] + fn endpoint_badge_encodes_the_raw_url() { + assert_eq!( + endpoint_badge_url("https://raw.githubusercontent.com/o/r/main/.autter/badge.json"), + "https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fo%2Fr%2Fmain%2F.autter%2Fbadge.json" + ); + } + + #[test] + fn github_raw_url_only_for_github_remotes() { + assert_eq!( + github_raw_url("https://github.com/o/r", "main", ".autter/badge.json").as_deref(), + Some("https://raw.githubusercontent.com/o/r/main/.autter/badge.json") + ); + assert_eq!( + github_raw_url("https://gitlab.com/o/r", "main", ".autter/badge.json"), + None + ); + } + + #[test] + fn parses_window_and_write_flags() { + assert_eq!(parse_options(&args(&[])).unwrap(), Options::default()); + assert_eq!( + parse_options(&args(&["--since", "30 days ago"])) + .unwrap() + .window, + Some(Window::Since("30 days ago".into())) + ); + assert_eq!( + parse_options(&args(&["--since=2025-01-01", "--write"])).unwrap(), + Options { + window: Some(Window::Since("2025-01-01".into())), + write: Some(PathBuf::from(DEFAULT_WRITE_PATH)), + ignore_patterns: vec![], + } + ); + assert_eq!( + parse_options(&args(&["--write", "docs/badge.json", "--all-time"])).unwrap(), + Options { + window: Some(Window::AllTime), + write: Some(PathBuf::from("docs/badge.json")), + ignore_patterns: vec![], + } + ); + assert_eq!( + parse_options(&args(&["--ignore", "*.lock", "vendor/**"])) + .unwrap() + .ignore_patterns, + vec!["*.lock".to_string(), "vendor/**".to_string()] + ); + assert!(parse_options(&args(&["--since", "1 day ago", "--all-time"])).is_err()); + assert!(parse_options(&args(&["--since"])).is_err()); + assert!(parse_options(&args(&["--ignore"])).is_err()); + assert!(parse_options(&args(&["--bogus"])).is_err()); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 8d361b3..833a291 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod arg_parser; pub mod ask; pub mod autter_handlers; +pub mod badge; pub mod blame; pub mod checkpoint_agent; pub mod ci_handlers; diff --git a/tests/integration/badge.rs b/tests/integration/badge.rs new file mode 100644 index 0000000..2abeb48 --- /dev/null +++ b/tests/integration/badge.rs @@ -0,0 +1,347 @@ +//! `autter badge`: the repo-wide AI share as a shields.io endpoint payload +//! and a README snippet. + +use crate::repos::test_file::ExpectedLineExt; +use crate::repos::test_repo::TestRepo; +use serde_json::Value; +use std::fs; + +/// Run `autter badge --json ` and parse the payload line. +fn badge_json(repo: &TestRepo, args: &[&str]) -> Value { + let mut full = vec!["badge", "--json"]; + full.extend_from_slice(args); + let output = repo.autter(&full).expect("autter badge --json succeeds"); + let line = output + .lines() + .find(|line| line.starts_with('{') && line.contains("\"schemaVersion\"")) + .unwrap_or_else(|| panic!("no badge payload in output:\n{output}")); + serde_json::from_str(line).expect("badge payload is JSON") +} + +/// Stdout of `autter badge ` alone, without the debug lines on stderr. +fn badge_stdout(repo: &TestRepo, args: &[&str]) -> String { + let mut full = vec!["badge"]; + full.extend_from_slice(args); + let output = repo + .autter_command(&full) + .output() + .expect("autter badge runs"); + assert!( + output.status.success(), + "autter badge failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).to_string() +} + +fn assert_shields_keys(payload: &Value) { + assert_eq!(payload["schemaVersion"], 1); + assert_eq!(payload["label"], "AI-written"); + assert!(payload["message"].is_string()); + assert!(payload["color"].is_string()); +} + +#[test] +fn badge_json_all_ai_repo() { + let repo = TestRepo::new(); + let file_path = repo.path().join("app.txt"); + + fs::write(&file_path, "AI line 1\nAI line 2\nAI line 3\n").unwrap(); + repo.autter(&["checkpoint", "mock_ai", "app.txt"]).unwrap(); + repo.stage_all_and_commit("All AI").unwrap(); + let mut file = repo.filename("app.txt"); + file.assert_committed_lines(lines!["AI line 1".ai(), "AI line 2".ai(), "AI line 3".ai(),]); + + let payload = badge_json(&repo, &[]); + assert_shields_keys(&payload); + assert_eq!(payload["message"], "100%"); + assert_eq!(payload["color"], "2ee6c5"); + assert_eq!(payload["ai_percent"], 100.0); + assert_eq!(payload["ai_accepted"], 3); + assert_eq!(payload["added_lines"], 3); + assert_eq!(payload["commits"], 1); + assert_eq!(payload["commits_with_authorship"], 1); + assert_eq!(payload["capped"], false); + // The default window is dated: an ISO date 90 days back. + let since = payload["since"] + .as_str() + .expect("default window has a since date"); + assert!( + chrono::NaiveDate::parse_from_str(since, "%Y-%m-%d").is_ok(), + "since should be an ISO date, got {since}" + ); +} + +#[test] +fn badge_json_mixed_repo() { + let repo = TestRepo::new(); + let file_path = repo.path().join("app.txt"); + + // Commit 1: one known-human line, then one AI line. + fs::write(&file_path, "Human line 1\n").unwrap(); + repo.autter(&["checkpoint", "mock_known_human", "app.txt"]) + .unwrap(); + fs::write(&file_path, "Human line 1\nAI line 1\n").unwrap(); + repo.autter(&["checkpoint", "mock_ai", "app.txt"]).unwrap(); + repo.stage_all_and_commit("Mixed 1").unwrap(); + let mut file = repo.filename("app.txt"); + file.assert_committed_lines(lines!["Human line 1".human(), "AI line 1".ai()]); + + // Commit 2: two more human lines, then two more AI lines. + fs::write( + &file_path, + "Human line 1\nAI line 1\nHuman line 2\nHuman line 3\n", + ) + .unwrap(); + repo.autter(&["checkpoint", "mock_known_human", "app.txt"]) + .unwrap(); + fs::write( + &file_path, + "Human line 1\nAI line 1\nHuman line 2\nHuman line 3\nAI line 2\nAI line 3\n", + ) + .unwrap(); + repo.autter(&["checkpoint", "mock_ai", "app.txt"]).unwrap(); + repo.stage_all_and_commit("Mixed 2").unwrap(); + file.assert_committed_lines(lines![ + "Human line 1".human(), + "AI line 1".ai(), + "Human line 2".human(), + "Human line 3".human(), + "AI line 2".ai(), + "AI line 3".ai(), + ]); + + // 3 AI-accepted of 6 added lines across both commits. + let payload = badge_json(&repo, &[]); + assert_shields_keys(&payload); + assert_eq!(payload["ai_accepted"], 3); + assert_eq!(payload["added_lines"], 6); + assert_eq!(payload["ai_percent"], 50.0); + assert_eq!(payload["message"], "50%"); + assert_eq!(payload["color"], "5b5bd6"); + assert_eq!(payload["commits"], 2); + assert_eq!(payload["commits_with_authorship"], 2); +} + +#[test] +fn badge_json_reports_coverage_for_commits_without_notes() { + let repo = TestRepo::new(); + let file_path = repo.path().join("app.txt"); + + fs::write(&file_path, "AI line 1\nAI line 2\n").unwrap(); + repo.autter(&["checkpoint", "mock_ai", "app.txt"]).unwrap(); + repo.stage_all_and_commit("With note").unwrap(); + let mut file = repo.filename("app.txt"); + file.assert_committed_lines(lines!["AI line 1".ai(), "AI line 2".ai()]); + + // A commit whose authorship note is gone (made elsewhere, never synced). + let other_path = repo.path().join("other.txt"); + fs::write(&other_path, "Untracked 1\nUntracked 2\nUntracked 3\n").unwrap(); + let commit = repo.stage_all_and_commit("Without note").unwrap(); + let mut other = repo.filename("other.txt"); + other.assert_committed_lines(lines![ + "Untracked 1".unattributed_human(), + "Untracked 2".unattributed_human(), + "Untracked 3".unattributed_human(), + ]); + let _ = repo.git_og(&["notes", "--ref=ai", "remove", &commit.commit_sha]); + assert!( + repo.read_authorship_note(&commit.commit_sha).is_none(), + "the second commit must have no authorship note" + ); + + let payload = badge_json(&repo, &[]); + assert_eq!(payload["commits"], 2); + assert_eq!(payload["commits_with_authorship"], 1); + // The note-less commit is counted but adds nothing to the share. + assert_eq!(payload["ai_accepted"], 2); + assert_eq!(payload["added_lines"], 2); + assert_eq!(payload["ai_percent"], 100.0); +} + +#[test] +fn badge_json_respects_autter_ignore() { + let repo = TestRepo::new(); + fs::write(repo.path().join(".autter-ignore"), "*.lock\n").unwrap(); + fs::write(repo.path().join("app.txt"), "AI line 1\nAI line 2\n").unwrap(); + fs::write( + repo.path().join("deps.lock"), + "lock 1\nlock 2\nlock 3\nlock 4\nlock 5\n", + ) + .unwrap(); + repo.autter(&["checkpoint", "mock_ai", "app.txt", "deps.lock"]) + .unwrap(); + repo.stage_all_and_commit("With lock file").unwrap(); + let mut file = repo.filename("app.txt"); + file.assert_committed_lines(lines!["AI line 1".ai(), "AI line 2".ai()]); + + let payload = badge_json(&repo, &[]); + assert_eq!(payload["ai_accepted"], 2); + // app.txt plus the one-line .autter-ignore; deps.lock is left out. + assert_eq!(payload["added_lines"], 3); + assert_eq!(payload["ai_percent"], 66.67); + assert_eq!(payload["message"], "67%"); + + // `--ignore` adds to the patterns from the file. + let payload = badge_json(&repo, &["--ignore", ".autter-ignore"]); + assert_eq!(payload["added_lines"], 2); + assert_eq!(payload["ai_percent"], 100.0); +} + +#[test] +fn badge_json_window_flags() { + let repo = TestRepo::new(); + fs::write(repo.path().join("app.txt"), "AI line 1\n").unwrap(); + repo.autter(&["checkpoint", "mock_ai", "app.txt"]).unwrap(); + repo.stage_all_and_commit("One").unwrap(); + let mut file = repo.filename("app.txt"); + file.assert_committed_lines(lines!["AI line 1".ai()]); + + let payload = badge_json(&repo, &["--all-time"]); + assert_eq!(payload["since"], Value::Null); + assert_eq!(payload["commits"], 1); + assert_eq!(payload["message"], "100%"); + + let payload = badge_json(&repo, &["--since", "1 day ago"]); + assert_eq!(payload["since"], "1 day ago"); + assert_eq!(payload["commits"], 1); + + // Nothing in the window: no data, grey badge, zero share. (git clamps a + // far-future year such as 2999 to the current year, so use a relative date.) + let payload = badge_json(&repo, &["--since=tomorrow"]); + assert_shields_keys(&payload); + assert_eq!(payload["commits"], 0); + assert_eq!(payload["commits_with_authorship"], 0); + assert_eq!(payload["added_lines"], 0); + assert_eq!(payload["ai_percent"], 0.0); + assert_eq!(payload["message"], "no data"); + assert_eq!(payload["color"], "lightgrey"); + + let err = repo + .autter(&["badge", "--since", "1 day ago", "--all-time"]) + .unwrap_err(); + assert!(err.contains("only one of --since and --all-time"), "{err}"); +} + +#[test] +fn badge_write_stores_the_payload() { + let repo = TestRepo::new(); + fs::write(repo.path().join("app.txt"), "AI line 1\nAI line 2\n").unwrap(); + repo.autter(&["checkpoint", "mock_ai", "app.txt"]).unwrap(); + repo.stage_all_and_commit("One").unwrap(); + let mut file = repo.filename("app.txt"); + file.assert_committed_lines(lines!["AI line 1".ai(), "AI line 2".ai()]); + + // Default path. + let printed = badge_json(&repo, &["--write"]); + let written: Value = serde_json::from_str( + &fs::read_to_string(repo.path().join(".autter/badge.json")).expect("badge.json written"), + ) + .expect("badge.json is JSON"); + assert_eq!(written, printed); + assert_shields_keys(&written); + assert_eq!(written["message"], "100%"); + + // Custom path, created with its parent directory. + let printed = badge_json(&repo, &["--all-time", "--write", "docs/ai-badge.json"]); + let written: Value = serde_json::from_str( + &fs::read_to_string(repo.path().join("docs/ai-badge.json")).expect("custom path written"), + ) + .unwrap(); + assert_eq!(written, printed); + assert_eq!(written["since"], Value::Null); +} + +#[test] +fn badge_markdown_snippet_without_cloud() { + let repo = TestRepo::new(); + fs::write( + repo.path().join("app.txt"), + "Human line\nAI line 1\nAI line 2\nAI line 3\n", + ) + .unwrap(); + repo.autter(&["checkpoint", "mock_known_human", "app.txt"]) + .unwrap(); + repo.stage_all_and_commit("Human").unwrap(); + let mut file = repo.filename("app.txt"); + file.assert_committed_lines(lines![ + "Human line".human(), + "AI line 1".human(), + "AI line 2".human(), + "AI line 3".human(), + ]); + + fs::write( + repo.path().join("app.txt"), + "Human line\nAI line 1\nAI line 2\nAI line 3\nAI line 4\nAI line 5\nAI line 6\nAI line 7\nAI line 8\nAI line 9\nAI line 10\nAI line 11\nAI line 12\n", + ) + .unwrap(); + repo.autter(&["checkpoint", "mock_ai", "app.txt"]).unwrap(); + repo.stage_all_and_commit("AI").unwrap(); + file.assert_committed_lines(lines![ + "Human line".human(), + "AI line 1".human(), + "AI line 2".human(), + "AI line 3".human(), + "AI line 4".ai(), + "AI line 5".ai(), + "AI line 6".ai(), + "AI line 7".ai(), + "AI line 8".ai(), + "AI line 9".ai(), + "AI line 10".ai(), + "AI line 11".ai(), + "AI line 12".ai(), + ]); + + // 9 of 13 added lines: 69.23%, shown as 69%, in the 50-74% color. + let output = badge_stdout(&repo, &["--all-time"]); + insta::assert_snapshot!("badge_markdown_snippet_without_cloud", output); +} + +#[test] +fn badge_write_with_github_remote_prints_endpoint_snippet() { + let repo = TestRepo::new(); + fs::write(repo.path().join("app.txt"), "AI line 1\n").unwrap(); + repo.autter(&["checkpoint", "mock_ai", "app.txt"]).unwrap(); + repo.stage_all_and_commit("One").unwrap(); + let mut file = repo.filename("app.txt"); + file.assert_committed_lines(lines!["AI line 1".ai()]); + repo.git_og(&["remote", "add", "origin", "git@github.com:acme/widgets.git"]) + .unwrap(); + let branch = repo.current_branch(); + + let output = badge_stdout(&repo, &["--all-time", "--write"]); + let expected = format!( + "[![AI-written](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Facme%2Fwidgets%2F{branch}%2F.autter%2Fbadge.json)](https://autter.dev)" + ); + assert!(output.contains(&expected), "unexpected output:\n{output}"); + assert!( + output.contains( + "Static badge: AI-written reads .autter/badge.json once it is committed and pushed" + ), + "unexpected output:\n{output}" + ); + assert!(repo.path().join(".autter/badge.json").is_file()); +} + +#[test] +fn badge_help_lists_flags_and_colors() { + let repo = TestRepo::new(); + let output = repo.autter(&["badge", "--help"]).unwrap(); + for needle in [ + "--since", + "--all-time", + "--write", + "--json", + "lightgrey", + "2ee6c5", + ] { + assert!( + output.contains(needle), + "help should mention {needle}:\n{output}" + ); + } + let overview = repo.autter(&["help"]).unwrap(); + assert!(overview.contains("badge"), "{overview}"); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 02e254f..f0727ee 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -16,6 +16,7 @@ mod amp; mod antigravity; mod attribution_tracker_comprehensive; mod background_agent_attribution; +mod badge; mod bash_attribution; mod bash_tool_benchmark; mod bash_tool_conformance; diff --git a/tests/integration/snapshots/integration__badge__badge_markdown_snippet_without_cloud.snap b/tests/integration/snapshots/integration__badge__badge_markdown_snippet_without_cloud.snap new file mode 100644 index 0000000..b7ae3f9 --- /dev/null +++ b/tests/integration/snapshots/integration__badge__badge_markdown_snippet_without_cloud.snap @@ -0,0 +1,12 @@ +--- +source: tests/integration/badge.rs +expression: output +--- +AI-written: 69% (9 AI-accepted of 13 added lines) +Window: 2 non-merge commits all time, 2 with authorship notes + +README snippet: + + [![AI-written](https://img.shields.io/badge/AI--written-69%25-5b5bd6)](https://autter.dev) + +Static badge with today's number (this repository has no remote to link to Autter). Run `autter badge --write` for one that updates from a committed file. From c4fa2116ed2488b6d1b9745c80932f9cb19efe9b Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Wed, 4 Nov 2026 00:50:23 +0530 Subject: [PATCH 2/2] Post repo badge links to /api/cli/repo-badge-link The backend mounts CLI routes under /api/cli, like every other CLI endpoint, so the badge link request goes there too. Co-Authored-By: Claude Fable 5.1 --- src/api/data_plane.rs | 3 ++- src/api/types.rs | 2 +- src/commands/badge.rs | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/api/data_plane.rs b/src/api/data_plane.rs index db494ca..0bb14a8 100644 --- a/src/api/data_plane.rs +++ b/src/api/data_plane.rs @@ -68,7 +68,8 @@ impl ApiClient { pub fn create_repo_badge_link(&self, repo_url: &str) -> Result { let request = serde_json::json!({ "repo_url": repo_url }); let body = require_success( - self.context().post_json("/cli/repo-badge-link", &request)?, + self.context() + .post_json("/api/cli/repo-badge-link", &request)?, "Repo badge link", )?; serde_json::from_str(&body).map_err(AutterError::JsonError) diff --git a/src/api/types.rs b/src/api/types.rs index 29345f2..7cc3ae2 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -171,7 +171,7 @@ pub struct CommitAuthorshipSummary { pub hunks: serde_json::Value, } -/// Response of `POST /cli/repo-badge-link`: the public token behind a +/// Response of `POST /api/cli/repo-badge-link`: the public token behind a /// repository's live `AI-written` badge. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RepoBadgeLink { diff --git a/src/commands/badge.rs b/src/commands/badge.rs index 2c8f8c6..d1d7b67 100644 --- a/src/commands/badge.rs +++ b/src/commands/badge.rs @@ -4,7 +4,7 @@ //! snippet is one of: //! //! * a live badge served by Autter, when the repository is linked to an -//! organization and the user is signed in (`POST /cli/repo-badge-link` +//! organization and the user is signed in (`POST /api/cli/repo-badge-link` //! hands back the public token behind `/api/public/repos//badge.svg`); //! * a static shields.io badge carrying the local number, for everyone else. //!