Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/api/data_plane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -61,6 +63,18 @@ 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/<token>/badge.svg`.
pub fn create_repo_badge_link(&self, repo_url: &str) -> Result<RepoBadgeLink, AutterError> {
let request = serde_json::json!({ "repo_url": repo_url });
let body = require_success(
self.context()
.post_json("/api/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> {
Expand Down
14 changes: 14 additions & 0 deletions src/api/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,20 @@ pub struct CommitAuthorshipSummary {
pub hunks: serde_json::Value,
}

/// 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 {
/// 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<String>,
/// The repository's Autter page, which the badge links to.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page_url: Option<String>,
}

/// Aggregated file-change count uploaded by the daemon.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileChangeCount {
Expand Down
1 change: 1 addition & 0 deletions src/authorship/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
173 changes: 173 additions & 0 deletions src/authorship/repo_share.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
/// 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<RepoShare, AutterError> {
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<String>, 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> = 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());
}
}
36 changes: 36 additions & 0 deletions src/commands/arg_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <date|duration> | --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 <value> 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 <pat> 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=<raw 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: &[],
Expand Down
4 changes: 4 additions & 0 deletions src/commands/autter_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ pub fn handle_autter(args: &[String]) {
| "ask"
| "logs"
| "threads"
| "badge"
| "memory"
| "context"
| "mcp"
Expand Down Expand Up @@ -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..]);
}
Expand Down
Loading
Loading