| 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 | use g1t_contracts::repos::{Commit, LogArgs, PathLog, PathLogArgs, 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), 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 | |
| 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: 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. |
| 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 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)] |
| 122 | mod 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 | } |