Skip to content
146 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
10use g1t_contracts::repos::{Commit, LogArgs, PathLog, PathLogArgs, 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), 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."
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: only the commits that 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 full = format!("{}/{}", repo.namespace, repo.name);
74 if path.is_empty() {
75 let log: Outcome<Vec<Commit>> = g1t_kit::call(
76 &services.repos,
77 "log",
78 &LogArgs { path: repo.clone(), viewer: viewer.clone(), git_ref, limit: limit(input) },
79 )
80 .await?;
81 return Ok(match log {
82 Outcome::Ok(commits) => Outcome::Ok(json!({
83 "repo": full,
84 "commits": commits.iter().map(|found| commit(&site, repo, found)).collect::<Vec<_>>(),
85 })),
86 Outcome::Fail(failure) => Outcome::Fail(failure),
87 });
88 }
89 // A path's own history: the commits that changed it, newest first.
90 let history: Outcome<PathLog> = g1t_kit::call(
91 &services.repos,
92 "path_log",
93 &PathLogArgs {
94 path: repo.clone(),
95 viewer: viewer.clone(),
96 git_ref,
97 file_path: path.clone(),
98 limit: limit(input),
99 budget_ms: Some(PATH_BUDGET_MS),
100 },
101 )
102 .await?;
103 let history = match history {
104 Outcome::Ok(history) => history,
105 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
106 };
107 let last_changed = match history.commits.first() {
108 Some(first) => commit(&site, repo, first),
109 None if history.complete => json!(format!("{path} is not in this repository at this ref.")),
110 None => json!("Not found in time: its history is long. Ask with a smaller limit, or read the branch's commits."),
111 };
112 Ok(Outcome::Ok(json!({
113 "repo": full,
114 "path": path,
115 "commits": history.commits.iter().map(|found| commit(&site, repo, found)).collect::<Vec<_>>(),
116 "last_changed": last_changed,
117 "complete": history.complete,
118 })))
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use g1t_contracts::repos::Signature;
125
126 #[test]
127 fn a_commit_says_why_and_where_it_is() {
128 let found = Commit {
129 hash: "abc123".into(),
130 tree_hash: "t".into(),
131 message: "The runner retries installs that break off.\n".into(),
132 author: Signature { name: "Chase Pierce".into(), email: "c@example.com".into() },
133 parents: vec!["p1".into()],
134 authored_at: "2026-10-13T01:00:00Z".into(),
135 };
136 let repo = RepoPath { namespace: "acme".into(), name: "web".into() };
137 let shown = commit("https://g1t.sh/", &repo, &found);
138 assert_eq!(shown["sha"], "abc123");
139 assert_eq!(shown["message"], "The runner retries installs that break off.");
140 assert_eq!(shown["author"], json!({ "name": "Chase Pierce" }));
141 assert!(shown.get("email").is_none() && shown["author"].get("email").is_none(), "no email addresses");
142 assert_eq!(shown["html_url"], "https://g1t.sh/acme/web/commit/abc123");
143 assert_eq!(limit(&json!({ "limit": "500" })), MAX_LIMIT);
144 assert_eq!(limit(&json!({})), DEFAULT_LIMIT);
145 }
146}