Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 1 | //! Commits people push, for the contribution calendar on their profile. |
| 2 | //! | |
| 3 | //! When a push lands on a repository's default branch (or `gh-pages`, which | |
| 4 | //! publishes a site), the person who pushed is credited, for that UTC day, | |
| 5 | //! with the push and the new commits it brought: the commits along the | |
| 6 | //! branch's first-parent line from where it is now back to where it was, | |
| 7 | //! at most [`MOST_PER_PUSH`]. A merge commit counts once, not the commits | |
| 8 | //! it merged. A branch's first push counts one commit, so importing a long | |
| 9 | //! history does not fill one day. Other branches count when they are | |
| 10 | //! merged, which is a pull request. | |
| 11 | //! | |
| 12 | //! Only people are credited: pushes with an agent's, a workspace's or a | |
| 13 | //! workflow job's token are not counted. The rows are in `push_commits` | |
| 14 | //! (migrations/0017); `commit_days` reads them for work's `contributions`, | |
| 15 | //! only for repositories the viewer may read (`Registry::readable`). | |
| 16 | ||
| 17 | use g1t_contracts::Viewer; | |
| 18 | use g1t_contracts::repos::Commit; | |
| 19 | use g1t_contracts::work::ContributionDay; | |
| 20 | use serde::Deserialize; | |
| 21 | use worker::{D1Database, Result}; | |
| 22 | ||
| 23 | use crate::registry::Registry; | |
| 24 | ||
| 25 | /// The most commits one push is credited with. | |
| 26 | pub const MOST_PER_PUSH: u32 = 50; | |
| 27 | ||
| 28 | /// Whether a push to `branch` counts on the calendar. | |
| 29 | pub fn counts(branch: &str, default_branch: &str) -> bool { | |
| 30 | branch == default_branch || branch == "gh-pages" | |
| 31 | } | |
| 32 | ||
| 33 | /// How many new commits a push brought, from the branch's log after it | |
| 34 | /// (newest first, first parents, at most `MOST_PER_PUSH + 1`) and where the | |
| 35 | /// branch was before. A new branch is one; a log that ends without reaching | |
| 36 | /// `before` (history rewritten onto an unrelated line) is one; one that | |
| 37 | /// runs past the cap is the cap. | |
| 38 | pub fn new_commits(log: &[Commit], before: Option<&str>) -> u32 { | |
| 39 | let Some(before) = before.filter(|before| !before.is_empty() && !before.bytes().all(|b| b == b'0')) else { | |
| 40 | return u32::from(!log.is_empty()); | |
| 41 | }; | |
| 42 | match log.iter().position(|commit| commit.hash == before) { | |
| 43 | Some(at) => (at as u32).min(MOST_PER_PUSH), | |
| 44 | None if log.len() as u32 > MOST_PER_PUSH => MOST_PER_PUSH, | |
| 45 | None => u32::from(!log.is_empty()), | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | /// Credits `user_id` with one push of `commits` commits to `repo_id` on `day`. | |
| 50 | pub async fn record(db: &D1Database, user_id: &str, repo_id: &str, day: &str, commits: u32) -> Result<()> { | |
| 51 | db.prepare( | |
| 52 | "INSERT INTO push_commits (user_id, repo_id, day, pushes, commits) VALUES (?1, ?2, ?3, 1, ?4) | |
| 53 | ON CONFLICT (user_id, day, repo_id) DO UPDATE SET | |
| 54 | pushes = push_commits.pushes + 1, commits = push_commits.commits + excluded.commits", | |
| 55 | ) | |
| 56 | .bind(&[user_id.into(), repo_id.into(), day.into(), commits.into()])? | |
| 57 | .run() | |
| 58 | .await?; | |
| 59 | Ok(()) | |
| 60 | } | |
| 61 | ||
| 62 | #[derive(Deserialize)] | |
| 63 | struct RepoRow { | |
| 64 | repo_id: String, | |
| 65 | } | |
| 66 | ||
| 67 | #[derive(Deserialize)] | |
| 68 | struct DayRow { | |
| 69 | day: String, | |
| 70 | commits: u32, | |
| 71 | } | |
| 72 | ||
| 73 | /// What `commit_days` answers: a person's commits each day since `since`, | |
| 74 | /// in the repositories the viewer may read. | |
| 75 | pub async fn days(registry: &Registry, user_id: &str, since: &str, viewer: &Viewer) -> Result<Vec<ContributionDay>> { | |
| 76 | let pushed = registry | |
| 77 | .db | |
| 78 | .prepare( | |
| 79 | "SELECT repo_id FROM push_commits WHERE user_id = ?1 AND day >= ?2 | |
| 80 | GROUP BY repo_id ORDER BY SUM(commits) DESC LIMIT ?3", | |
| 81 | ) | |
| 82 | .bind(&[user_id.into(), since.into(), (g1t_contracts::repos::MAX_READABLE as u32).into()])? | |
| 83 | .all() | |
| 84 | .await? | |
| 85 | .results::<RepoRow>()?; | |
| 86 | if pushed.is_empty() { | |
| 87 | return Ok(Vec::new()); | |
| 88 | } | |
| 89 | let ids: Vec<String> = pushed.into_iter().map(|row| row.repo_id).collect(); | |
| 90 | let readable = registry.readable(&ids, viewer).await?; | |
| 91 | if readable.is_empty() { | |
| 92 | return Ok(Vec::new()); | |
| 93 | } | |
| 94 | let visible: Vec<&str> = readable.iter().map(|repo| repo.id.as_str()).collect(); | |
| 95 | let rows = registry | |
| 96 | .db | |
| 97 | .prepare(DAYS_SQL) | |
| 98 | .bind(&[user_id.into(), since.into(), serde_json::to_string(&visible)?.into()])? | |
| 99 | .all() | |
| 100 | .await? | |
| 101 | .results::<DayRow>()?; | |
| 102 | Ok(rows | |
| 103 | .into_iter() | |
| 104 | .filter(|row| row.commits > 0) | |
| 105 | .map(|row| ContributionDay { date: row.day, count: row.commits, commits: row.commits }) | |
| 106 | .collect()) | |
| 107 | } | |
| 108 | ||
| 109 | /// Commits a day, in the repositories `?3` (a JSON array of ids) only. | |
| 110 | const DAYS_SQL: &str = "SELECT day, SUM(commits) AS commits FROM push_commits | |
| 111 | WHERE user_id = ?1 AND day >= ?2 AND repo_id IN (SELECT value FROM json_each(?3)) | |
| 112 | GROUP BY day ORDER BY day"; | |
| 113 | ||
| 114 | #[cfg(test)] | |
| 115 | mod tests { | |
| 116 | use super::*; | |
| 117 | use g1t_contracts::repos::Signature; | |
| 118 | ||
| 119 | fn log(hashes: &[&str]) -> Vec<Commit> { | |
| 120 | hashes | |
| 121 | .iter() | |
| 122 | .map(|hash| Commit { | |
| 123 | hash: (*hash).to_owned(), | |
| 124 | tree_hash: String::new(), | |
| 125 | message: String::new(), | |
| 126 | author: Signature { name: String::new(), email: String::new() }, | |
| 127 | parents: Vec::new(), | |
| 128 | authored_at: String::new(), | |
| 129 | }) | |
| 130 | .collect() | |
| 131 | } | |
| 132 | ||
| 133 | #[test] | |
| 134 | fn the_default_branch_and_gh_pages_count() { | |
| 135 | assert!(counts("main", "main")); | |
| 136 | assert!(counts("gh-pages", "main")); | |
| 137 | assert!(!counts("feature", "main")); | |
| 138 | } | |
| 139 | ||
| 140 | #[test] | |
| 141 | fn a_push_brings_the_commits_after_where_the_branch_was() { | |
| 142 | assert_eq!(new_commits(&log(&["c", "b", "a"]), Some("a")), 2); | |
| 143 | assert_eq!(new_commits(&log(&["c", "b", "a"]), Some("c")), 0); | |
| 144 | } | |
| 145 | ||
| 146 | #[test] | |
| 147 | fn a_new_branch_or_a_rewrite_is_one() { | |
| 148 | assert_eq!(new_commits(&log(&["c", "b", "a"]), None), 1); | |
| 149 | assert_eq!(new_commits(&log(&["c", "b", "a"]), Some(&"0".repeat(40))), 1); | |
| 150 | assert_eq!(new_commits(&log(&["z", "y"]), Some("a")), 1); | |
| 151 | assert_eq!(new_commits(&[], Some("a")), 0); | |
| 152 | } | |
| 153 | ||
| 154 | #[test] | |
| 155 | fn a_push_is_credited_with_the_cap_at_most() { | |
| 156 | let hashes: Vec<String> = (0..=MOST_PER_PUSH).map(|n| format!("h{n}")).collect(); | |
| 157 | let long = log(&hashes.iter().map(String::as_str).collect::<Vec<_>>()); | |
| 158 | assert_eq!(new_commits(&long, Some("old")), MOST_PER_PUSH); | |
| 159 | assert_eq!(new_commits(&long, Some(&format!("h{MOST_PER_PUSH}"))), MOST_PER_PUSH); | |
| 160 | } | |
| 161 | ||
| 162 | #[test] | |
| 163 | fn days_are_confined_to_the_readable_set() { | |
| 164 | assert_eq!(DAYS_SQL.matches("json_each(?3)").count(), 1); | |
| 165 | assert!(DAYS_SQL.contains("user_id = ?1 AND day >= ?2")); | |
| 166 | } | |
| 167 | } |