| 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 | |
| 10 | use g1t_contracts::repos::{Commit, LastCommits, LastCommitsArgs, LogArgs, RepoPath}; |
| 11 | use g1t_contracts::{Outcome, Viewer}; |
| 12 | use serde_json::{Value, json}; |
| 13 | use worker::Result; |
| 14 | |
| 15 | use crate::operations::Services; |
| 16 | |
| 17 | pub const DEFAULT_LIMIT: u32 = 30; |
| 18 | pub const MAX_LIMIT: u32 = 100; |
| 19 | /// The most of one commit's message an answer carries. |
| 20 | const MAX_MESSAGE_CHARS: usize = 4_000; |
| 21 | /// How long finding a path's last change may take, in milliseconds. |
| 22 | const PATH_BUDGET_MS: u64 = 8_000; |
| 23 | |
| 24 | pub 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 | |
| 28 | pub 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. |
| 42 | pub 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 | |
| 60 | fn 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 | |
| 69 | pub 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)] |
| 112 | mod 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 | } |