Skip to content
136 linesCodeBlameRaw
1//! `list_commits`: a branch's history, newest first, with each commit's
2//! message, author, time and page; with `path`, also the commit that last
3//! changed that file or directory. What an agent (or Ask g1t) reads to say
4//! 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 the
7//! history, `last_commits` of the path's directory for its last change.
8//! The shaping is [`commit`], a plain function.
9
10use g1t_contracts::repos::{Commit, LastCommits, LastCommitsArgs, LogArgs, RepoPath};
11use g1t_contracts::{Outcome, Viewer};
12use serde_json::{Value, json};
13use worker::Result;
14
15use crate::operations::Services;
16
17pub const DEFAULT_LIMIT: u32 = 30;
18pub const MAX_LIMIT: u32 = 100;
19/// The most of one commit's message an answer carries.
20const MAX_MESSAGE_CHARS: usize = 4_000;
21/// How long finding a path's last change may take, in milliseconds.
22const PATH_BUDGET_MS: u64 = 8_000;
23
24pub fn description() -> &'static str {
25 "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`, `authored_at` and `html_url`. `limit` is 30 unless you say, at most 100. With `path` (a file or directory), also `last_changed`: the commit that last changed it."
26}
27
28pub fn input() -> Value {
29 json!({
30 "type": "object",
31 "properties": {
32 "repo": { "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." },
33 "ref": { "type": "string", "description": "A branch, tag or commit; the default branch when left out." },
34 "path": { "type": "string", "description": "A file or directory, such as scripts/deploy/npm-ci.mjs: adds the commit that last changed it." },
35 "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT, "description": "How many commits: 30 unless you say, at most 100." },
36 },
37 "required": ["repo"],
38 })
39}
40
41/// One commit as an answer shows it.
42pub fn commit(site: &str, repo: &RepoPath, commit: &Commit) -> Value {
43 let site = site.trim_end_matches('/');
44 let total = commit.message.chars().count();
45 let message = if total > MAX_MESSAGE_CHARS {
46 format!("{}… [{} more characters]", commit.message.chars().take(MAX_MESSAGE_CHARS).collect::<String>(), total - MAX_MESSAGE_CHARS)
47 } else {
48 commit.message.trim_end().to_owned()
49 };
50 json!({
51 "sha": commit.hash,
52 "message": message,
53 "author": { "name": commit.author.name },
54 "authored_at": commit.authored_at,
55 "parents": commit.parents,
56 "html_url": format!("{site}/{}/{}/commit/{}", repo.namespace, repo.name, commit.hash),
57 })
58}
59
60fn limit(input: &Value) -> u32 {
61 let asked = match input.get("limit") {
62 Some(Value::String(text)) => text.trim().parse::<u64>().ok(),
63 Some(value) => value.as_u64(),
64 None => None,
65 };
66 asked.map_or(DEFAULT_LIMIT, |n| n.clamp(1, u64::from(MAX_LIMIT)) as u32)
67}
68
69pub async fn run(services: &Services, viewer: &Viewer, repo: &RepoPath, input: &Value) -> Result<Outcome<Value>> {
70 let site = services.addresses.site.clone();
71 let git_ref = input["ref"].as_str().map(str::trim).filter(|git_ref| !git_ref.is_empty()).map(str::to_owned);
72 let path = input["path"].as_str().unwrap_or_default().trim().trim_matches('/').to_owned();
73 let log: Outcome<Vec<Commit>> = g1t_kit::call(
74 &services.repos,
75 "log",
76 &LogArgs { path: repo.clone(), viewer: viewer.clone(), git_ref: git_ref.clone(), limit: limit(input) },
77 )
78 .await?;
79 let commits = match log {
80 Outcome::Ok(commits) => commits,
81 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
82 };
83 let mut answer = json!({
84 "repo": format!("{}/{}", repo.namespace, repo.name),
85 "commits": commits.iter().map(|found| commit(&site, repo, found)).collect::<Vec<_>>(),
86 });
87 if !path.is_empty() {
88 let (directory, name) = match path.rsplit_once('/') {
89 Some((directory, name)) => (directory.to_owned(), name.to_owned()),
90 None => (String::new(), path.clone()),
91 };
92 let last: Outcome<LastCommits> = g1t_kit::call(
93 &services.repos,
94 "last_commits",
95 &LastCommitsArgs { path: repo.clone(), viewer: viewer.clone(), git_ref, tree_path: directory, budget_ms: Some(PATH_BUDGET_MS) },
96 )
97 .await?;
98 answer["path"] = json!(path);
99 answer["last_changed"] = match last {
100 Outcome::Ok(last) => match last.entries.iter().find(|entry| entry.name == name) {
101 Some(entry) => commit(&site, repo, &entry.commit),
102 None if last.complete => json!(format!("{path} is not in this repository at this ref.")),
103 None => json!("Not found in time: its history is long. Look through the commits for it."),
104 },
105 Outcome::Fail(failure) => json!(failure.message),
106 };
107 }
108 Ok(Outcome::Ok(answer))
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use g1t_contracts::repos::Signature;
115
116 #[test]
117 fn a_commit_says_why_and_where_it_is() {
118 let found = Commit {
119 hash: "abc123".into(),
120 tree_hash: "t".into(),
121 message: "The runner retries installs that break off.\n".into(),
122 author: Signature { name: "Chase Pierce".into(), email: "c@example.com".into() },
123 parents: vec!["p1".into()],
124 authored_at: "2026-10-13T01:00:00Z".into(),
125 };
126 let repo = RepoPath { namespace: "acme".into(), name: "web".into() };
127 let shown = commit("https://g1t.sh/", &repo, &found);
128 assert_eq!(shown["sha"], "abc123");
129 assert_eq!(shown["message"], "The runner retries installs that break off.");
130 assert_eq!(shown["author"], json!({ "name": "Chase Pierce" }));
131 assert!(shown.get("email").is_none() && shown["author"].get("email").is_none(), "no email addresses");
132 assert_eq!(shown["html_url"], "https://g1t.sh/acme/web/commit/abc123");
133 assert_eq!(limit(&json!({ "limit": "500" })), MAX_LIMIT);
134 assert_eq!(limit(&json!({})), DEFAULT_LIMIT);
135 }
136}