| 1 | //! `list_commits`: a branch's history, newest first, with each commit's |
| 2 | //! message, author, time and page; with `path`, that file's or directory's |
| 3 | //! own history: the commits that changed it. What an agent (or Ask g1t) |
| 4 | //! reads to say what changed lately and why: g1t's commit messages say why. |
| 5 | //! |
| 6 | //! The repos service reads it, as the caller may see it: `log` for a |
| 7 | //! branch, `path_log` for a path. The shaping is [`commit`], a plain |
| 8 | //! function. |
| 9 | //! |
| 10 | //! Each author is named as git wrote them, and with the g1t account their |
| 11 | //! address belongs to (`author.username`) when it is a confirmed or noreply |
| 12 | //! address of one, as the site shows commits: identity's `email_owners`. |
| 13 | //! The address itself is never returned. |
| 14 | |
| 15 | use std::collections::{BTreeSet, HashMap}; |
| 16 | |
| 17 | use g1t_contracts::accounts::{EmailOwner, EmailOwnersArgs}; |
| 18 | use g1t_contracts::repos::{Commit, LogArgs, PathLog, PathLogArgs, RepoPath}; |
| 19 | use g1t_contracts::{Outcome, Viewer}; |
| 20 | use serde_json::{Value, json}; |
| 21 | use worker::Result; |
| 22 | |
| 23 | use crate::operations::Services; |
| 24 | |
| 25 | pub const DEFAULT_LIMIT: u32 = 30; |
| 26 | pub const MAX_LIMIT: u32 = 100; |
| 27 | /// The most of one commit's message an answer carries. |
| 28 | const MAX_MESSAGE_CHARS: usize = 4_000; |
| 29 | /// How long finding a path's last change may take, in milliseconds. |
| 30 | const PATH_BUDGET_MS: u64 = 8_000; |
| 31 | |
| 32 | /// g1t's own commit addresses, shown as @g1t: its noreply address and those |
| 33 | /// its agents and merge queue used before (services/repos contributors.rs). |
| 34 | const G1T_EMAILS: [&str; 4] = ["g1t@users.noreply.g1t.sh", "agent@g1t.sh", "queue@g1t.sh", "mergecheck@g1t.sh"]; |
| 35 | /// The most addresses one `email_owners` call takes. |
| 36 | const MAX_ADDRESSES: usize = 200; |
| 37 | |
| 38 | /// Author address, lowercased, to the g1t username it belongs to. |
| 39 | pub type Authors = HashMap<String, String>; |
| 40 | |
| 41 | /// The distinct author addresses of some commits that identity is asked |
| 42 | /// about, lowercased: g1t's own are known already. |
| 43 | pub fn addresses<'a>(commits: impl IntoIterator<Item = &'a Commit>) -> Vec<String> { |
| 44 | commits |
| 45 | .into_iter() |
| 46 | .map(|commit| commit.author.email.trim().to_lowercase()) |
| 47 | .filter(|email| email.contains('@') && !G1T_EMAILS.contains(&email.as_str())) |
| 48 | .collect::<BTreeSet<_>>() |
| 49 | .into_iter() |
| 50 | .take(MAX_ADDRESSES) |
| 51 | .collect() |
| 52 | } |
| 53 | |
| 54 | /// Who each address belongs to, as usernames: g1t's own addresses are g1t, |
| 55 | /// the rest whatever identity matched (a confirmed or noreply address, |
| 56 | /// never an unconfirmed one). An address nobody owns is left out. |
| 57 | pub fn authors(commits: &[&Commit], owners: &HashMap<String, EmailOwner>) -> Authors { |
| 58 | let mut found = Authors::new(); |
| 59 | for commit in commits { |
| 60 | let email = commit.author.email.trim().to_lowercase(); |
| 61 | if G1T_EMAILS.contains(&email.as_str()) { |
| 62 | found.insert(email, "g1t".to_owned()); |
| 63 | } else if let Some(owner) = owners.get(&email) { |
| 64 | found.insert(email, owner.username.clone()); |
| 65 | } |
| 66 | } |
| 67 | found |
| 68 | } |
| 69 | |
| 70 | /// The g1t username of a commit's author, when their address is known. |
| 71 | pub fn username<'a>(commit: &Commit, authors: &'a Authors) -> Option<&'a str> { |
| 72 | authors.get(&commit.author.email.trim().to_lowercase()).map(String::as_str) |
| 73 | } |
| 74 | |
| 75 | /// Reads who wrote some commits from identity. Without identity, nobody: |
| 76 | /// the commits still say their authors' names. |
| 77 | pub async fn read_authors(services: &Services, commits: &[&Commit]) -> Authors { |
| 78 | let emails = addresses(commits.iter().copied()); |
| 79 | let owners = if emails.is_empty() { |
| 80 | HashMap::new() |
| 81 | } else { |
| 82 | g1t_kit::call::<_, HashMap<String, EmailOwner>>(&services.identity, "email_owners", &EmailOwnersArgs { emails }) |
| 83 | .await |
| 84 | .unwrap_or_default() |
| 85 | }; |
| 86 | authors(commits, &owners) |
| 87 | } |
| 88 | |
| 89 | pub fn description() -> &'static str { |
| 90 | "A branch's commits, newest first (the default branch unless `ref` names a branch, tag or commit): each with its `sha`, `message` (why it was made: g1t's commit messages say), `author` (`name` as git wrote it, and `username`, the g1t account the author's address belongs to, when one does), `authored_at` and `html_url`. `limit` is 30 unless you say, at most 100. With `path` (a file or directory), only the commits that changed it, newest first (its history: added, changed, removed), with `last_changed`, the newest of them, and `complete` false when a long history was not walked to its start in time." |
| 91 | } |
| 92 | |
| 93 | pub fn input() -> Value { |
| 94 | json!({ |
| 95 | "type": "object", |
| 96 | "properties": { |
| 97 | "repo": { "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }, |
| 98 | "ref": { "type": "string", "description": "A branch, tag or commit; the default branch when left out." }, |
| 99 | "path": { "type": "string", "description": "A file or directory, such as scripts/deploy/npm-ci.mjs: only the commits that changed it." }, |
| 100 | "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT, "description": "How many commits: 30 unless you say, at most 100." }, |
| 101 | }, |
| 102 | "required": ["repo"], |
| 103 | }) |
| 104 | } |
| 105 | |
| 106 | /// One commit as an answer shows it. |
| 107 | pub fn commit(site: &str, repo: &RepoPath, commit: &Commit, authors: &Authors) -> Value { |
| 108 | let site = site.trim_end_matches('/'); |
| 109 | let total = commit.message.chars().count(); |
| 110 | let message = if total > MAX_MESSAGE_CHARS { |
| 111 | format!("{}… [{} more characters]", commit.message.chars().take(MAX_MESSAGE_CHARS).collect::<String>(), total - MAX_MESSAGE_CHARS) |
| 112 | } else { |
| 113 | commit.message.trim_end().to_owned() |
| 114 | }; |
| 115 | json!({ |
| 116 | "sha": commit.hash, |
| 117 | "message": message, |
| 118 | "author": author(commit, authors), |
| 119 | "authored_at": commit.authored_at, |
| 120 | "parents": commit.parents, |
| 121 | "html_url": format!("{site}/{}/{}/commit/{}", repo.namespace, repo.name, commit.hash), |
| 122 | }) |
| 123 | } |
| 124 | |
| 125 | /// A commit's author as an answer shows them: the name git wrote, and their |
| 126 | /// username when it is known. Never the address. |
| 127 | fn author(commit: &Commit, authors: &Authors) -> Value { |
| 128 | match username(commit, authors) { |
| 129 | Some(username) => json!({ "name": commit.author.name, "username": username }), |
| 130 | None => json!({ "name": commit.author.name }), |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | fn limit(input: &Value) -> u32 { |
| 135 | let asked = match input.get("limit") { |
| 136 | Some(Value::String(text)) => text.trim().parse::<u64>().ok(), |
| 137 | Some(value) => value.as_u64(), |
| 138 | None => None, |
| 139 | }; |
| 140 | asked.map_or(DEFAULT_LIMIT, |n| n.clamp(1, u64::from(MAX_LIMIT)) as u32) |
| 141 | } |
| 142 | |
| 143 | pub async fn run(services: &Services, viewer: &Viewer, repo: &RepoPath, input: &Value) -> Result<Outcome<Value>> { |
| 144 | let site = services.addresses.site.clone(); |
| 145 | let git_ref = input["ref"].as_str().map(str::trim).filter(|git_ref| !git_ref.is_empty()).map(str::to_owned); |
| 146 | let path = input["path"].as_str().unwrap_or_default().trim().trim_matches('/').to_owned(); |
| 147 | let full = format!("{}/{}", repo.namespace, repo.name); |
| 148 | if path.is_empty() { |
| 149 | let log: Outcome<Vec<Commit>> = g1t_kit::call( |
| 150 | &services.repos, |
| 151 | "log", |
| 152 | &LogArgs { path: repo.clone(), viewer: viewer.clone(), git_ref, limit: limit(input) }, |
| 153 | ) |
| 154 | .await?; |
| 155 | let commits = match log { |
| 156 | Outcome::Ok(commits) => commits, |
| 157 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 158 | }; |
| 159 | let authors = read_authors(services, &commits.iter().collect::<Vec<_>>()).await; |
| 160 | return Ok(Outcome::Ok(json!({ |
| 161 | "repo": full, |
| 162 | "commits": commits.iter().map(|found| commit(&site, repo, found, &authors)).collect::<Vec<_>>(), |
| 163 | }))); |
| 164 | } |
| 165 | // A path's own history: the commits that changed it, newest first. |
| 166 | let history: Outcome<PathLog> = g1t_kit::call( |
| 167 | &services.repos, |
| 168 | "path_log", |
| 169 | &PathLogArgs { |
| 170 | path: repo.clone(), |
| 171 | viewer: viewer.clone(), |
| 172 | git_ref, |
| 173 | file_path: path.clone(), |
| 174 | limit: limit(input), |
| 175 | budget_ms: Some(PATH_BUDGET_MS), |
| 176 | }, |
| 177 | ) |
| 178 | .await?; |
| 179 | let history = match history { |
| 180 | Outcome::Ok(history) => history, |
| 181 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 182 | }; |
| 183 | let authors = read_authors(services, &history.commits.iter().collect::<Vec<_>>()).await; |
| 184 | let last_changed = match history.commits.first() { |
| 185 | Some(first) => commit(&site, repo, first, &authors), |
| 186 | None if history.complete => json!(format!("{path} is not in this repository at this ref.")), |
| 187 | None => json!("Not found in time: its history is long. Ask with a smaller limit, or read the branch's commits."), |
| 188 | }; |
| 189 | Ok(Outcome::Ok(json!({ |
| 190 | "repo": full, |
| 191 | "path": path, |
| 192 | "commits": history.commits.iter().map(|found| commit(&site, repo, found, &authors)).collect::<Vec<_>>(), |
| 193 | "last_changed": last_changed, |
| 194 | "complete": history.complete, |
| 195 | }))) |
| 196 | } |
| 197 | |
| 198 | #[cfg(test)] |
| 199 | mod tests { |
| 200 | use super::*; |
| 201 | use g1t_contracts::repos::Signature; |
| 202 | |
| 203 | #[test] |
| 204 | fn a_commit_says_why_and_where_it_is() { |
| 205 | let found = Commit { |
| 206 | hash: "abc123".into(), |
| 207 | tree_hash: "t".into(), |
| 208 | message: "The runner retries installs that break off.\n".into(), |
| 209 | author: Signature { name: "Chase Pierce".into(), email: "c@example.com".into() }, |
| 210 | parents: vec!["p1".into()], |
| 211 | authored_at: "2026-10-13T01:00:00Z".into(), |
| 212 | }; |
| 213 | let repo = RepoPath { namespace: "acme".into(), name: "web".into() }; |
| 214 | let shown = commit("https://g1t.sh/", &repo, &found, &Authors::new()); |
| 215 | assert_eq!(shown["sha"], "abc123"); |
| 216 | assert_eq!(shown["message"], "The runner retries installs that break off."); |
| 217 | assert_eq!(shown["author"], json!({ "name": "Chase Pierce" })); |
| 218 | assert!(shown.get("email").is_none() && shown["author"].get("email").is_none(), "no email addresses"); |
| 219 | assert_eq!(shown["html_url"], "https://g1t.sh/acme/web/commit/abc123"); |
| 220 | assert_eq!(limit(&json!({ "limit": "500" })), MAX_LIMIT); |
| 221 | assert_eq!(limit(&json!({})), DEFAULT_LIMIT); |
| 222 | } |
| 223 | |
| 224 | fn by(name: &str, email: &str) -> Commit { |
| 225 | Commit { |
| 226 | hash: format!("{}1", name.len()), |
| 227 | tree_hash: "t".into(), |
| 228 | message: "m".into(), |
| 229 | author: Signature { name: name.into(), email: email.into() }, |
| 230 | parents: Vec::new(), |
| 231 | authored_at: "2026-10-14T01:00:00Z".into(), |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | #[test] |
| 236 | fn an_author_is_named_by_the_account_their_address_belongs_to() { |
| 237 | let known = by("Chase Pierce", " Syntaqx@Gmail.com "); |
| 238 | let stranger = by("Someone Else", "someone@example.com"); |
| 239 | let ours = by("g1t", "g1t@users.noreply.g1t.sh"); |
| 240 | let unsigned = by("Nobody", ""); |
| 241 | let all = [&known, &stranger, &ours, &unsigned]; |
| 242 | // Asked about: each real address once, lowercased; never g1t's own. |
| 243 | assert_eq!(addresses(all), vec!["someone@example.com".to_owned(), "syntaqx@gmail.com".to_owned()]); |
| 244 | let owners = HashMap::from([( |
| 245 | "syntaqx@gmail.com".to_owned(), |
| 246 | EmailOwner { id: "usr_1".into(), username: "syntaqx".into(), avatar: None }, |
| 247 | )]); |
| 248 | let found = authors(&all, &owners); |
| 249 | let repo = RepoPath { namespace: "flagon-io".into(), name: "g1t".into() }; |
| 250 | // A known address: the git name and the username, never the address. |
| 251 | let shown = commit("https://g1t.sh", &repo, &known, &found); |
| 252 | assert_eq!(shown["author"], json!({ "name": "Chase Pierce", "username": "syntaqx" })); |
| 253 | assert!(!shown.to_string().to_lowercase().contains("gmail"), "no address"); |
| 254 | // Nobody on g1t: the name alone, and no username made up from it. |
| 255 | assert_eq!(commit("https://g1t.sh", &repo, &stranger, &found)["author"], json!({ "name": "Someone Else" })); |
| 256 | assert_eq!(commit("https://g1t.sh", &repo, &unsigned, &found)["author"], json!({ "name": "Nobody" })); |
| 257 | // g1t's own commits are @g1t's. |
| 258 | assert_eq!(commit("https://g1t.sh", &repo, &ours, &found)["author"]["username"], "g1t"); |
| 259 | } |
| 260 | } |