Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
260 linesCodeBlameRaw
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
15use std::collections::{BTreeSet, HashMap};
16
17use g1t_contracts::accounts::{EmailOwner, EmailOwnersArgs};
18use g1t_contracts::repos::{Commit, LogArgs, PathLog, PathLogArgs, RepoPath};
19use g1t_contracts::{Outcome, Viewer};
20use serde_json::{Value, json};
21use worker::Result;
22
23use crate::operations::Services;
24
25pub const DEFAULT_LIMIT: u32 = 30;
26pub const MAX_LIMIT: u32 = 100;
27/// The most of one commit's message an answer carries.
28const MAX_MESSAGE_CHARS: usize = 4_000;
29/// How long finding a path's last change may take, in milliseconds.
30const 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).
34const 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.
36const MAX_ADDRESSES: usize = 200;
37
38/// Author address, lowercased, to the g1t username it belongs to.
39pub 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.
43pub 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.
57pub 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.
71pub 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.
77pub 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
89pub 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
93pub 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.
107pub 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.
127fn 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
134fn 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
143pub 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)]
199mod 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}