From 0381f252bc4535c7bf9be1d42196eaa072a52ed4 Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Wed, 4 Nov 2026 00:09:29 +0530 Subject: [PATCH 1/5] 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 ca4188eabc271b07159e24cb03ebc790660156e4 Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Wed, 4 Nov 2026 00:12:13 +0530 Subject: [PATCH 2/5] Add autter wrapped: a year of AI-assisted coding as a share card `autter wrapped [--year] [--author|--all-authors] [--json] [--include-prompt-text] [--no-color]` walks the year's commits, reads each authorship note and folds the per-commit stats into one summary: AI share overall and by month, top agent and model, the most-prompted file, the longest prompt, the longest streak and the busiest weekday. The terminal output is a deterministic 60-column card for screenshots; `--json` prints the `wrapped/1.0.0` contract the share-image endpoint will consume. Prompt text never leaves the machine: the longest prompt is measured from the local transcript cache only (new `resolve_cas_messages_local`), and its first 120 characters are included only with `--include-prompt-text`. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 + README.md | 2 + changelog/wrapped.md | 24 + src/authorship/cas_bridge.rs | 33 +- src/authorship/mod.rs | 1 + src/authorship/post_commit.rs | 8 +- src/authorship/wrapped.rs | 570 ++++++++++++ src/commands/arg_parser.rs | 21 + src/commands/autter_handlers.rs | 6 + src/commands/mod.rs | 1 + ...mmands__wrapped__tests__card_in_ascii.snap | 38 + ...ds__wrapped__tests__card_in_truecolor.snap | 38 + ...s__wrapped__tests__card_without_color.snap | 38 + src/commands/wrapped.rs | 845 ++++++++++++++++++ tests/integration/main.rs | 1 + tests/integration/repos/test_repo.rs | 2 +- ...ation__wrapped__wrapped_card_snapshot.snap | 38 + tests/integration/wrapped.rs | 399 +++++++++ 18 files changed, 2057 insertions(+), 10 deletions(-) create mode 100644 changelog/wrapped.md create mode 100644 src/authorship/wrapped.rs create mode 100644 src/commands/snapshots/autter__commands__wrapped__tests__card_in_ascii.snap create mode 100644 src/commands/snapshots/autter__commands__wrapped__tests__card_in_truecolor.snap create mode 100644 src/commands/snapshots/autter__commands__wrapped__tests__card_without_color.snap create mode 100644 src/commands/wrapped.rs create mode 100644 tests/integration/snapshots/integration__wrapped__wrapped_card_snapshot.snap create mode 100644 tests/integration/wrapped.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f176b0..e712cfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ This changelog is automatically generated by AI when releases are published. ### Added +- **`autter wrapped`**: a Spotify-Wrapped-style summary of your year of AI-assisted coding in a repository — AI share overall and by month, top agent and model, most-prompted file, longest prompt, streak and busiest weekday — drawn as a 60-column card for screenshots, with `--json` for the share-image pipeline. Prompt text stays local and is only included with `--include-prompt-text`. See [changelog/wrapped.md](changelog/wrapped.md). + - **Interactive update prompt**: when a newer release is available, autter now offers to install it in place — `Update autter now? [Y/n]`, answered with a single keystroke — instead of only printing `run autter upgrade`. Declining silences the prompt for a week. The prompt only appears on an interactive terminal, never in CI or inside AI agents, is bounded by a timeout so it can't wedge a git command, and runs the upgrade in a child process so a failed download can't change the git command's exit code. Opt out with `disable_update_prompt` in `~/.autter/config.json` or `AUTTER_DISABLE_UPDATE_PROMPT=1`. See [changelog/interactive-update-prompt.md](changelog/interactive-update-prompt.md). - **Panic reports keep their message**: `panic_message` downcast a `&(dyn Any + Send)` produced by an unsizing coercion, which reports the trait-object type rather than the concrete payload — every caught panic reached error tracking as `"unknown panic"`. diff --git a/README.md b/README.md index 1d15a85..3df0f09 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,8 @@ autter stats .. --json It computes % AI code, AI lines generated vs committed, acceptance rates, and human overrides, broken down by tool and model. More in the [stats command reference](https://autter.dev/docs/cli/reference#stats). +For a year at a glance, `autter wrapped` draws a screenshot-ready card of your AI-assisted coding in the repository: AI share by month, top agent and model, most-prompted file, longest prompt, streak and busiest weekday. `--json` prints the same numbers; prompt text never leaves the machine unless you pass `--include-prompt-text`. +
Example JSON output diff --git a/changelog/wrapped.md b/changelog/wrapped.md new file mode 100644 index 0000000..69bcf2e --- /dev/null +++ b/changelog/wrapped.md @@ -0,0 +1,24 @@ +# `autter wrapped` + +## Summary + +A Spotify-Wrapped-style summary of your year of AI-assisted coding in a repository, drawn as a 60-column card made for screenshots, with a JSON form that the share-image pipeline consumes. + +## Added + +- **`autter wrapped [--year ] [--author | --all-authors] [--json] [--include-prompt-text] [--no-color]`**: walks the year's commits (yours by default, from `git user.email`), reads each authorship note and folds the per-commit stats into one summary: + - the AI share of added lines, overall and by month; + - the top agent and top model by accepted lines, with the full ranked lists; + - the file with the most distinct prompts attached to it, with the top five; + - the longest first prompt of the year by character count; + - the longest streak of days with an AI-attributed commit and the busiest weekday. +- The terminal card shows the headline percentage in block digits, a twelve-bar monthly chart, the rows above and an `autter.dev/wrapped` footer. It honors `NO_COLOR` and `--no-color`, falls back to ASCII on terminals without Unicode, and is deterministic for a given summary so screenshots come out the same every time. +- With fewer than three noted commits in the year, the command prints a short note instead of a card. + +## Privacy + +Nothing leaves the machine. The longest prompt is measured from transcripts already in the local cache; prompts that only exist remotely are skipped. Its text is never printed or included in the JSON unless `--include-prompt-text` is passed, and then only the first 120 characters. + +## JSON + +`autter wrapped --json` prints a `wrapped/1.0.0` document: `year`, `author`, `commits`, `commits_with_authorship`, `added_lines`, `ai_lines`, `human_lines`, `untracked_lines`, `ai_percent`, `by_month` (12 entries of `month`, `ai_accepted`, `added_lines`, `ai_percent`, `commits`), `top_agent`, `top_model`, `agents`, `models`, `most_prompted_file`, `files`, `prompts_total`, `prompts_with_transcript`, `longest_prompt` (`chars`, `words`, `agent`, `model`, `commit`, `snippet`), `busiest_day` and `streak_days`. The walk stops after 5,000 commits and says so in `capped`. diff --git a/src/authorship/cas_bridge.rs b/src/authorship/cas_bridge.rs index e84e46a..7f7d9ed 100644 --- a/src/authorship/cas_bridge.rs +++ b/src/authorship/cas_bridge.rs @@ -201,13 +201,11 @@ pub fn first_user_prompt_from_transcript_file(path: &str) -> Result` URL. -/// -/// Lookup order: -/// 1. Local SQLite cache (`cas_cache`) -/// 2. Local sync queue (`cas_sync_queue`, not yet uploaded) -/// 3. Cloud CAS API (when authenticated) -pub fn resolve_cas_messages(messages_url: &str) -> Result>, AutterError> { +/// Resolve transcript messages from a `cas:` URL using only what is +/// on this machine: the local cache, then the not-yet-uploaded sync queue. +/// Never contacts the platform, so it is safe for commands that must keep +/// prompt text local. +pub fn resolve_cas_messages_local(messages_url: &str) -> Result>, AutterError> { let Some(hash) = messages_url.strip_prefix("cas:") else { return Ok(None); }; @@ -231,6 +229,27 @@ pub fn resolve_cas_messages(messages_url: &str) -> Result>, } } + Ok(None) +} + +/// Resolve transcript messages from a `cas:` URL. +/// +/// Lookup order: +/// 1. Local SQLite cache (`cas_cache`) +/// 2. Local sync queue (`cas_sync_queue`, not yet uploaded) +/// 3. Cloud CAS API (when authenticated) +pub fn resolve_cas_messages(messages_url: &str) -> Result>, AutterError> { + let Some(hash) = messages_url.strip_prefix("cas:") else { + return Ok(None); + }; + if hash.is_empty() { + return Ok(None); + } + + if let Some(messages) = resolve_cas_messages_local(messages_url)? { + return Ok(Some(messages)); + } + let cfg = config::Config::fresh(); // Local mode does not contact the platform at all. if !cfg.platform_sync_enabled() { diff --git a/src/authorship/mod.rs b/src/authorship/mod.rs index 091326d..5399f1d 100644 --- a/src/authorship/mod.rs +++ b/src/authorship/mod.rs @@ -22,3 +22,4 @@ pub mod stats; pub mod transcript; pub mod virtual_attribution; pub mod working_log; +pub mod wrapped; diff --git a/src/authorship/post_commit.rs b/src/authorship/post_commit.rs index 2f5adb2..9748489 100644 --- a/src/authorship/post_commit.rs +++ b/src/authorship/post_commit.rs @@ -768,7 +768,9 @@ fn current_branch(repo: &Repository) -> Option { .and_then(|head_ref| head_ref.shorthand().ok()) } -fn transcript_paths_by_prompt_key(checkpoints: &[Checkpoint]) -> HashMap { +pub(crate) fn transcript_paths_by_prompt_key( + checkpoints: &[Checkpoint], +) -> HashMap { let mut transcript_paths = HashMap::new(); for checkpoint in checkpoints { let (Some(agent_id), Some(metadata)) = (&checkpoint.agent_id, &checkpoint.agent_metadata) @@ -794,7 +796,9 @@ fn transcript_paths_by_prompt_key(checkpoints: &[Checkpoint]) -> HashMap) -> HashMap { +pub(crate) fn first_user_prompts_by_path( + paths: impl Iterator, +) -> HashMap { let mut prompts = HashMap::new(); for path in paths { if prompts.contains_key(&path) { diff --git a/src/authorship/wrapped.rs b/src/authorship/wrapped.rs new file mode 100644 index 0000000..b43a637 --- /dev/null +++ b/src/authorship/wrapped.rs @@ -0,0 +1,570 @@ +//! `autter wrapped`: a year of AI-assisted coding in this repository, in the +//! numbers a share card needs. Walks the year's commits, reads each +//! authorship note, and folds the per-commit stats into one +//! [`WrappedSummary`]: AI share overall and by month, the agents and models +//! behind the AI lines, the file that drew the most prompts, the longest +//! prompt, and the streak and weekday habits. +//! +//! Nothing here leaves the machine. Prompt text is read only from the local +//! transcript cache, and even then only its length is reported unless the +//! caller opts into the 120-character snippet. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use chrono::{Datelike, NaiveDate, Weekday}; +use serde::Serialize; + +use crate::authorship::authorship_log_serialization::{AUTTER_VERSION, AuthorshipLog}; +use crate::authorship::stats::stats_for_commit_stats_with_authorship; +use crate::authorship::transcript::Message; +use crate::authorship::working_log::AgentId; +use crate::error::AutterError; +use crate::git::notes_api::read_authorship; +use crate::git::repository::{Repository, exec_git_allow_nonzero}; + +/// Schema tag on the JSON output. Bump when the shape changes; the share +/// image endpoint validates against it. +pub const WRAPPED_SCHEMA_VERSION: &str = "wrapped/1.0.0"; + +/// The most commits one summary walks. A year of a busy repository fits; +/// beyond this the summary says it was capped rather than running for minutes. +pub const COMMIT_CAP: usize = 5000; + +/// Commits with an authorship note needed before a card is worth showing. +pub const MIN_COMMITS_FOR_CARD: u32 = 3; + +/// Characters of prompt text included in the snippet when opted in. +pub const SNIPPET_CHARS: usize = 120; + +#[derive(Debug, Clone)] +pub struct WrappedOptions { + pub year: i32, + /// Commits by this author email only; `None` keeps every author. + pub author: Option, + /// Include the first 120 characters of the longest prompt. + pub include_prompt_text: bool, + pub ignore_patterns: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct MonthStats { + /// 1 = January … 12 = December. + pub month: u32, + pub ai_accepted: u64, + pub added_lines: u64, + pub ai_percent: f64, + pub commits: u32, +} + +/// One agent or model with the AI lines it accounts for. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct RankedName { + pub name: String, + pub lines: u64, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct RankedFile { + pub path: String, + /// Distinct prompts or sessions attested on this file across the year. + pub prompts: u32, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct LongestPrompt { + pub chars: u64, + pub words: u64, + pub agent: String, + pub model: String, + pub commit: String, + /// First 120 characters of the prompt; `null` unless prompt text was + /// opted in. + pub snippet: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WrappedSummary { + pub schema_version: &'static str, + pub autter_version: &'static str, + pub year: i32, + /// The author email the summary is scoped to; `null` with all authors. + pub author: Option, + pub all_authors: bool, + pub commits: u32, + pub commits_with_authorship: u32, + /// True when the walk stopped at [`COMMIT_CAP`] commits. + pub capped: bool, + pub commit_cap: usize, + pub added_lines: u64, + pub ai_lines: u64, + pub human_lines: u64, + pub untracked_lines: u64, + /// `ai_lines / added_lines`, as a percentage with two decimals. + pub ai_percent: f64, + pub by_month: Vec, + pub top_agent: Option, + pub top_model: Option, + pub agents: Vec, + pub models: Vec, + pub most_prompted_file: Option, + pub files: Vec, + pub prompts_total: u32, + /// Prompts whose transcript is in the local cache, so their length is known. + pub prompts_with_transcript: u32, + pub longest_prompt: Option, + /// Weekday with the most AI lines, e.g. `"Tuesday"`. + pub busiest_day: Option, + /// Longest run of consecutive days with an AI-attributed commit. + pub streak_days: u32, +} + +impl WrappedSummary { + /// Whether there is enough data for a card. + pub fn has_enough_data(&self) -> bool { + self.commits_with_authorship >= MIN_COMMITS_FOR_CARD + } +} + +struct CommitRow { + sha: String, + date: NaiveDate, +} + +/// Build the summary for the year. +pub fn wrapped_summary( + repo: &Repository, + options: &WrappedOptions, +) -> Result { + let (rows, capped) = year_commits(repo, options.year, options.author.as_deref())?; + + let mut months: Vec = (1..=12) + .map(|month| MonthStats { + month, + ai_accepted: 0, + added_lines: 0, + ai_percent: 0.0, + commits: 0, + }) + .collect(); + let mut commits_with_authorship = 0u32; + let mut added_lines = 0u64; + let mut ai_lines = 0u64; + let mut human_lines = 0u64; + let mut untracked_lines = 0u64; + let mut agents: BTreeMap = BTreeMap::new(); + let mut models: BTreeMap = BTreeMap::new(); + let mut prompts_by_file: BTreeMap> = BTreeMap::new(); + let mut prompt_ids: HashSet = HashSet::new(); + let mut ai_by_weekday: [u64; 7] = [0; 7]; + let mut ai_days: BTreeSet = BTreeSet::new(); + let mut prompts_with_transcript = 0u32; + let mut longest: Option = None; + // A transcript shared by several commits is read once. + let mut transcript_lengths: HashMap> = HashMap::new(); + + for row in &rows { + let log = read_authorship(repo, &row.sha); + if log.is_some() { + commits_with_authorship += 1; + } + let stats = stats_for_commit_stats_with_authorship( + repo, + &row.sha, + &options.ignore_patterns, + log.as_ref(), + )?; + + let month = &mut months[(row.date.month0()) as usize]; + month.commits += 1; + month.added_lines += u64::from(stats.git_diff_added_lines); + month.ai_accepted += u64::from(stats.ai_accepted); + + added_lines += u64::from(stats.git_diff_added_lines); + ai_lines += u64::from(stats.ai_accepted); + human_lines += u64::from(stats.human_additions); + untracked_lines += u64::from(stats.unknown_additions); + + if stats.ai_accepted > 0 { + ai_by_weekday[row.date.weekday().num_days_from_monday() as usize] += + u64::from(stats.ai_accepted); + ai_days.insert(row.date); + } + + for (key, headline) in &stats.tool_model_breakdown { + let (tool, model) = split_tool_model(key); + *agents.entry(tool.to_string()).or_default() += u64::from(headline.ai_accepted); + *models.entry(model.to_string()).or_default() += u64::from(headline.ai_accepted); + } + + let Some(log) = log else { + continue; + }; + + for file in &log.attestations { + for entry in &file.entries { + if let Some(id) = prompt_id_from_hash(&entry.hash) { + prompts_by_file + .entry(file.file_path.clone()) + .or_default() + .insert(id.to_string()); + } + } + } + + for (id, agent_id, messages_url) in prompt_records(&log) { + if !prompt_ids.insert(id) { + continue; + } + let Some(url) = messages_url else { + continue; + }; + let measured = transcript_lengths + .entry(url.clone()) + .or_insert_with(|| first_user_prompt_length(&url)) + .clone(); + let Some((chars, words, text)) = measured else { + continue; + }; + prompts_with_transcript += 1; + if longest.as_ref().is_none_or(|best| chars > best.chars) { + longest = Some(LongestPrompt { + chars, + words, + agent: agent_id.tool.clone(), + model: agent_id.model.clone(), + commit: row.sha.clone(), + snippet: options + .include_prompt_text + .then(|| text.chars().take(SNIPPET_CHARS).collect()), + }); + } + } + } + + for month in &mut months { + month.ai_percent = percent(month.ai_accepted, month.added_lines); + } + + let agents = ranked(agents); + let models = ranked(models); + let mut files: Vec = prompts_by_file + .into_iter() + .map(|(path, ids)| RankedFile { + path, + prompts: ids.len() as u32, + }) + .collect(); + files.sort_by(|a, b| b.prompts.cmp(&a.prompts).then_with(|| a.path.cmp(&b.path))); + files.truncate(5); + + Ok(WrappedSummary { + schema_version: WRAPPED_SCHEMA_VERSION, + autter_version: AUTTER_VERSION, + year: options.year, + author: options.author.clone(), + all_authors: options.author.is_none(), + commits: rows.len() as u32, + commits_with_authorship, + capped, + commit_cap: COMMIT_CAP, + added_lines, + ai_lines, + human_lines, + untracked_lines, + ai_percent: percent(ai_lines, added_lines), + by_month: months, + top_agent: agents.first().cloned(), + top_model: models.first().cloned(), + agents, + models, + most_prompted_file: files.first().cloned(), + files, + prompts_total: prompt_ids.len() as u32, + prompts_with_transcript, + longest_prompt: longest, + busiest_day: busiest_weekday(&ai_by_weekday), + streak_days: longest_streak(&ai_days), + }) +} + +/// The year's commits, newest first, by the author when one is given. +/// Merge commits are left out: they add no lines of their own. +fn year_commits( + repo: &Repository, + year: i32, + author: Option<&str>, +) -> Result<(Vec, bool), AutterError> { + let mut args = repo.global_args_for_exec(); + // The window is a day wider on each side than the year because git + // reads `--since`/`--until` in this machine's timezone while the + // commit's own date (what `%ad` prints) decides the bucket below. + args.extend( + [ + "log", + "--no-color", + "--no-merges", + "-n", + &COMMIT_CAP.to_string(), + &format!("--since={}-12-31T00:00:00", year - 1), + &format!("--until={}-01-01T23:59:59", year + 1), + "--date=format:%Y-%m-%d", + "--format=%H%x00%ad%x00%ae", + "HEAD", + "--", + ] + .map(String::from), + ); + let output = exec_git_allow_nonzero(&args)?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // A repository with no commits yet has nothing to wrap up. + if stderr.contains("does not have any commits") + || stderr.contains("unknown revision") + || stderr.contains("bad revision") + { + return Ok((Vec::new(), false)); + } + return Err(AutterError::Generic(format!( + "git log failed: {}", + stderr.trim() + ))); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let mut listed = 0usize; + let mut rows = Vec::new(); + for line in stdout.lines() { + let mut fields = line.split('\0'); + let (Some(sha), Some(date), Some(email)) = (fields.next(), fields.next(), fields.next()) + else { + continue; + }; + listed += 1; + let Ok(date) = NaiveDate::parse_from_str(date, "%Y-%m-%d") else { + continue; + }; + if date.year() != year { + continue; + } + if let Some(author) = author + && !email.eq_ignore_ascii_case(author) + { + continue; + } + rows.push(CommitRow { + sha: sha.to_string(), + date, + }); + } + Ok((rows, listed >= COMMIT_CAP)) +} + +/// `tool::model` keys from `CommitStats::tool_model_breakdown`. +pub fn split_tool_model(key: &str) -> (&str, &str) { + match key.split_once("::") { + Some((tool, model)) => (tool, model), + None => (key, key), + } +} + +/// The prompt or session id behind an attestation hash, or `None` for a +/// known-human entry. Session entries carry a trace suffix +/// (`s_xxx::t_yyy`) that is not part of the id. +fn prompt_id_from_hash(hash: &str) -> Option<&str> { + if hash.starts_with("h_") { + return None; + } + if hash.starts_with("s_") { + return hash.split("::").next(); + } + Some(hash) +} + +/// Every prompt and session record in a note as `(id, agent, messages_url)`. +/// The prompts map mirrors the sessions map, so ids repeat; the caller +/// deduplicates. +fn prompt_records(log: &AuthorshipLog) -> Vec<(String, AgentId, Option)> { + let mut records = Vec::new(); + for (id, record) in &log.metadata.prompts { + records.push(( + id.clone(), + record.agent_id.clone(), + record.messages_url.clone(), + )); + } + for (id, record) in &log.metadata.sessions { + records.push(( + id.clone(), + record.agent_id.clone(), + record.messages_url.clone(), + )); + } + records +} + +/// Length of the first user message of a transcript held in the local cache: +/// `(chars, words, text)`. Transcripts that are not on this machine are +/// skipped; nothing is fetched. +fn first_user_prompt_length(messages_url: &str) -> Option<(u64, u64, String)> { + let messages = crate::authorship::cas_bridge::resolve_cas_messages_local(messages_url) + .ok() + .flatten()?; + let text = messages.into_iter().find_map(|message| match message { + Message::User { text, .. } => Some(text), + _ => None, + })?; + let chars = text.chars().count() as u64; + let words = text.split_whitespace().count() as u64; + Some((chars, words, text)) +} + +fn ranked(counts: BTreeMap) -> Vec { + let mut ranked: Vec = counts + .into_iter() + .filter(|(_, lines)| *lines > 0) + .map(|(name, lines)| RankedName { name, lines }) + .collect(); + ranked.sort_by(|a, b| b.lines.cmp(&a.lines).then_with(|| a.name.cmp(&b.name))); + ranked +} + +fn percent(part: u64, total: u64) -> f64 { + if total == 0 { + 0.0 + } else { + ((part as f64 / total as f64) * 10000.0).round() / 100.0 + } +} + +const WEEKDAYS: [&str; 7] = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", +]; + +/// The weekday (Monday first) with the most AI lines; ties go to the +/// earlier weekday. +fn busiest_weekday(ai_by_weekday: &[u64; 7]) -> Option { + let (index, lines) = + ai_by_weekday + .iter() + .enumerate() + .fold((0usize, 0u64), |best, (i, &lines)| { + if lines > best.1 { (i, lines) } else { best } + }); + (lines > 0).then(|| WEEKDAYS[index].to_string()) +} + +/// Longest run of consecutive dates. +fn longest_streak(days: &BTreeSet) -> u32 { + let mut best = 0u32; + let mut run = 0u32; + let mut previous: Option = None; + for &day in days { + run = match previous { + Some(prev) if prev.succ_opt() == Some(day) => run + 1, + _ => 1, + }; + best = best.max(run); + previous = Some(day); + } + best +} + +/// Full weekday name for a date, as `busiest_day` reports it. +pub fn weekday_name(weekday: Weekday) -> &'static str { + WEEKDAYS[weekday.num_days_from_monday() as usize] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn date(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() + } + + #[test] + fn splits_tool_and_model_keys() { + assert_eq!(split_tool_model("claude::opus"), ("claude", "opus")); + assert_eq!( + split_tool_model("cursor::anthropic/claude-4::fast"), + ("cursor", "anthropic/claude-4::fast") + ); + assert_eq!(split_tool_model("legacy"), ("legacy", "legacy")); + } + + #[test] + fn prompt_ids_skip_humans_and_trace_suffixes() { + assert_eq!(prompt_id_from_hash("h_abc"), None); + assert_eq!(prompt_id_from_hash("s_abc::t_def"), Some("s_abc")); + assert_eq!(prompt_id_from_hash("s_abc"), Some("s_abc")); + assert_eq!(prompt_id_from_hash("1a2b3c4d"), Some("1a2b3c4d")); + } + + #[test] + fn streak_counts_consecutive_days_only() { + let days: BTreeSet = [ + date(2026, 3, 1), + date(2026, 3, 2), + date(2026, 3, 3), + date(2026, 3, 5), + date(2026, 3, 6), + ] + .into_iter() + .collect(); + assert_eq!(longest_streak(&days), 3); + assert_eq!(longest_streak(&BTreeSet::new()), 0); + assert_eq!(longest_streak(&[date(2026, 1, 1)].into_iter().collect()), 1); + } + + #[test] + fn streak_crosses_month_boundaries() { + let days: BTreeSet = [date(2026, 1, 31), date(2026, 2, 1)].into_iter().collect(); + assert_eq!(longest_streak(&days), 2); + } + + #[test] + fn busiest_weekday_prefers_earlier_day_on_ties() { + assert_eq!(busiest_weekday(&[0; 7]), None); + assert_eq!( + busiest_weekday(&[1, 5, 5, 0, 0, 0, 0]), + Some("Tuesday".to_string()) + ); + assert_eq!( + busiest_weekday(&[0, 0, 0, 0, 0, 0, 9]), + Some("Sunday".to_string()) + ); + } + + #[test] + fn ranking_drops_zero_and_breaks_ties_by_name() { + let counts: BTreeMap = [ + ("zed".to_string(), 4), + ("claude".to_string(), 4), + ("cursor".to_string(), 0), + ("codex".to_string(), 9), + ] + .into_iter() + .collect(); + let names: Vec = ranked(counts).into_iter().map(|r| r.name).collect(); + assert_eq!(names, ["codex", "claude", "zed"]); + } + + #[test] + fn percent_rounds_to_two_decimals() { + assert_eq!(percent(0, 0), 0.0); + assert_eq!(percent(9, 14), 64.29); + assert_eq!(percent(2, 3), 66.67); + assert_eq!(percent(3, 3), 100.0); + } + + #[test] + fn weekday_names_are_full() { + assert_eq!(weekday_name(Weekday::Mon), "Monday"); + assert_eq!(weekday_name(Weekday::Sun), "Sunday"); + } +} diff --git a/src/commands/arg_parser.rs b/src/commands/arg_parser.rs index 430bf8c..c4e2d35 100644 --- a/src/commands/arg_parser.rs +++ b/src/commands/arg_parser.rs @@ -357,6 +357,27 @@ autter stats [commit] -i, --interactive Browse recent commits full-screen by AI share, with each commit's breakdown by tool and model", }, + HelpEntry { + name: "wrapped", + aliases: &[], + summary: "Your year of AI-assisted coding in this repo, as a share card", + body: "\ +autter wrapped [options] + + A Spotify-Wrapped-style summary of the current user's AI-assisted coding + in this repository for one year: AI share overall and by month, the top + agent and model, the most-prompted file, the longest prompt, the longest + streak and the busiest weekday. Printed as a fixed-width card made for + screenshots. Nothing leaves the machine. + + --year Year to summarize (default: the current year) + --author Commits by this author (default: git user.email) + --all-authors Every author in the repository + --include-prompt-text Include the first 120 characters of the longest + prompt; off by default so prompt text stays private + --json Output the summary as JSON (the share-card contract) + --no-color Plain text, no ANSI colors", + }, HelpEntry { name: "file-changes", aliases: &[], diff --git a/src/commands/autter_handlers.rs b/src/commands/autter_handlers.rs index dd1de04..2725647 100644 --- a/src/commands/autter_handlers.rs +++ b/src/commands/autter_handlers.rs @@ -196,6 +196,12 @@ pub fn handle_autter(args: &[String]) { } handle_stats(&args[1..]); } + "wrapped" => { + if is_interactive_terminal() { + log_message("wrapped", "info", None) + } + commands::wrapped::handle_wrapped(&args[1..]); + } "file-changes" => { commands::file_changes::handle_file_changes(&args[1..]); } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 8d361b3..774435c 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -39,6 +39,7 @@ pub mod telemetry; pub mod upgrade; pub mod usage_hints; pub mod whoami; +pub mod wrapped; /// Process exit codes for `autter` subcommands. /// diff --git a/src/commands/snapshots/autter__commands__wrapped__tests__card_in_ascii.snap b/src/commands/snapshots/autter__commands__wrapped__tests__card_in_ascii.snap new file mode 100644 index 0000000..b79c323 --- /dev/null +++ b/src/commands/snapshots/autter__commands__wrapped__tests__card_in_ascii.snap @@ -0,0 +1,38 @@ +--- +source: src/commands/wrapped.rs +expression: "render(&summary(), &caps(ColorLevel::None, Charset::Ascii))" +--- +Autter Wrapped 2026 +you@company.com +94 commits · 90 with authorship notes + ++----------------------------------------------------------+ +| | +| ##### #### ## # | +| # # # | +| # ### # | +| # # # | +| # #### # ## | +| | +| of the lines added in 2026 were written with AI | +| | +| AI share by month peak Jul 100% | +| ## ## ## | +| ## ## ## ## ## | +| ## ## ## ## ## ## | +| ## ## __ . ## ## ## . ## ## . | +| Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec | +| | +| Top agent claude · 3,100 lines | +| Top model claude-opus-5-5 · 2,400 lines | +| Most prompted …ands/autter_handlers.rs · 42 prompts | +| Longest prompt 1,834 chars · 312 words · claude | +| Streak 14 days in a row with AI commits | +| Busiest day Tuesday · most AI lines | +| | +| 94 commits · 3,940 AI lines | +| 1,200 human · 290 untracked lines | +| | +| autter.dev/wrapped | +| | ++----------------------------------------------------------+ diff --git a/src/commands/snapshots/autter__commands__wrapped__tests__card_in_truecolor.snap b/src/commands/snapshots/autter__commands__wrapped__tests__card_in_truecolor.snap new file mode 100644 index 0000000..8ef01c2 --- /dev/null +++ b/src/commands/snapshots/autter__commands__wrapped__tests__card_in_truecolor.snap @@ -0,0 +1,38 @@ +--- +source: src/commands/wrapped.rs +expression: "render(&summary(), &caps(ColorLevel::TrueColor, Charset::Unicode))" +--- + ▗▟▀▀▙▟▌ Autter Wrapped 2026 + ▐▌ ▐█▌ you@company.com + ▝▜▄▄▛▜▌ ██ 94 commits · 90 with authorship notes + +┌──────────────────────────────────────────────────────────┐ +│ │ +│ █████ ████ ██ █ │ +│  █ █ █  │ +│  █ ███ █  │ +│  █ █ █  │ +│  █ ████ █ ██ │ +│ │ +│ of the lines added in 2026 were written with AI │ +│ │ +│ AI share by month peak Jul 100% │ +│     ▅▅  ██    ▆▆   │ +│  ▃▃   ██ ██ ██   ▇▇ ██   │ +│ ▂▂ ██   ██ ██ ██   ██ ██   │ +│ ██ ██ ▁▁ · ██ ██ ██ · ▂▂ ██ ██ ·  │ +│ Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec │ +│ │ +│ Top agent claude · 3,100 lines │ +│ Top model claude-opus-5-5 · 2,400 lines │ +│ Most prompted …ands/autter_handlers.rs · 42 prompts │ +│ Longest prompt 1,834 chars · 312 words · claude │ +│ Streak 14 days in a row with AI commits │ +│ Busiest day Tuesday · most AI lines │ +│ │ +│ 94 commits · 3,940 AI lines │ +│ 1,200 human · 290 untracked lines │ +│ │ +│ autter.dev/wrapped │ +│ │ +└──────────────────────────────────────────────────────────┘ diff --git a/src/commands/snapshots/autter__commands__wrapped__tests__card_without_color.snap b/src/commands/snapshots/autter__commands__wrapped__tests__card_without_color.snap new file mode 100644 index 0000000..236fef6 --- /dev/null +++ b/src/commands/snapshots/autter__commands__wrapped__tests__card_without_color.snap @@ -0,0 +1,38 @@ +--- +source: src/commands/wrapped.rs +expression: "render(&summary(), &caps(ColorLevel::None, Charset::Unicode))" +--- + ▗▟▀▀▙▟▌ Autter Wrapped 2026 + ▐▌ ▐█▌ you@company.com + ▝▜▄▄▛▜▌ ██ 94 commits · 90 with authorship notes + +┌──────────────────────────────────────────────────────────┐ +│ │ +│ █████ ████ ██ █ │ +│ █ █ █ │ +│ █ ███ █ │ +│ █ █ █ │ +│ █ ████ █ ██ │ +│ │ +│ of the lines added in 2026 were written with AI │ +│ │ +│ AI share by month peak Jul 100% │ +│ ▅▅ ██ ▆▆ │ +│ ▃▃ ██ ██ ██ ▇▇ ██ │ +│ ▂▂ ██ ██ ██ ██ ██ ██ │ +│ ██ ██ ▁▁ · ██ ██ ██ · ▂▂ ██ ██ · │ +│ Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec │ +│ │ +│ Top agent claude · 3,100 lines │ +│ Top model claude-opus-5-5 · 2,400 lines │ +│ Most prompted …ands/autter_handlers.rs · 42 prompts │ +│ Longest prompt 1,834 chars · 312 words · claude │ +│ Streak 14 days in a row with AI commits │ +│ Busiest day Tuesday · most AI lines │ +│ │ +│ 94 commits · 3,940 AI lines │ +│ 1,200 human · 290 untracked lines │ +│ │ +│ autter.dev/wrapped │ +│ │ +└──────────────────────────────────────────────────────────┘ diff --git a/src/commands/wrapped.rs b/src/commands/wrapped.rs new file mode 100644 index 0000000..4a37219 --- /dev/null +++ b/src/commands/wrapped.rs @@ -0,0 +1,845 @@ +//! `autter wrapped`: the year's AI-assisted coding as a share card. The +//! numbers come from `authorship::wrapped`; this module parses the flags, +//! prints the JSON contract for the share-image pipeline, or draws the +//! fixed-width card. The card is 60 columns, deterministic for a given +//! summary, and goes to stdout so it can be piped into a screenshot tool. + +use crate::authorship::ignore::effective_ignore_patterns; +use crate::authorship::wrapped::{ + MIN_COMMITS_FOR_CARD, WrappedOptions, WrappedSummary, wrapped_summary, +}; +use crate::commands::{self, arg_parser}; +use crate::git::find_repository; +use crate::ui; +use crate::ui::rail; +use crate::ui::theme::{Charset, TermCaps}; + +/// Outer width of the card, borders included. +const CARD_WIDTH: usize = 60; +/// Columns between the two borders. +const INNER_WIDTH: usize = CARD_WIDTH - 2; +/// Margin inside the card, on both sides. +const INDENT: usize = 3; +/// Columns available to content between the margins. +const CONTENT_WIDTH: usize = INNER_WIDTH - 2 * INDENT; +/// Width of the label column in the stats rows. +const LABEL_WIDTH: usize = 15; +/// Rows of the monthly bar chart. +const CHART_ROWS: usize = 4; +const FOOTER: &str = "autter.dev/wrapped"; +const MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +pub fn handle_wrapped(args: &[String]) { + let pp = match arg_parser::pre_parse(args, arg_parser::ScanMode::Full, false) { + Ok(p) => p, + Err(e) => { + eprintln!("error: {}", e); + std::process::exit(commands::EXIT_USAGE_ERROR); + } + }; + if pp.flags.help { + arg_parser::print_command_help("wrapped"); + return; + } + arg_parser::merge_global_flags(&pp.flags); + + let mut year: Option = None; + let mut author: Option = None; + let mut all_authors = false; + let mut include_prompt_text = false; + let mut ignore_patterns: Vec = Vec::new(); + + let usage = |message: &str| -> ! { + eprintln!("{message}"); + eprintln!("Run 'autter wrapped --help' for usage."); + std::process::exit(commands::EXIT_USAGE_ERROR); + }; + + let rest = &pp.rest; + let mut i = 0; + while i < rest.len() { + let arg = rest[i].as_str(); + let (flag, inline_value) = match arg.split_once('=') { + Some((flag, value)) if flag.starts_with("--") => (flag, Some(value.to_string())), + _ => (arg, None), + }; + let mut take_value = |what: &str| -> String { + if let Some(value) = inline_value.clone() { + return value; + } + i += 1; + match rest.get(i) { + Some(value) if !value.starts_with("--") => value.clone(), + _ => usage(&format!("{flag} requires {what}")), + } + }; + match flag { + "--year" => { + let raw = take_value("a year, e.g. --year 2026"); + match raw.parse::() { + Ok(parsed) if (1970..=9999).contains(&parsed) => year = Some(parsed), + _ => usage(&format!("--year expects a four-digit year, got '{raw}'")), + } + } + "--author" => { + let email = take_value("an email address"); + if email.trim().is_empty() { + usage("--author expects an email address"); + } + author = Some(email.trim().to_string()); + } + "--all-authors" => all_authors = true, + "--include-prompt-text" => include_prompt_text = true, + "--ignore" => ignore_patterns.push(take_value("a pattern")), + other => usage(&format!("Unknown wrapped argument: {other}")), + } + i += 1; + } + if all_authors && author.is_some() { + usage("--author and --all-authors cannot be combined"); + } + + let repo = match find_repository(&Vec::::new()) { + Ok(repo) => repo, + Err(e) => { + eprintln!("Failed to find repository: {}", e); + std::process::exit(commands::EXIT_RUNTIME_ERROR); + } + }; + + let author = if all_authors { + None + } else if let Some(author) = author { + Some(author) + } else { + match repo.git_commit_author_identity().email { + Some(email) if !email.trim().is_empty() => Some(email.trim().to_string()), + _ => usage( + "No git author email is configured. Set user.email, or pass --author or --all-authors.", + ), + } + }; + + let options = WrappedOptions { + year: year.unwrap_or_else(current_year), + author, + include_prompt_text, + ignore_patterns: effective_ignore_patterns(&repo, &ignore_patterns, &[]), + }; + + let summary = match wrapped_summary(&repo, &options) { + Ok(summary) => summary, + Err(e) => { + eprintln!("Wrapped failed: {}", e); + std::process::exit(commands::EXIT_RUNTIME_ERROR); + } + }; + + if arg_parser::json() { + match serde_json::to_string_pretty(&summary) { + Ok(json) => println!("{json}"), + Err(e) => { + eprintln!("Wrapped failed: {}", e); + std::process::exit(commands::EXIT_RUNTIME_ERROR); + } + } + return; + } + + print!("{}", render(&summary, &card_caps())); +} + +fn current_year() -> i32 { + use chrono::Datelike; + chrono::Local::now().year() +} + +/// Capabilities for the card on stdout. The card is decorative and meant +/// to be piped into screenshot tools, so it always draws as if on a wide +/// terminal; only the color decision follows stdout (`--no-color`, +/// `NO_COLOR`, not a TTY). +fn card_caps() -> TermCaps { + TermCaps::from_env(cfg!(windows), true, arg_parser::use_color(), 80, |key| { + std::env::var(key).ok() + }) +} + +/// Box, bar and big-digit glyphs for one charset. +struct CardGlyphs { + top_left: &'static str, + top_right: &'static str, + bottom_left: &'static str, + bottom_right: &'static str, + horizontal: &'static str, + vertical: &'static str, + full: &'static str, + /// One to seven eighths of a block, bottom-up. + eighths: [&'static str; 7], + /// A month without commits. + empty_month: &'static str, + /// A month with commits but no AI lines. + zero_month: &'static str, +} + +const UNICODE_CARD: CardGlyphs = CardGlyphs { + top_left: "┌", + top_right: "┐", + bottom_left: "└", + bottom_right: "┘", + horizontal: "─", + vertical: "│", + full: "█", + eighths: ["▁", "▂", "▃", "▄", "▅", "▆", "▇"], + empty_month: "·", + zero_month: "▁", +}; + +const ASCII_CARD: CardGlyphs = CardGlyphs { + top_left: "+", + top_right: "+", + bottom_left: "+", + bottom_right: "+", + horizontal: "-", + vertical: "|", + full: "#", + eighths: [" ", " ", " ", "#", "#", "#", "#"], + empty_month: ".", + zero_month: "_", +}; + +/// Five-row block font for the headline percentage: digits then `%`. +/// `#` is a block, space is empty. +const BIG_FONT: [[&str; 5]; 11] = [ + [" ### ", "# #", "# #", "# #", " ### "], + [" # ", " ## ", " # ", " # ", " ### "], + [" ### ", "# #", " # ", " # ", "#####"], + ["#### ", " #", " ### ", " #", "#### "], + ["# # ", "# # ", "#####", " # ", " # "], + ["#####", "# ", "#### ", " #", "#### "], + [" ### ", "# ", "#### ", "# #", " ### "], + ["#####", " #", " # ", " # ", " # "], + [" ### ", "# #", " ### ", "# #", " ### "], + [" ### ", "# #", " ####", " #", " ### "], + ["## #", " # ", " # ", " # ", "# ##"], +]; + +/// A styled piece of a card row. `style` is an SGR code for `caps.paint`, +/// `"accent"` for the brand color, or empty for plain text. +struct Segment { + style: &'static str, + text: String, +} + +fn seg(style: &'static str, text: impl Into) -> Segment { + Segment { + style, + text: text.into(), + } +} + +fn dim(text: impl Into) -> Segment { + seg("2", text) +} + +fn bold(text: impl Into) -> Segment { + seg("1", text) +} + +fn accent(text: impl Into) -> Segment { + seg("accent", text) +} + +struct Card<'a> { + caps: &'a TermCaps, + glyphs: &'static CardGlyphs, + out: String, +} + +impl Card<'_> { + fn paint(&self, segment: &Segment) -> String { + match segment.style { + "" => segment.text.clone(), + "accent" => self.caps.accent(&segment.text), + sgr => self.caps.paint(sgr, &segment.text), + } + } + + fn border(&mut self, left: &str, right: &str) { + let line = format!( + "{left}{}{right}", + self.glyphs.horizontal.repeat(INNER_WIDTH) + ); + self.out.push_str(&self.caps.paint("2", &line)); + self.out.push('\n'); + } + + /// One row: the segments left-aligned inside the margin, `right` + /// right-aligned against the other margin, padded to the inner width. + fn row(&mut self, left: &[Segment], right: &[Segment]) { + let left_width: usize = left.iter().map(|s| s.text.chars().count()).sum(); + let right_width: usize = right.iter().map(|s| s.text.chars().count()).sum(); + let used = left_width + right_width; + let gap = CONTENT_WIDTH + .saturating_sub(used) + .max(if right.is_empty() { 0 } else { 1 }); + let mut body = String::new(); + body.push_str(&" ".repeat(INDENT)); + for segment in left { + body.push_str(&self.paint(segment)); + } + body.push_str(&" ".repeat(gap)); + for segment in right { + body.push_str(&self.paint(segment)); + } + // Over-long content gets no padding rather than a broken border. + let trailing = INNER_WIDTH.saturating_sub(INDENT + left_width + gap + right_width); + body.push_str(&" ".repeat(trailing)); + let bar = self.caps.paint("2", self.glyphs.vertical); + self.out.push_str(&format!("{bar}{body}{bar}\n")); + } + + fn blank(&mut self) { + self.row(&[], &[]); + } + + /// A stats row: dimmed label column, then the value segments. + fn labeled(&mut self, label: &str, value: Vec) { + let mut segments = vec![dim(format!("{label: String { + if !summary.has_enough_data() { + return render_empty_state(summary, caps); + } + let glyphs = if caps.charset == Charset::Unicode { + &UNICODE_CARD + } else { + &ASCII_CARD + }; + let mut card = Card { + caps, + glyphs, + out: String::new(), + }; + + card.out.push_str(&header(summary).render(caps)); + card.out.push('\n'); + + card.border(glyphs.top_left, glyphs.top_right); + card.blank(); + + // Headline percentage in the block font. + let headline = format!("{}%", summary.ai_percent.round() as u64); + for row in big_text(&headline, glyphs) { + card.row(&[accent(row)], &[]); + } + card.blank(); + card.row( + &[dim(format!( + "of the lines added in {} were written with AI", + summary.year + ))], + &[], + ); + card.blank(); + + // Monthly chart. + let peak = summary + .by_month + .iter() + .filter(|month| month.commits > 0) + .max_by(|a, b| { + a.ai_percent + .partial_cmp(&b.ai_percent) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| b.month.cmp(&a.month)) + }); + let peak_label = peak + .map(|month| { + format!( + "peak {} {}%", + MONTHS[(month.month - 1) as usize], + month.ai_percent.round() as u64 + ) + }) + .unwrap_or_default(); + card.row(&[dim("AI share by month")], &[dim(peak_label)]); + for row in chart_rows(summary, glyphs) { + card.row(&row, &[]); + } + card.row(&[dim(MONTHS.join(" "))], &[]); + card.blank(); + + // Who and what. + card.labeled( + "Top agent", + match &summary.top_agent { + Some(agent) => vec![ + bold(agent.name.clone()), + dim(format!(" · {} lines", thousands(agent.lines))), + ], + None => vec![dim("no AI lines yet")], + }, + ); + card.labeled( + "Top model", + match &summary.top_model { + Some(model) => vec![ + bold(fit(&model.name, 24)), + dim(format!(" · {} lines", thousands(model.lines))), + ], + None => vec![dim("no AI lines yet")], + }, + ); + card.labeled( + "Most prompted", + match &summary.most_prompted_file { + Some(file) => { + let suffix = format!(" · {} prompts", file.prompts); + let room = CONTENT_WIDTH - LABEL_WIDTH - suffix.chars().count(); + vec![bold(fit_path(&file.path, room)), dim(suffix)] + } + None => vec![dim("no prompted files yet")], + }, + ); + match &summary.longest_prompt { + Some(prompt) => { + card.labeled( + "Longest prompt", + vec![ + bold(format!("{} chars", thousands(prompt.chars))), + dim(format!( + " · {} words · {}", + thousands(prompt.words), + prompt.agent + )), + ], + ); + if let Some(snippet) = &prompt.snippet { + let room = CONTENT_WIDTH - LABEL_WIDTH - 2; + let one_line: String = snippet.split_whitespace().collect::>().join(" "); + card.labeled( + "", + vec![dim(format!("\u{201C}{}\u{201D}", fit(&one_line, room)))], + ); + } + } + None => card.labeled("Longest prompt", vec![dim("no transcripts cached locally")]), + } + card.labeled( + "Streak", + match summary.streak_days { + 0 => vec![dim("no AI commits yet")], + 1 => vec![bold("1 day"), dim(" with AI commits")], + days => vec![ + bold(format!("{days} days")), + dim(" in a row with AI commits"), + ], + }, + ); + card.labeled( + "Busiest day", + match &summary.busiest_day { + Some(day) => vec![bold(day.clone()), dim(" · most AI lines")], + None => vec![dim("no AI commits yet")], + }, + ); + card.blank(); + + // Totals. + card.row( + &[dim(format!( + "{} commits · {} AI lines", + thousands(u64::from(summary.commits)), + thousands(summary.ai_lines) + ))], + &[], + ); + card.row( + &[dim(format!( + "{} human · {} untracked lines", + thousands(summary.human_lines), + thousands(summary.untracked_lines) + ))], + &[], + ); + if summary.capped { + card.row( + &[dim(format!( + "first {} commits of the year only", + thousands(summary.commit_cap as u64) + ))], + &[], + ); + } + card.blank(); + card.row(&[], &[accent(FOOTER)]); + card.blank(); + card.border(glyphs.bottom_left, glyphs.bottom_right); + card.out +} + +fn header(summary: &WrappedSummary) -> ui::Header { + let who = summary + .author + .clone() + .unwrap_or_else(|| "all authors".to_string()); + ui::Header::new( + "Autter Wrapped", + summary.year.to_string(), + who, + format!( + "{} commits · {} with authorship notes", + thousands(u64::from(summary.commits)), + thousands(u64::from(summary.commits_with_authorship)) + ), + ) +} + +/// What `autter wrapped` prints when the year has too few noted commits. +fn render_empty_state(summary: &WrappedSummary, caps: &TermCaps) -> String { + let noted = summary.commits_with_authorship; + let found = match (summary.commits, noted) { + (0, _) => format!("No commits in {} yet.", summary.year), + (commits, 0) => format!( + "{} in {}, none with an authorship note.", + plural(commits, "commit"), + summary.year + ), + (_, noted) => format!( + "{} with an authorship note in {}.", + plural(noted, "commit"), + summary.year + ), + }; + [ + rail::step(caps, &format!("Autter Wrapped {}", summary.year), None), + rail::note( + caps, + &format!( + "{found} A card needs at least {MIN_COMMITS_FOR_CARD}.\n\ + Commit with an autter-tracked agent, then run autter wrapped again." + ), + ), + rail::end(caps, FOOTER), + ] + .concat() +} + +fn plural(count: u32, noun: &str) -> String { + if count == 1 { + format!("1 {noun}") + } else { + format!("{count} {noun}s") + } +} + +/// `text` in the five-row block font, one string per row. +fn big_text(text: &str, glyphs: &CardGlyphs) -> Vec { + let mut rows = vec![String::new(); 5]; + for (i, ch) in text.chars().enumerate() { + let glyph = match ch { + '0'..='9' => &BIG_FONT[ch as usize - '0' as usize], + _ => &BIG_FONT[10], + }; + for (row, line) in rows.iter_mut().zip(glyph.iter()) { + if i > 0 { + row.push(' '); + } + for cell in line.chars() { + row.push_str(if cell == '#' { glyphs.full } else { " " }); + } + } + } + rows +} + +/// The bar chart, top row first. Each month is a two-column bar scaled to +/// its AI share, in eighths of a row; months without commits show a dot. +fn chart_rows(summary: &WrappedSummary, glyphs: &CardGlyphs) -> Vec> { + let heights: Vec> = summary + .by_month + .iter() + .map(|month| { + if month.commits == 0 { + return None; + } + let eighths = (month.ai_percent / 100.0 * (CHART_ROWS * 8) as f64).round() as usize; + let eighths = if month.ai_percent > 0.0 { + eighths.clamp(1, CHART_ROWS * 8) + } else { + 0 + }; + Some(eighths) + }) + .collect(); + + (0..CHART_ROWS) + .rev() + .map(|from_bottom| { + let mut segments = Vec::new(); + for (i, height) in heights.iter().enumerate() { + let pad = if i + 1 < MONTHS.len() { " " } else { "" }; + let cell: String = match height { + None if from_bottom == 0 => { + segments.push(dim(format!("{} {pad}", glyphs.empty_month))); + continue; + } + Some(0) if from_bottom == 0 => { + segments.push(dim(format!("{}{pad}", glyphs.zero_month.repeat(2)))); + continue; + } + None => " ".to_string(), + Some(eighths) => { + let full = eighths / 8; + let part = eighths % 8; + if from_bottom < full { + glyphs.full.repeat(2) + } else if from_bottom == full && part > 0 { + glyphs.eighths[part - 1].repeat(2) + } else { + " ".to_string() + } + } + }; + segments.push(accent(format!("{cell}{pad}"))); + } + segments + }) + .collect() +} + +/// `n` with thousands separators. +fn thousands(n: u64) -> String { + let digits = n.to_string(); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (i, ch) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(ch); + } + out +} + +/// `text` cut to `width` characters with a trailing ellipsis. +fn fit(text: &str, width: usize) -> String { + if text.chars().count() <= width { + return text.to_string(); + } + let keep = width.saturating_sub(1); + let mut out: String = text.chars().take(keep).collect(); + out.push('\u{2026}'); + out +} + +/// A path cut from the left so its file name stays visible. +fn fit_path(path: &str, width: usize) -> String { + if path.chars().count() <= width { + return path.to_string(); + } + let keep = width.saturating_sub(1); + let tail: String = path + .chars() + .rev() + .take(keep) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("\u{2026}{tail}") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::authorship::wrapped::{ + COMMIT_CAP, LongestPrompt, MonthStats, RankedFile, RankedName, WRAPPED_SCHEMA_VERSION, + }; + use crate::ui::theme::ColorLevel; + + fn caps(color: ColorLevel, charset: Charset) -> TermCaps { + TermCaps { + tty: true, + color, + charset, + width: 100, + } + } + + fn month(month: u32, ai: u64, added: u64, commits: u32) -> MonthStats { + MonthStats { + month, + ai_accepted: ai, + added_lines: added, + ai_percent: if added == 0 { + 0.0 + } else { + (ai as f64 / added as f64 * 10000.0).round() / 100.0 + }, + commits, + } + } + + fn summary() -> WrappedSummary { + let by_month = vec![ + month(1, 120, 400, 9), + month(2, 300, 500, 12), + month(3, 0, 80, 2), + month(4, 0, 0, 0), + month(5, 900, 1000, 20), + month(6, 450, 600, 8), + month(7, 700, 700, 15), + month(8, 0, 0, 0), + month(9, 20, 400, 3), + month(10, 650, 900, 11), + month(11, 800, 850, 14), + month(12, 0, 0, 0), + ]; + WrappedSummary { + schema_version: WRAPPED_SCHEMA_VERSION, + autter_version: "test", + year: 2026, + author: Some("you@company.com".to_string()), + all_authors: false, + commits: 94, + commits_with_authorship: 90, + capped: false, + commit_cap: COMMIT_CAP, + added_lines: 5430, + ai_lines: 3940, + human_lines: 1200, + untracked_lines: 290, + ai_percent: 72.56, + by_month, + top_agent: Some(RankedName { + name: "claude".to_string(), + lines: 3100, + }), + top_model: Some(RankedName { + name: "claude-opus-5-5".to_string(), + lines: 2400, + }), + agents: vec![], + models: vec![], + most_prompted_file: Some(RankedFile { + path: "src/commands/autter_handlers.rs".to_string(), + prompts: 42, + }), + files: vec![], + prompts_total: 310, + prompts_with_transcript: 280, + longest_prompt: Some(LongestPrompt { + chars: 1834, + words: 312, + agent: "claude".to_string(), + model: "claude-opus-5-5".to_string(), + commit: "abc123".to_string(), + snippet: None, + }), + busiest_day: Some("Tuesday".to_string()), + streak_days: 14, + } + } + + #[test] + fn card_without_color() { + insta::assert_snapshot!(render( + &summary(), + &caps(ColorLevel::None, Charset::Unicode) + )); + } + + #[test] + fn card_in_truecolor() { + insta::assert_snapshot!(render( + &summary(), + &caps(ColorLevel::TrueColor, Charset::Unicode) + )); + } + + #[test] + fn card_in_ascii() { + insta::assert_snapshot!(render(&summary(), &caps(ColorLevel::None, Charset::Ascii))); + } + + #[test] + fn every_card_line_is_sixty_columns() { + let text = render(&summary(), &caps(ColorLevel::None, Charset::Unicode)); + let card_lines: Vec<&str> = text + .lines() + .skip_while(|line| !line.starts_with('┌')) + .collect(); + assert!(card_lines.len() > 20, "{text}"); + for line in card_lines { + assert_eq!(line.chars().count(), CARD_WIDTH, "{line:?}"); + } + } + + #[test] + fn snippet_is_shown_only_when_present() { + let mut with_snippet = summary(); + with_snippet.longest_prompt.as_mut().unwrap().snippet = + Some("Please refactor the\nconfig loader so it validates every field before use and reports all errors at once, not just the first".to_string()); + let text = render(&with_snippet, &caps(ColorLevel::None, Charset::Unicode)); + assert!( + text.contains("\u{201C}Please refactor the config loader"), + "{text}" + ); + assert!(text.contains('\u{2026}'), "long snippets are cut: {text}"); + let without = render(&summary(), &caps(ColorLevel::None, Charset::Unicode)); + assert!(!without.contains('\u{201C}'), "{without}"); + } + + #[test] + fn empty_state_when_too_few_commits() { + let mut sparse = summary(); + sparse.commits = 2; + sparse.commits_with_authorship = 1; + let text = render(&sparse, &caps(ColorLevel::None, Charset::Unicode)); + assert_eq!( + text, + "◇ Autter Wrapped 2026\n\ + │ 1 commit with an authorship note in 2026. A card needs at least 3.\n\ + │ Commit with an autter-tracked agent, then run autter wrapped again.\n\ + └ autter.dev/wrapped\n" + ); + sparse.commits = 0; + sparse.commits_with_authorship = 0; + let text = render(&sparse, &caps(ColorLevel::None, Charset::Unicode)); + assert!(text.contains("No commits in 2026 yet."), "{text}"); + } + + #[test] + fn big_font_covers_every_digit() { + let rows = big_text("0123456789%", &UNICODE_CARD); + assert_eq!(rows.len(), 5); + for row in &rows { + assert_eq!(row.chars().count(), 11 * 5 + 10); + } + } + + #[test] + fn numbers_get_thousands_separators() { + assert_eq!(thousands(0), "0"); + assert_eq!(thousands(999), "999"); + assert_eq!(thousands(1000), "1,000"); + assert_eq!(thousands(1234567), "1,234,567"); + } + + #[test] + fn paths_are_cut_from_the_left() { + assert_eq!(fit_path("src/main.rs", 20), "src/main.rs"); + assert_eq!( + fit_path("src/commands/autter_handlers.rs", 12), + "…handlers.rs" + ); + assert_eq!(fit("abcdef", 4), "abc…"); + } +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 02e254f..bebadaf 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -151,3 +151,4 @@ mod utf8_filenames; mod virtual_attribution_unit; mod windsurf; mod worktrees; +mod wrapped; diff --git a/tests/integration/repos/test_repo.rs b/tests/integration/repos/test_repo.rs index a71fb67..527dfee 100644 --- a/tests/integration/repos/test_repo.rs +++ b/tests/integration/repos/test_repo.rs @@ -912,7 +912,7 @@ fn parse_checkpoint_request_count(stdout: &str) -> u64 { fn autter_command_requires_daemon_sync(args: &[&str]) -> bool { matches!( autter_primary_command(args), - Some("blame" | "continue" | "diff" | "prompts" | "search" | "stats") + Some("blame" | "continue" | "diff" | "prompts" | "search" | "stats" | "wrapped") ) } diff --git a/tests/integration/snapshots/integration__wrapped__wrapped_card_snapshot.snap b/tests/integration/snapshots/integration__wrapped__wrapped_card_snapshot.snap new file mode 100644 index 0000000..19dfb63 --- /dev/null +++ b/tests/integration/snapshots/integration__wrapped__wrapped_card_snapshot.snap @@ -0,0 +1,38 @@ +--- +source: tests/integration/wrapped.rs +expression: card +--- + ▗▟▀▀▙▟▌ Autter Wrapped 2031 + ▐▌ ▐█▌ test@example.com + ▝▜▄▄▛▜▌ ██ 4 commits · 4 with authorship notes + +┌──────────────────────────────────────────────────────────┐ +│ │ +│ ███ █ █ ██ █ │ +│ █ █ █ █ │ +│ ████ █████ █ │ +│ █ █ █ █ │ +│ ███ █ █ ██ │ +│ │ +│ of the lines added in 2031 were written with AI │ +│ │ +│ AI share by month peak Jul 100% │ +│ ██ │ +│ ▅▅ ██ │ +│ ██ ██ │ +│ ▁▁ · ██ · · · ██ · · · · · │ +│ Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec │ +│ │ +│ Top agent mock_ai · 6 lines │ +│ Top model mock_ai/unknown-model · 6 lines │ +│ Most prompted src/app.rs · 3 prompts │ +│ Longest prompt 155 chars · 29 words · claude │ +│ Streak 2 days in a row with AI commits │ +│ Busiest day Monday · most AI lines │ +│ │ +│ 4 commits · 9 AI lines │ +│ 5 human · 0 untracked lines │ +│ │ +│ autter.dev/wrapped │ +│ │ +└──────────────────────────────────────────────────────────┘ diff --git a/tests/integration/wrapped.rs b/tests/integration/wrapped.rs new file mode 100644 index 0000000..a3819f1 --- /dev/null +++ b/tests/integration/wrapped.rs @@ -0,0 +1,399 @@ +//! `autter wrapped`: a fixture year of commits with explicit checkpoints +//! and dates, checked through the JSON contract and the `--no-color` card. + +use crate::repos::test_repo::TestRepo; +use serde_json::{Value, json}; +use std::fs; +use std::path::Path; + +const YEAR: &str = "2031"; + +/// The user message in the Claude transcript; its length is the longest +/// prompt of the year. +const CLAUDE_PROMPT: &str = "Please refactor the config loader so it validates every field before \ +use and reports all of the errors at once instead of failing on the first one it finds"; + +fn commit_on(repo: &TestRepo, message: &str, date: &str) -> String { + let envs = [("GIT_AUTHOR_DATE", date), ("GIT_COMMITTER_DATE", date)]; + repo.stage_all_and_commit_with_env(message, &envs) + .unwrap_or_else(|e| panic!("commit {message:?} should succeed: {e}")) + .commit_sha +} + +fn write_claude_transcript(path: &Path, session_id: &str) { + let user = json!({ + "type": "user", + "sessionId": session_id, + "uuid": "u-1", + "timestamp": "2031-07-07T10:00:00.000Z", + "message": {"role": "user", "content": CLAUDE_PROMPT}, + }); + let assistant = json!({ + "type": "assistant", + "sessionId": session_id, + "uuid": "u-2", + "timestamp": "2031-07-07T10:00:05.000Z", + "message": { + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": [{"type": "text", "text": "On it."}], + }, + }); + fs::write(path, format!("{user}\n{assistant}\n")).unwrap(); +} + +/// Four commits by the default author across January, March and July of +/// 2031 plus one July commit by someone else. Returns the SHA of the Claude +/// commit, which carries the only locally cached transcript. +/// +/// Expected for the default author: 14 added lines, 9 AI (64.29%), 5 known +/// human; Monday is the busiest weekday (Mar 10 and Jul 7) and Mar 10-11 is +/// the longest streak. +fn build_fixture(repo: &mut TestRepo) -> String { + repo.patch_autter_config(|patch| { + patch.exclude_prompts_in_repositories = Some(vec![]); + patch.prompt_storage = Some("local".to_string()); + }); + let root = repo.canonical_path(); + fs::create_dir_all(root.join("src")).unwrap(); + let app = root.join("src").join("app.rs"); + let lib = root.join("src").join("lib.rs"); + + // January: two known-human lines. + fs::write(&app, "fn main() {}\nfn helper() {}\n").unwrap(); + repo.autter(&["checkpoint", "mock_known_human", "src/app.rs"]) + .unwrap(); + commit_on(repo, "Scaffold", "2031-01-05T09:00:00Z"); + + // March 10 (Monday): four AI lines from one mock session. + fs::write( + &app, + "fn main() {}\nfn helper() {}\nfn ai_one() {}\nfn ai_two() {}\nfn ai_three() {}\nfn ai_four() {}\n", + ) + .unwrap(); + repo.autter(&["checkpoint", "mock_ai", "src/app.rs"]) + .unwrap(); + commit_on(repo, "Add handlers", "2031-03-10T09:00:00Z"); + + // March 11 (Tuesday): three human lines in a new file, two AI lines in app.rs. + fs::write(&lib, "pub mod a;\npub mod b;\npub mod c;\n").unwrap(); + repo.autter(&["checkpoint", "mock_known_human", "src/lib.rs"]) + .unwrap(); + fs::write( + &app, + "fn main() {}\nfn helper() {}\nfn ai_one() {}\nfn ai_two() {}\nfn ai_three() {}\nfn ai_four() {}\nfn ai_five() {}\nfn ai_six() {}\n", + ) + .unwrap(); + repo.autter(&["checkpoint", "mock_ai", "src/app.rs"]) + .unwrap(); + commit_on(repo, "Add modules", "2031-03-11T09:00:00Z"); + + // July 7 (Monday): three AI lines through the Claude preset with a transcript. + let session_id = "wrapped-claude-session"; + // Outside the working tree so `git add -A` never commits it. + let transcript = repo.test_home_path().join("claude-transcript.jsonl"); + write_claude_transcript(&transcript, session_id); + let hook = |event: &str| { + json!({ + "cwd": root.to_string_lossy(), + "hook_event_name": event, + "session_id": session_id, + "tool_name": "Edit", + "tool_use_id": "edit-1", + "tool_input": {"file_path": app.to_string_lossy()}, + "transcript_path": transcript.to_string_lossy(), + }) + .to_string() + }; + repo.autter(&["checkpoint", "claude", "--hook-input", &hook("PreToolUse")]) + .unwrap(); + fs::write( + &app, + "fn main() {}\nfn helper() {}\nfn ai_one() {}\nfn ai_two() {}\nfn ai_three() {}\nfn ai_four() {}\nfn ai_five() {}\nfn ai_six() {}\nfn validate() {}\nfn report() {}\nfn collect() {}\n", + ) + .unwrap(); + repo.autter(&["checkpoint", "claude", "--hook-input", &hook("PostToolUse")]) + .unwrap(); + let claude_commit = commit_on(repo, "Validate config", "2031-07-07T09:00:00Z"); + + // July 15: ten AI lines by another author, outside the default scope. + let other = root.join("src").join("other.rs"); + fs::write(&other, "o1\no2\no3\no4\no5\no6\no7\no8\no9\no10\n").unwrap(); + repo.autter(&["checkpoint", "mock_ai", "src/other.rs"]) + .unwrap(); + repo.stage_all_and_commit_with_env( + "Other author", + &[ + ("GIT_AUTHOR_DATE", "2031-07-15T09:00:00Z"), + ("GIT_COMMITTER_DATE", "2031-07-15T09:00:00Z"), + ("GIT_AUTHOR_NAME", "Other Person"), + ("GIT_AUTHOR_EMAIL", "other@example.com"), + ], + ) + .unwrap(); + + claude_commit +} + +fn wrapped_json(repo: &TestRepo, extra: &[&str]) -> Value { + let mut args = vec!["wrapped", "--json", "--year", YEAR]; + args.extend_from_slice(extra); + let output = repo + .autter_command(&args) + .output() + .expect("autter wrapped should run"); + assert!( + output.status.success(), + "autter wrapped {extra:?} failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).unwrap_or_else(|e| { + panic!( + "wrapped --json should print JSON: {e}\n{}", + String::from_utf8_lossy(&output.stdout) + ) + }) +} + +fn wrapped_card(repo: &TestRepo, extra: &[&str]) -> String { + let mut args = vec!["wrapped", "--year", YEAR, "--no-color"]; + args.extend_from_slice(extra); + let output = repo + .autter_command(&args) + // Decide the charset explicitly so the card draws the same on + // every CI runner. + .env("LANG", "en_US.UTF-8") + .env("LC_ALL", "en_US.UTF-8") + .env_remove("TERM") + .output() + .expect("autter wrapped should run"); + assert!( + output.status.success(), + "autter wrapped {extra:?} failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("card is UTF-8") +} + +fn month(summary: &Value, month: u64) -> &Value { + summary["by_month"] + .as_array() + .expect("by_month is an array") + .iter() + .find(|entry| entry["month"] == month) + .unwrap_or_else(|| panic!("month {month} missing")) +} + +#[test] +fn wrapped_json_matches_the_share_contract() { + let mut repo = TestRepo::new(); + let claude_commit = build_fixture(&mut repo); + + let summary = wrapped_json(&repo, &[]); + + assert_eq!(summary["schema_version"], "wrapped/1.0.0"); + assert_eq!(summary["year"], 2031); + assert_eq!(summary["author"], "test@example.com"); + assert_eq!(summary["all_authors"], false); + assert_eq!(summary["commits"], 4); + assert_eq!(summary["commits_with_authorship"], 4); + assert_eq!(summary["capped"], false); + assert_eq!(summary["added_lines"], 14); + assert_eq!(summary["ai_lines"], 9); + assert_eq!(summary["human_lines"], 5); + assert_eq!(summary["untracked_lines"], 0); + assert_eq!(summary["ai_percent"], 64.29); + + assert_eq!(summary["by_month"].as_array().unwrap().len(), 12); + assert_eq!( + month(&summary, 1), + &json!({"month": 1, "ai_accepted": 0, "added_lines": 2, "ai_percent": 0.0, "commits": 1}) + ); + assert_eq!( + month(&summary, 3), + &json!({"month": 3, "ai_accepted": 6, "added_lines": 9, "ai_percent": 66.67, "commits": 2}) + ); + assert_eq!( + month(&summary, 7), + &json!({"month": 7, "ai_accepted": 3, "added_lines": 3, "ai_percent": 100.0, "commits": 1}) + ); + for empty in [2, 4, 5, 6, 8, 9, 10, 11, 12] { + assert_eq!(month(&summary, empty)["commits"], 0, "month {empty}"); + assert_eq!(month(&summary, empty)["added_lines"], 0, "month {empty}"); + } + + assert_eq!(summary["top_agent"], json!({"name": "mock_ai", "lines": 6})); + assert_eq!( + summary["top_model"], + json!({"name": "mock_ai/unknown-model", "lines": 6}) + ); + assert_eq!( + summary["agents"], + json!([{"name": "mock_ai", "lines": 6}, {"name": "claude", "lines": 3}]) + ); + assert_eq!( + summary["models"], + json!([ + {"name": "mock_ai/unknown-model", "lines": 6}, + {"name": "claude-sonnet-4-20250514", "lines": 3} + ]) + ); + + assert_eq!( + summary["most_prompted_file"], + json!({"path": "src/app.rs", "prompts": 3}) + ); + assert_eq!( + summary["files"], + json!([{"path": "src/app.rs", "prompts": 3}]), + "known-human-only files are not prompted files" + ); + + assert_eq!(summary["prompts_total"], 3); + assert_eq!(summary["prompts_with_transcript"], 1); + let longest = &summary["longest_prompt"]; + assert_eq!(longest["chars"], CLAUDE_PROMPT.chars().count() as u64); + assert_eq!( + longest["words"], + CLAUDE_PROMPT.split_whitespace().count() as u64 + ); + assert_eq!(longest["agent"], "claude"); + assert_eq!(longest["model"], "claude-sonnet-4-20250514"); + assert_eq!(longest["commit"], claude_commit); + assert_eq!( + longest["snippet"], + Value::Null, + "prompt text stays out of the JSON unless opted in" + ); + + assert_eq!(summary["busiest_day"], "Monday"); + assert_eq!(summary["streak_days"], 2); +} + +#[test] +fn wrapped_snippet_is_opt_in() { + let mut repo = TestRepo::new(); + build_fixture(&mut repo); + + let summary = wrapped_json(&repo, &["--include-prompt-text"]); + let expected: String = CLAUDE_PROMPT.chars().take(120).collect(); + assert_eq!(summary["longest_prompt"]["snippet"], expected); + assert_eq!(expected.chars().count(), 120); +} + +#[test] +fn wrapped_scopes_to_the_author() { + let mut repo = TestRepo::new(); + build_fixture(&mut repo); + + let everyone = wrapped_json(&repo, &["--all-authors"]); + assert_eq!(everyone["author"], Value::Null); + assert_eq!(everyone["all_authors"], true); + assert_eq!(everyone["commits"], 5); + assert_eq!(everyone["added_lines"], 24); + assert_eq!(everyone["ai_lines"], 19); + assert_eq!(everyone["ai_percent"], 79.17); + assert_eq!( + everyone["top_agent"], + json!({"name": "mock_ai", "lines": 16}) + ); + assert_eq!(month(&everyone, 7)["commits"], 2); + + let other = wrapped_json(&repo, &["--author", "Other@Example.com"]); + assert_eq!(other["author"], "Other@Example.com"); + assert_eq!(other["commits"], 1); + assert_eq!(other["ai_lines"], 10); + assert_eq!(other["commits_with_authorship"], 1); + assert_eq!( + other["most_prompted_file"], + json!({"path": "src/other.rs", "prompts": 1}) + ); +} + +#[test] +fn wrapped_other_years_are_empty() { + let mut repo = TestRepo::new(); + build_fixture(&mut repo); + + let output = repo + .autter_command(&["wrapped", "--json", "--year", "2030"]) + .output() + .unwrap(); + assert!(output.status.success()); + let summary: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(summary["commits"], 0); + assert_eq!(summary["ai_percent"], 0.0); + assert_eq!(summary["top_agent"], Value::Null); + assert_eq!(summary["longest_prompt"], Value::Null); + assert_eq!(summary["busiest_day"], Value::Null); + assert_eq!(summary["streak_days"], 0); +} + +#[test] +fn wrapped_card_snapshot() { + let mut repo = TestRepo::new(); + build_fixture(&mut repo); + + let card = wrapped_card(&repo, &[]); + assert!(card.contains("autter.dev/wrapped"), "{card}"); + insta::assert_snapshot!(card); +} + +#[test] +fn wrapped_card_with_prompt_text_shows_a_snippet() { + let mut repo = TestRepo::new(); + build_fixture(&mut repo); + + let card = wrapped_card(&repo, &["--include-prompt-text"]); + assert!( + card.contains("\u{201C}Please refactor the config loader"), + "{card}" + ); + let plain = wrapped_card(&repo, &[]); + assert!(!plain.contains("Please refactor"), "{plain}"); +} + +#[test] +fn wrapped_empty_state_below_three_noted_commits() { + let repo = TestRepo::new(); + fs::write(repo.path().join("README.md"), "# Hello\n").unwrap(); + repo.autter(&["checkpoint", "mock_known_human", "README.md"]) + .unwrap(); + commit_on(&repo, "Initial", "2031-02-02T09:00:00Z"); + + let card = wrapped_card(&repo, &[]); + assert_eq!( + card, + "◇ Autter Wrapped 2031\n\ + │ 1 commit with an authorship note in 2031. A card needs at least 3.\n\ + │ Commit with an autter-tracked agent, then run autter wrapped again.\n\ + └ autter.dev/wrapped\n" + ); + + // The JSON contract is still produced for the share pipeline. + let summary = wrapped_json(&repo, &[]); + assert_eq!(summary["commits"], 1); + assert_eq!(summary["commits_with_authorship"], 1); +} + +#[test] +fn wrapped_rejects_bad_arguments() { + let repo = TestRepo::new(); + fs::write(repo.path().join("README.md"), "# Hello\n").unwrap(); + repo.stage_all_and_commit("Initial").unwrap(); + + for args in [ + vec!["wrapped", "--year", "20x"], + vec!["wrapped", "--year"], + vec!["wrapped", "--author", "a@b.c", "--all-authors"], + vec!["wrapped", "--bogus"], + ] { + let output = repo.autter_command(&args).output().unwrap(); + assert_eq!( + output.status.code(), + Some(2), + "{args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + } +} From c4fa2116ed2488b6d1b9745c80932f9cb19efe9b Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Wed, 4 Nov 2026 00:50:23 +0530 Subject: [PATCH 3/5] 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. //! From 3fc25ca6017d3e272a7f6208e99eb5d78c8126c3 Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Wed, 4 Nov 2026 00:56:29 +0530 Subject: [PATCH 4/5] Add --share, --hide-repo and --unshare to autter wrapped `--share` posts the exact `--json` summary to `/api/cli/wrapped-share` with the CLI access token (org-scoped when the repository's org has one), prints the page link after the card and opens it at a terminal. `--hide-repo` keeps the repository name off the page; `--unshare` takes a page down by link or token. Nothing is sent in local mode or without a sign-in, and the prompt snippet is stripped from the body unless `--include-prompt-text` was given. Server refusals map to one line each. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- README.md | 2 +- changelog/wrapped.md | 10 +- src/api/client.rs | 32 +++ src/api/mod.rs | 1 + src/api/wrapped_share.rs | 381 ++++++++++++++++++++++++++++++++++ src/authorship/post_commit.rs | 2 +- src/commands/arg_parser.rs | 11 +- src/commands/wrapped.rs | 127 +++++++++++- tests/integration/wrapped.rs | 230 ++++++++++++++++++++ 10 files changed, 792 insertions(+), 6 deletions(-) create mode 100644 src/api/wrapped_share.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e712cfd..08cc05e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ This changelog is automatically generated by AI when releases are published. ### Added -- **`autter wrapped`**: a Spotify-Wrapped-style summary of your year of AI-assisted coding in a repository — AI share overall and by month, top agent and model, most-prompted file, longest prompt, streak and busiest weekday — drawn as a 60-column card for screenshots, with `--json` for the share-image pipeline. Prompt text stays local and is only included with `--include-prompt-text`. See [changelog/wrapped.md](changelog/wrapped.md). +- **`autter wrapped`**: a Spotify-Wrapped-style summary of your year of AI-assisted coding in a repository — AI share overall and by month, top agent and model, most-prompted file, longest prompt, streak and busiest weekday — drawn as a 60-column card for screenshots, with `--json` for the share-image pipeline and `--share` to publish it as a page with a share image (`--hide-repo`, `--unshare`). Prompt text stays local and is only included with `--include-prompt-text`. See [changelog/wrapped.md](changelog/wrapped.md). - **Interactive update prompt**: when a newer release is available, autter now offers to install it in place — `Update autter now? [Y/n]`, answered with a single keystroke — instead of only printing `run autter upgrade`. Declining silences the prompt for a week. The prompt only appears on an interactive terminal, never in CI or inside AI agents, is bounded by a timeout so it can't wedge a git command, and runs the upgrade in a child process so a failed download can't change the git command's exit code. Opt out with `disable_update_prompt` in `~/.autter/config.json` or `AUTTER_DISABLE_UPDATE_PROMPT=1`. See [changelog/interactive-update-prompt.md](changelog/interactive-update-prompt.md). diff --git a/README.md b/README.md index 3df0f09..7447dd1 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ autter stats .. --json It computes % AI code, AI lines generated vs committed, acceptance rates, and human overrides, broken down by tool and model. More in the [stats command reference](https://autter.dev/docs/cli/reference#stats). -For a year at a glance, `autter wrapped` draws a screenshot-ready card of your AI-assisted coding in the repository: AI share by month, top agent and model, most-prompted file, longest prompt, streak and busiest weekday. `--json` prints the same numbers; prompt text never leaves the machine unless you pass `--include-prompt-text`. +For a year at a glance, `autter wrapped` draws a screenshot-ready card of your AI-assisted coding in the repository: AI share by month, top agent and model, most-prompted file, longest prompt, streak and busiest weekday. `--json` prints the same numbers, and `--share` publishes the card as a page with a share image. Prompt text never leaves the machine unless you pass `--include-prompt-text`.
Example JSON output diff --git a/changelog/wrapped.md b/changelog/wrapped.md index 69bcf2e..7695dc4 100644 --- a/changelog/wrapped.md +++ b/changelog/wrapped.md @@ -15,9 +15,17 @@ A Spotify-Wrapped-style summary of your year of AI-assisted coding in a reposito - The terminal card shows the headline percentage in block digits, a twelve-bar monthly chart, the rows above and an `autter.dev/wrapped` footer. It honors `NO_COLOR` and `--no-color`, falls back to ASCII on terminals without Unicode, and is deterministic for a given summary so screenshots come out the same every time. - With fewer than three noted commits in the year, the command prints a short note instead of a card. +## Sharing + +- **`--share`** publishes the summary as a page at `app.autter.dev/wrapped/` with a 1200x630 share image, prints the link after the card and opens it in the browser at a terminal. Sharing again for the same user, year and repository updates the same page and keeps the link. With `--json`, stdout stays the exact JSON contract and the link goes to stderr. +- **`--hide-repo`** shows no repository name on the page. +- **`--unshare `** takes a page down. + +Sharing needs a connected account and sends nothing in local mode. Each server refusal is one line: not signed in, not a member of the organization, too large, nothing to share yet, or the server's own message. `app.autter.dev/wrapped/demo` shows a sample page. + ## Privacy -Nothing leaves the machine. The longest prompt is measured from transcripts already in the local cache; prompts that only exist remotely are skipped. Its text is never printed or included in the JSON unless `--include-prompt-text` is passed, and then only the first 120 characters. +Nothing leaves the machine unless you pass `--share`, and then only the JSON summary. The longest prompt is measured from transcripts already in the local cache; prompts that only exist remotely are skipped. Its text is never printed or included in the JSON unless `--include-prompt-text` is passed, and then only the first 120 characters. ## JSON diff --git a/src/api/client.rs b/src/api/client.rs index 00d7f82..6ccdeb6 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -509,6 +509,19 @@ impl ApiContext { (agent, request) } + /// Create a DELETE request with common headers (User-Agent, X-Distinct-ID). + pub fn http_delete(url: &str, timeout_secs: Option) -> (ureq::Agent, ureq::Request) { + let agent = http::build_agent(timeout_secs); + let request = agent + .delete(url) + .set( + "User-Agent", + &format!("autter/{}", env!("CARGO_PKG_VERSION")), + ) + .set("X-Distinct-ID", &config::get_or_create_distinct_id()); + (agent, request) + } + /// Create a new API context, automatically using stored credentials if available /// If base_url is None, uses api_base_url from config (which can be set via config file, env var, or defaults) /// Uses Config::fresh() to support runtime config updates (daemon mode) @@ -648,6 +661,25 @@ impl ApiContext { http::send(request).map_err(|e| AutterError::Generic(format!("HTTP request failed: {}", e))) } + + /// Make a DELETE request + pub fn delete(&self, endpoint: &str) -> Result { + let url = self.build_url(endpoint)?; + + let (_agent, mut request) = Self::http_delete(&url, self.timeout_secs); + + if let Some(api_key) = &self.api_key { + request = request.set("X-API-Key", api_key); + if let Some(identity) = &self.author_identity { + request = request.set("X-Author-Identity", identity); + } + } + if let Some(token) = &self.auth_token { + request = request.set("Authorization", &format!("Bearer {}", token)); + } + + http::send(request).map_err(|e| AutterError::Generic(format!("HTTP request failed: {}", e))) + } } /// API client wrapper diff --git a/src/api/mod.rs b/src/api/mod.rs index 9885d9a..60ee92d 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -5,6 +5,7 @@ pub mod data_plane; pub mod metrics; pub mod notes; pub mod types; +pub mod wrapped_share; pub use client::{ApiClient, ApiContext}; pub use metrics::upload_metrics_with_retry; diff --git a/src/api/wrapped_share.rs b/src/api/wrapped_share.rs new file mode 100644 index 0000000..17c96cc --- /dev/null +++ b/src/api/wrapped_share.rs @@ -0,0 +1,381 @@ +//! `autter wrapped --share`: publish the year summary as a page at +//! `app.autter.dev/wrapped/` with a share image, and take it down +//! again with `--unshare`. The request carries the exact `--json` document; +//! the server drops the prompt snippet unless the user opted in, and the +//! client never sends it otherwise either. + +use std::fmt; + +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::api::client::ApiClient; +use crate::authorship::wrapped::WrappedSummary; +use crate::error::AutterError; + +const SHARE_ENDPOINT: &str = "/api/cli/wrapped-share"; + +/// What the server returns for a new or updated share. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct WrappedShareResponse { + pub token: String, + /// The page: `https://app.autter.dev/wrapped/`. + pub url: String, + #[serde(default)] + pub image_url: Option, +} + +/// Why a share or unshare did not happen, each with one line for the user. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShareError { + /// 401: no valid sign-in. + LoginRequired, + /// 403: signed in, but not a member of the org that owns the repository. + NotMember, + /// 413: the summary is larger than the server accepts. + TooLarge, + /// 422: the year has nothing worth a card yet. + NothingToShare, + /// 404 on unshare: not one of the user's shares, or already gone. + NotFound, + /// 400: the server's own message, e.g. an unsupported schema version. + Rejected(String), + /// Transport failures and unexpected statuses. + Other(String), +} + +impl fmt::Display for ShareError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ShareError::LoginRequired => write!(f, "Not signed in. Run `autter login` first."), + ShareError::NotMember => write!( + f, + "Your account is not a member of the organization that owns this repository." + ), + ShareError::TooLarge => write!(f, "The summary is too large to share."), + ShareError::NothingToShare => write!( + f, + "Nothing to share yet: the year needs at least 3 commits with authorship notes." + ), + ShareError::NotFound => write!( + f, + "No share with that token belongs to you, or it was already removed." + ), + ShareError::Rejected(message) => write!(f, "The server rejected the share: {message}"), + ShareError::Other(message) => write!(f, "Sharing failed: {message}"), + } + } +} + +impl std::error::Error for ShareError {} + +/// The request body: the `--json` document plus the sharing options. The +/// snippet is removed unless prompt text was opted in, whatever the summary +/// holds, so no prompt text can leave the machine by accident. +pub fn build_share_request( + summary: &WrappedSummary, + repo_url: Option<&str>, + include_prompt_text: bool, + hide_repo: bool, +) -> Value { + let mut wrapped = serde_json::to_value(summary).unwrap_or(Value::Null); + if !include_prompt_text + && let Some(prompt) = wrapped + .get_mut("longest_prompt") + .and_then(Value::as_object_mut) + { + prompt.insert("snippet".to_string(), Value::Null); + } + let mut body = json!({ + "wrapped": wrapped, + "include_prompt_text": include_prompt_text, + "hide_repo": hide_repo, + }); + if let Some(repo_url) = repo_url + && let Some(object) = body.as_object_mut() + { + object.insert("repo_url".to_string(), Value::String(repo_url.to_string())); + } + body +} + +/// The share token in `--unshare `: a bare token, or the +/// token at the end of a `/wrapped/` page URL. +pub fn share_token_from(arg: &str) -> Option { + let trimmed = arg.trim().trim_end_matches('/'); + let token = match trimmed.find("/wrapped/") { + Some(index) => &trimmed[index + "/wrapped/".len()..], + None if trimmed.contains("://") || trimmed.contains('/') => return None, + None => trimmed, + }; + let token = token + .split(['?', '#']) + .next() + .unwrap_or_default() + .trim_end_matches('/'); + let valid = !token.is_empty() + && token + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); + valid.then(|| token.to_string()) +} + +fn server_message(body: &str) -> String { + serde_json::from_str::(body) + .ok() + .and_then(|value| { + value + .get("error") + .or_else(|| value.get("message")) + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| body.trim().to_string()) +} + +fn map_status(status: u16, body: &str, unsharing: bool) -> ShareError { + match status { + 400 => ShareError::Rejected(server_message(body)), + 401 => ShareError::LoginRequired, + 403 => ShareError::NotMember, + 404 if unsharing => ShareError::NotFound, + 413 => ShareError::TooLarge, + 422 => ShareError::NothingToShare, + other => { + let message = server_message(body); + ShareError::Other(if message.is_empty() { + format!("status {other}") + } else { + format!("status {other}: {message}") + }) + } + } +} + +impl ApiClient { + /// Publish (or update) the share page for a summary. + pub fn share_wrapped(&self, body: &Value) -> Result { + let response = self + .context() + .post_json(SHARE_ENDPOINT, body) + .map_err(|e| ShareError::Other(e.to_string()))?; + let text = response.as_str().unwrap_or("").to_string(); + if !(200..300).contains(&response.status_code) { + return Err(map_status(response.status_code, &text, false)); + } + serde_json::from_str(&text) + .map_err(|e| ShareError::Other(format!("unexpected response: {e}"))) + } + + /// Take a share page down. + pub fn unshare_wrapped(&self, token: &str) -> Result<(), ShareError> { + let response = self + .context() + .delete(&format!("{SHARE_ENDPOINT}/{token}")) + .map_err(|e| ShareError::Other(e.to_string()))?; + if (200..300).contains(&response.status_code) { + return Ok(()); + } + let text = response.as_str().unwrap_or("").to_string(); + Err(map_status(response.status_code, &text, true)) + } +} + +impl From for AutterError { + fn from(error: ShareError) -> Self { + AutterError::Generic(error.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::client::ApiContext; + use crate::authorship::wrapped::{COMMIT_CAP, LongestPrompt, WRAPPED_SCHEMA_VERSION}; + + fn summary(snippet: Option<&str>) -> WrappedSummary { + WrappedSummary { + schema_version: WRAPPED_SCHEMA_VERSION, + autter_version: "test", + year: 2026, + author: Some("you@company.com".to_string()), + all_authors: false, + commits: 10, + commits_with_authorship: 9, + capped: false, + commit_cap: COMMIT_CAP, + added_lines: 100, + ai_lines: 60, + human_lines: 30, + untracked_lines: 10, + ai_percent: 60.0, + by_month: vec![], + top_agent: None, + top_model: None, + agents: vec![], + models: vec![], + most_prompted_file: None, + files: vec![], + prompts_total: 3, + prompts_with_transcript: 1, + longest_prompt: Some(LongestPrompt { + chars: 40, + words: 8, + agent: "claude".to_string(), + model: "claude-opus-5-5".to_string(), + commit: "abc".to_string(), + snippet: snippet.map(str::to_string), + }), + busiest_day: None, + streak_days: 0, + } + } + + #[test] + fn request_carries_the_json_document_and_options() { + let body = build_share_request(&summary(None), Some("github.com/acme/app"), false, true); + assert_eq!(body["wrapped"]["schema_version"], "wrapped/1.0.0"); + assert_eq!(body["wrapped"]["year"], 2026); + assert_eq!(body["repo_url"], "github.com/acme/app"); + assert_eq!(body["include_prompt_text"], false); + assert_eq!(body["hide_repo"], true); + } + + #[test] + fn repo_url_is_omitted_when_unknown() { + let body = build_share_request(&summary(None), None, false, false); + assert!(body.get("repo_url").is_none(), "{body}"); + } + + #[test] + fn snippet_leaves_only_when_opted_in() { + let body = build_share_request(&summary(Some("secret prompt")), None, false, false); + assert_eq!(body["wrapped"]["longest_prompt"]["snippet"], Value::Null); + assert!(!body.to_string().contains("secret prompt")); + + let body = build_share_request(&summary(Some("shared prompt")), None, true, false); + assert_eq!( + body["wrapped"]["longest_prompt"]["snippet"], + "shared prompt" + ); + assert_eq!(body["include_prompt_text"], true); + } + + #[test] + fn tokens_come_from_bare_values_and_page_urls() { + assert_eq!(share_token_from("abc-123_X"), Some("abc-123_X".to_string())); + assert_eq!( + share_token_from("https://app.autter.dev/wrapped/abc-123/"), + Some("abc-123".to_string()) + ); + assert_eq!( + share_token_from("https://app.autter.dev/wrapped/abc?utm=x"), + Some("abc".to_string()) + ); + assert_eq!(share_token_from("https://app.autter.dev/me"), None); + assert_eq!(share_token_from("https://app.autter.dev/wrapped/"), None); + assert_eq!(share_token_from(" "), None); + assert_eq!(share_token_from("a b"), None); + } + + #[test] + fn statuses_map_to_one_line_each() { + assert_eq!(map_status(401, "", false), ShareError::LoginRequired); + assert_eq!(map_status(403, "", false), ShareError::NotMember); + assert_eq!(map_status(413, "", false), ShareError::TooLarge); + assert_eq!(map_status(422, "", false), ShareError::NothingToShare); + assert_eq!(map_status(404, "", true), ShareError::NotFound); + assert_eq!( + map_status(400, r#"{"error":"unsupported schema_version"}"#, false), + ShareError::Rejected("unsupported schema_version".to_string()) + ); + assert_eq!( + map_status(500, "boom", false), + ShareError::Other("status 500: boom".to_string()) + ); + assert!( + ShareError::LoginRequired + .to_string() + .contains("autter login") + ); + } + + fn client(server: &mockito::Server) -> ApiClient { + ApiClient::new(ApiContext::with_auth( + Some(server.url()), + "test-access-token".to_string(), + )) + } + + #[test] + fn share_posts_the_body_with_a_bearer_token() { + let mut server = mockito::Server::new(); + let mock = server + .mock("POST", "/api/cli/wrapped-share") + .match_header("authorization", "Bearer test-access-token") + .match_header("content-type", "application/json") + .match_body(mockito::Matcher::PartialJson(json!({ + "hide_repo": true, + "include_prompt_text": false, + "wrapped": {"year": 2026} + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"token":"tok1","url":"https://app.autter.dev/wrapped/tok1","image_url":"https://app.autter.dev/public/wrapped/tok1/card.png"}"#, + ) + .create(); + + let body = build_share_request(&summary(None), None, false, true); + let response = client(&server).share_wrapped(&body).unwrap(); + mock.assert(); + assert_eq!( + response, + WrappedShareResponse { + token: "tok1".to_string(), + url: "https://app.autter.dev/wrapped/tok1".to_string(), + image_url: Some("https://app.autter.dev/public/wrapped/tok1/card.png".to_string()), + } + ); + } + + #[test] + fn share_errors_are_mapped() { + let mut server = mockito::Server::new(); + let mock = server + .mock("POST", "/api/cli/wrapped-share") + .with_status(400) + .with_body(r#"{"error":"unsupported schema_version wrapped/9.0.0"}"#) + .create(); + let body = build_share_request(&summary(None), None, false, false); + let error = client(&server).share_wrapped(&body).unwrap_err(); + mock.assert(); + assert_eq!( + error, + ShareError::Rejected("unsupported schema_version wrapped/9.0.0".to_string()) + ); + } + + #[test] + fn unshare_deletes_by_token() { + let mut server = mockito::Server::new(); + let ok = server + .mock("DELETE", "/api/cli/wrapped-share/tok1") + .match_header("authorization", "Bearer test-access-token") + .with_status(204) + .create(); + let gone = server + .mock("DELETE", "/api/cli/wrapped-share/tok2") + .with_status(404) + .with_body(r#"{"error":"not found"}"#) + .create(); + + assert_eq!(client(&server).unshare_wrapped("tok1"), Ok(())); + assert_eq!( + client(&server).unshare_wrapped("tok2"), + Err(ShareError::NotFound) + ); + ok.assert(); + gone.assert(); + } +} diff --git a/src/authorship/post_commit.rs b/src/authorship/post_commit.rs index 9748489..b721840 100644 --- a/src/authorship/post_commit.rs +++ b/src/authorship/post_commit.rs @@ -749,7 +749,7 @@ fn upload_commit_authorship_summary( } } -fn current_repo_url(repo: &Repository) -> Option { +pub(crate) fn current_repo_url(repo: &Repository) -> Option { repo.get_default_remote() .ok() .flatten() diff --git a/src/commands/arg_parser.rs b/src/commands/arg_parser.rs index c4e2d35..7a8e340 100644 --- a/src/commands/arg_parser.rs +++ b/src/commands/arg_parser.rs @@ -376,7 +376,16 @@ autter wrapped [options] --include-prompt-text Include the first 120 characters of the longest prompt; off by default so prompt text stays private --json Output the summary as JSON (the share-card contract) - --no-color Plain text, no ANSI colors", + --no-color Plain text, no ANSI colors + + --share Publish the summary as a page at app.autter.dev/wrapped/ + with a share image, and print its link. Sharing again for + the same year and repository updates the same page. + --hide-repo With --share: show no repository name on the page + --unshare Take a share page down + + Sharing needs a connected account (autter login); nothing is sent in local mode. + See app.autter.dev/wrapped/demo for a sample page.", }, HelpEntry { name: "file-changes", diff --git a/src/commands/wrapped.rs b/src/commands/wrapped.rs index 4a37219..11599cf 100644 --- a/src/commands/wrapped.rs +++ b/src/commands/wrapped.rs @@ -3,13 +3,23 @@ //! prints the JSON contract for the share-image pipeline, or draws the //! fixed-width card. The card is 60 columns, deterministic for a given //! summary, and goes to stdout so it can be piped into a screenshot tool. +//! `--share` publishes the JSON as a page with a share image through +//! `api::wrapped_share`; `--unshare` takes one down. +use crate::api::client::{ + ApiClient, ApiContext, access_token_for_org, resolve_org_for_repo_cached, +}; +use crate::api::wrapped_share::{ + ShareError, WrappedShareResponse, build_share_request, share_token_from, +}; use crate::authorship::ignore::effective_ignore_patterns; +use crate::authorship::post_commit::current_repo_url; use crate::authorship::wrapped::{ MIN_COMMITS_FOR_CARD, WrappedOptions, WrappedSummary, wrapped_summary, }; use crate::commands::{self, arg_parser}; use crate::git::find_repository; +use crate::git::repository::Repository; use crate::ui; use crate::ui::rail; use crate::ui::theme::{Charset, TermCaps}; @@ -50,6 +60,9 @@ pub fn handle_wrapped(args: &[String]) { let mut all_authors = false; let mut include_prompt_text = false; let mut ignore_patterns: Vec = Vec::new(); + let mut share = false; + let mut hide_repo = false; + let mut unshare: Option = None; let usage = |message: &str| -> ! { eprintln!("{message}"); @@ -93,6 +106,17 @@ pub fn handle_wrapped(args: &[String]) { "--all-authors" => all_authors = true, "--include-prompt-text" => include_prompt_text = true, "--ignore" => ignore_patterns.push(take_value("a pattern")), + "--share" => share = true, + "--hide-repo" => hide_repo = true, + "--unshare" => { + let raw = take_value("a share link or token"); + match share_token_from(&raw) { + Some(token) => unshare = Some(token), + None => usage(&format!( + "--unshare expects a share link (app.autter.dev/wrapped/) or a token, got '{raw}'" + )), + } + } other => usage(&format!("Unknown wrapped argument: {other}")), } i += 1; @@ -100,6 +124,12 @@ pub fn handle_wrapped(args: &[String]) { if all_authors && author.is_some() { usage("--author and --all-authors cannot be combined"); } + if hide_repo && !share { + usage("--hide-repo only applies with --share"); + } + if unshare.is_some() && share { + usage("--share and --unshare cannot be combined"); + } let repo = match find_repository(&Vec::::new()) { Ok(repo) => repo, @@ -109,6 +139,24 @@ pub fn handle_wrapped(args: &[String]) { } }; + if let Some(token) = unshare { + let client = match share_client(&repo) { + Ok(client) => client, + Err(message) => { + eprintln!("{message}"); + std::process::exit(commands::EXIT_RUNTIME_ERROR); + } + }; + match client.unshare_wrapped(&token) { + Ok(()) => println!("Removed the share page for {token}."), + Err(e) => { + eprintln!("{e}"); + std::process::exit(commands::EXIT_RUNTIME_ERROR); + } + } + return; + } + let author = if all_authors { None } else if let Some(author) = author { @@ -137,6 +185,25 @@ pub fn handle_wrapped(args: &[String]) { } }; + // Sharing needs a connected account; check before printing anything so + // the reason is the last line on screen. + let client = if share { + if !summary.has_enough_data() { + print!("{}", render(&summary, &card_caps())); + eprintln!("{}", ShareError::NothingToShare); + std::process::exit(commands::EXIT_RUNTIME_ERROR); + } + match share_client(&repo) { + Ok(client) => Some(client), + Err(message) => { + eprintln!("{message}"); + std::process::exit(commands::EXIT_RUNTIME_ERROR); + } + } + } else { + None + }; + if arg_parser::json() { match serde_json::to_string_pretty(&summary) { Ok(json) => println!("{json}"), @@ -145,10 +212,68 @@ pub fn handle_wrapped(args: &[String]) { std::process::exit(commands::EXIT_RUNTIME_ERROR); } } + } else { + print!("{}", render(&summary, &card_caps())); + } + + let Some(client) = client else { return; + }; + let body = build_share_request( + &summary, + current_repo_url(&repo).as_deref(), + include_prompt_text, + hide_repo, + ); + match client.share_wrapped(&body) { + Ok(response) => announce_share(&response, arg_parser::json()), + Err(e) => { + eprintln!("{e}"); + std::process::exit(commands::EXIT_RUNTIME_ERROR); + } } +} - print!("{}", render(&summary, &card_caps())); +/// The signed-in client for sharing: an org-scoped token for the repository's +/// org when the account has one, else the stored sign-in. Local mode and a +/// missing sign-in stop here, before anything is sent. +fn share_client(repo: &Repository) -> Result { + if !crate::config::platform_sync_enabled_now() { + return Err( + "Sharing is off in local mode: nothing leaves this machine. Run `autter onboard` to connect an account." + .to_string(), + ); + } + let client = if let Some(repo_url) = current_repo_url(repo) + && let Some(org) = resolve_org_for_repo_cached(&repo_url) + && let Some(token) = access_token_for_org(&org) + { + ApiClient::new(ApiContext::with_auth(None, token)) + } else { + ApiClient::new(ApiContext::new(None)) + }; + if !client.is_logged_in() && !client.has_api_key() { + return Err(ShareError::LoginRequired.to_string()); + } + Ok(client) +} + +/// The share link, after the card (or on stderr after `--json`, keeping +/// stdout the exact contract), opened in the browser at a terminal. +fn announce_share(response: &WrappedShareResponse, json: bool) { + let caps = card_caps(); + let line = format!("Shared at {}", caps.accent(&response.url)); + if json { + eprintln!("{line}"); + } else { + println!("{line}"); + } + if crate::utils::is_interactive_terminal() + && std::io::IsTerminal::is_terminal(&std::io::stdout()) + && let Err(e) = crate::commands::personal_dashboard::open_browser(&response.url) + { + eprintln!("Could not open the browser: {e}"); + } } fn current_year() -> i32 { diff --git a/tests/integration/wrapped.rs b/tests/integration/wrapped.rs index a3819f1..5e71145 100644 --- a/tests/integration/wrapped.rs +++ b/tests/integration/wrapped.rs @@ -397,3 +397,233 @@ fn wrapped_rejects_bad_arguments() { ); } } + +// --- Sharing ----------------------------------------------------------------- + +/// A signed-in account in the test home: the file credential store holds a +/// long-lived access token, so `ApiContext::new` sends it as a Bearer token. +fn sign_in(repo: &TestRepo) { + let path = repo + .test_home_path() + .join(".autter") + .join("internal") + .join("credentials"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let far_future = 4_102_444_800i64; // 2100-01-01 + fs::write( + &path, + json!({ + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + "access_token_expires_at": far_future, + "refresh_token_expires_at": far_future, + }) + .to_string(), + ) + .unwrap(); +} + +fn run_against(repo: &TestRepo, server: &mockito::Server, args: &[&str]) -> std::process::Output { + let mut full = vec!["wrapped", "--year", YEAR, "--no-color"]; + full.extend_from_slice(args); + repo.autter_command(&full) + .env("AUTTER_API_BASE_URL", server.url()) + .env("LANG", "en_US.UTF-8") + .env("LC_ALL", "en_US.UTF-8") + .output() + .expect("autter wrapped should run") +} + +#[test] +fn wrapped_share_flags_are_validated() { + let repo = TestRepo::new(); + fs::write(repo.path().join("README.md"), "# Hello\n").unwrap(); + repo.stage_all_and_commit("Initial").unwrap(); + + for args in [ + vec!["wrapped", "--hide-repo"], + vec!["wrapped", "--unshare"], + vec!["wrapped", "--unshare", "https://app.autter.dev/me"], + vec!["wrapped", "--unshare", "not a token"], + vec!["wrapped", "--share", "--unshare", "tok1"], + ] { + let output = repo.autter_command(&args).output().unwrap(); + assert_eq!( + output.status.code(), + Some(2), + "{args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + } +} + +#[test] +fn wrapped_share_sends_nothing_in_local_mode() { + let mut repo = TestRepo::new(); + build_fixture(&mut repo); // prompt_storage = local + sign_in(&repo); + let mut server = mockito::Server::new(); + let post = server + .mock("POST", "/api/cli/wrapped-share") + .expect(0) + .create(); + let delete = server + .mock("DELETE", mockito::Matcher::Any) + .expect(0) + .create(); + + let output = run_against(&repo, &server, &["--share"]); + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("local mode"), "{stderr}"); + + let output = run_against(&repo, &server, &["--unshare", "tok1"]); + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr).contains("local mode"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + post.assert(); + delete.assert(); +} + +#[test] +fn wrapped_share_without_sign_in_sends_nothing() { + let mut repo = TestRepo::new(); + build_fixture(&mut repo); + repo.patch_autter_config(|patch| patch.prompt_storage = Some("default".to_string())); + let mut server = mockito::Server::new(); + let post = server + .mock("POST", "/api/cli/wrapped-share") + .expect(0) + .create(); + + let output = run_against(&repo, &server, &["--share"]); + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("autter login"), "{stderr}"); + post.assert(); +} + +#[test] +fn wrapped_share_posts_the_summary_and_prints_the_link() { + let mut repo = TestRepo::new(); + build_fixture(&mut repo); + repo.patch_autter_config(|patch| patch.prompt_storage = Some("default".to_string())); + sign_in(&repo); + let mut server = mockito::Server::new(); + let post = server + .mock("POST", "/api/cli/wrapped-share") + .match_header("authorization", "Bearer test-access-token") + .match_body(mockito::Matcher::PartialJson(json!({ + "hide_repo": true, + "include_prompt_text": false, + "wrapped": { + "schema_version": "wrapped/1.0.0", + "year": 2031, + "commits": 4, + "ai_percent": 64.29, + "longest_prompt": {"snippet": null} + } + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"token":"tok1","url":"https://app.autter.dev/wrapped/tok1","image_url":"https://app.autter.dev/public/wrapped/tok1/card.png"}"#) + .expect(2) + .create(); + + let output = run_against(&repo, &server, &["--share", "--hide-repo"]); + assert_eq!( + output.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("autter.dev/wrapped"), + "card first: {stdout}" + ); + assert!( + stdout + .trim_end() + .ends_with("Shared at https://app.autter.dev/wrapped/tok1"), + "{stdout}" + ); + + // With --json, stdout stays the exact contract and the link goes to stderr. + let output = run_against(&repo, &server, &["--share", "--hide-repo", "--json"]); + assert_eq!(output.status.code(), Some(0)); + let summary: Value = + serde_json::from_slice(&output.stdout).expect("stdout is the JSON contract"); + assert_eq!(summary["year"], 2031); + assert!( + String::from_utf8_lossy(&output.stderr).contains("https://app.autter.dev/wrapped/tok1"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + post.assert(); +} + +#[test] +fn wrapped_share_reports_server_errors_in_one_line() { + let mut repo = TestRepo::new(); + build_fixture(&mut repo); + repo.patch_autter_config(|patch| patch.prompt_storage = Some("default".to_string())); + sign_in(&repo); + let mut server = mockito::Server::new(); + let post = server + .mock("POST", "/api/cli/wrapped-share") + .with_status(403) + .with_body(r#"{"error":"forbidden"}"#) + .create(); + + let output = run_against(&repo, &server, &["--share"]); + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("not a member"), "{stderr}"); + post.assert(); +} + +#[test] +fn wrapped_unshare_deletes_by_link_or_token() { + let repo = TestRepo::new(); + fs::write(repo.path().join("README.md"), "# Hello\n").unwrap(); + repo.stage_all_and_commit("Initial").unwrap(); + let mut repo = repo; + repo.patch_autter_config(|patch| patch.prompt_storage = Some("default".to_string())); + sign_in(&repo); + let mut server = mockito::Server::new(); + let ok = server + .mock("DELETE", "/api/cli/wrapped-share/tok1") + .match_header("authorization", "Bearer test-access-token") + .with_status(204) + .expect(2) + .create(); + let gone = server + .mock("DELETE", "/api/cli/wrapped-share/tok2") + .with_status(404) + .with_body(r#"{"error":"not found"}"#) + .create(); + + for arg in ["https://app.autter.dev/wrapped/tok1", "tok1"] { + let output = run_against(&repo, &server, &["--unshare", arg]); + assert_eq!( + output.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("Removed")); + } + let output = run_against(&repo, &server, &["--unshare", "tok2"]); + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr).contains("already removed"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + ok.assert(); + gone.assert(); +} From 0bc085a29442be8afd97929409ede1e97426ff19 Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Wed, 4 Nov 2026 00:59:18 +0530 Subject: [PATCH 5/5] Drop the Unreleased changelog line for autter wrapped The 3.0.0 release PR carries the Wrapped entry in its own section; changelog/wrapped.md and the README paragraph stay. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08cc05e..0f176b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,6 @@ This changelog is automatically generated by AI when releases are published. ### Added -- **`autter wrapped`**: a Spotify-Wrapped-style summary of your year of AI-assisted coding in a repository — AI share overall and by month, top agent and model, most-prompted file, longest prompt, streak and busiest weekday — drawn as a 60-column card for screenshots, with `--json` for the share-image pipeline and `--share` to publish it as a page with a share image (`--hide-repo`, `--unshare`). Prompt text stays local and is only included with `--include-prompt-text`. See [changelog/wrapped.md](changelog/wrapped.md). - - **Interactive update prompt**: when a newer release is available, autter now offers to install it in place — `Update autter now? [Y/n]`, answered with a single keystroke — instead of only printing `run autter upgrade`. Declining silences the prompt for a week. The prompt only appears on an interactive terminal, never in CI or inside AI agents, is bounded by a timeout so it can't wedge a git command, and runs the upgrade in a child process so a failed download can't change the git command's exit code. Opt out with `disable_update_prompt` in `~/.autter/config.json` or `AUTTER_DISABLE_UPDATE_PROMPT=1`. See [changelog/interactive-update-prompt.md](changelog/interactive-update-prompt.md). - **Panic reports keep their message**: `panic_message` downcast a `&(dyn Any + Send)` produced by an unsizing coercion, which reports the trait-object type rather than the concrete payload — every caught panic reached error tracking as `"unknown panic"`.