Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

231 linesCodeBlameRaw
1//! A person's year, for the contribution calendar on their profile: how
2//! many commits they pushed to a default branch (or `gh-pages`), work items
3//! (filed from a repository) and pull requests they opened (or g1t opened for them, see `requested_by`)
4//! and reviews they gave, day by day (UTC).
5//!
6//! Commits are the repos service's: it credits whoever pushed, as each
7//! push lands (`commit_days`, services/repos/src/push_commits.rs), and
8//! counts only repositories the viewer may read, by the same `readable`.
9//!
10//! As with `by_author` (authored.rs), only repositories the viewer may read
11//! are counted: the repos service decides which (`readable`) over every
12//! repository the person worked in that year, and every count here is then
13//! confined to that set. A day's number never includes work in a private
14//! repository the viewer could not open.
15
16use g1t_contracts::identity::UsernameArgs;
17use g1t_contracts::repos::{CommitDaysArgs, ReadableArgs, Repo, MAX_READABLE};
18use g1t_contracts::time::rfc3339;
19use g1t_contracts::work::*;
20use g1t_contracts::{FailureCode, Outcome, Viewer};
21use g1t_kit::now_ms;
22use serde::Deserialize;
23use worker::Result;
24
25use crate::Work;
26
27/// Every repository the person did something in since `?2`, busiest first.
28const TOUCHED_SQL: &str = concat!(
29 "SELECT repo_id, count(*) AS n FROM (
30 SELECT i.origin_id AS repo_id FROM ",
31 crate::items::sql::items_with_origin!(),
32 " i
33 WHERE COALESCE(i.requested_by_id, i.author_id) = ?1 AND i.created_at >= ?2 AND i.origin_id IS NOT NULL
34 UNION ALL SELECT repo_id FROM pulls WHERE COALESCE(requested_by_id, author_id) = ?1 AND created_at >= ?2
35 UNION ALL SELECT repo_id FROM comments WHERE author_id = ?1 AND created_at >= ?2 AND verdict IS NOT NULL
36 ) GROUP BY repo_id ORDER BY n DESC LIMIT ?3"
37);
38
39/// What they did each day since `?3`, in the repositories `?2` (a JSON
40/// array of ids) only.
41const DAYS_SQL: &str = concat!(
42 "SELECT day, count(*) AS n FROM (
43 SELECT substr(i.created_at, 1, 10) AS day FROM ",
44 crate::items::sql::items_with_origin!(),
45 " i
46 WHERE COALESCE(i.requested_by_id, i.author_id) = ?1 AND i.created_at >= ?3
47 AND i.origin_id IN (SELECT value FROM json_each(?2))
48 UNION ALL
49 SELECT substr(created_at, 1, 10) AS day FROM pulls
50 WHERE COALESCE(requested_by_id, author_id) = ?1 AND created_at >= ?3
51 AND repo_id IN (SELECT value FROM json_each(?2))
52 UNION ALL
53 SELECT substr(created_at, 1, 10) AS day FROM comments
54 WHERE author_id = ?1 AND created_at >= ?3 AND verdict IS NOT NULL
55 AND repo_id IN (SELECT value FROM json_each(?2))
56 ) GROUP BY day ORDER BY day"
57);
58
59#[derive(Deserialize)]
60struct RepoCount {
61 repo_id: String,
62}
63
64#[derive(Deserialize)]
65struct DayRow {
66 day: String,
67 n: u32,
68}
69
70/// The first day counted, `YYYY-MM-DD`, when it is `now_ms`: today and the
71/// days before it, [`CONTRIBUTION_DAYS`] in all.
72fn first_day(now_ms: u64) -> String {
73 let back = (CONTRIBUTION_DAYS - 1) * 86_400_000;
74 rfc3339(now_ms.saturating_sub(back))[..10].to_owned()
75}
76
77/// The rows and the days' commits as the calendar's days and their total.
78fn tally(rows: Vec<DayRow>, commits: Vec<ContributionDay>, from: String) -> Contributions {
79 let mut by_day = std::collections::BTreeMap::<String, (u32, u32)>::new();
80 for row in rows {
81 by_day.entry(row.day).or_default().0 += row.n;
82 }
83 for day in commits {
84 by_day.entry(day.date).or_default().1 += day.commits;
85 }
86 let days: Vec<ContributionDay> = by_day
87 .into_iter()
88 .filter(|(day, (work, commits))| work + commits > 0 && day.len() == 10 && day.as_str() >= from.as_str())
89 .map(|(date, (work, commits))| ContributionDay {
90 date,
91 count: work + commits,
92 commits,
93 })
94 .collect();
95 let total = days.iter().map(|day| day.count).sum();
96 Contributions { days, total, from }
97}
98
99impl Work {
100 pub(crate) async fn contributions(&self, a: ContributionsArgs) -> Result<Outcome<Contributions>> {
101 let person: Viewer = g1t_kit::call(
102 &self.identity,
103 "user_by_username",
104 &UsernameArgs {
105 username: a.username.trim().to_lowercase(),
106 },
107 )
108 .await?;
109 let Some(person) = person else {
110 return Ok(Outcome::fail(FailureCode::NotFound, "There is no such account."));
111 };
112 let from = first_day(now_ms());
113 // Commits come from the repos service, asked at once; a calendar
114 // without them is better than none.
115 let (commits, rows) = futures_util::future::join(
116 g1t_kit::call::<_, Vec<ContributionDay>>(
117 &self.repos,
118 "commit_days",
119 &CommitDaysArgs {
120 user_id: person.id.clone(),
121 since: from.clone(),
122 viewer: a.viewer.clone(),
123 },
124 ),
125 self.work_days(&person.id, &from, a.viewer),
126 )
127 .await;
128 let commits = commits.unwrap_or_else(|error| {
129 worker::console_error!("commit_days for {} failed: {error}", person.username);
130 Vec::new()
131 });
132 Ok(Outcome::Ok(tally(rows?, commits, from)))
133 }
134
135 /// Work items, pull requests and reviews a day, in repositories the viewer may read.
136 async fn work_days(&self, person: &str, from: &str, viewer: Viewer) -> Result<Vec<DayRow>> {
137 let touched = self
138 .db
139 .prepare(TOUCHED_SQL)
140 .bind(&[person.into(), from.into(), (MAX_READABLE as u32).into()])?
141 .all()
142 .await?
143 .results::<RepoCount>()?;
144 if touched.is_empty() {
145 return Ok(Vec::new());
146 }
147 let readable: Vec<Repo> = g1t_kit::call(
148 &self.repos,
149 "readable",
150 &ReadableArgs {
151 ids: touched.into_iter().map(|row| row.repo_id).collect(),
152 viewer,
153 },
154 )
155 .await?;
156 if readable.is_empty() {
157 return Ok(Vec::new());
158 }
159 let ids: Vec<&str> = readable.iter().map(|repo| repo.id.as_str()).collect();
160 let visible = serde_json::to_string(&ids)?;
161 self.db
162 .prepare(DAYS_SQL)
163 .bind(&[person.into(), visible.as_str().into(), from.into()])?
164 .all()
165 .await?
166 .results::<DayRow>()
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn every_count_is_confined_to_the_readable_set() {
176 // One `json_each(?2)` per kind of work: nothing is counted outside it.
177 assert_eq!(DAYS_SQL.matches("json_each(?2)").count(), 3);
178 assert_eq!(DAYS_SQL.matches("created_at >= ?3").count(), 3);
179 // Reviews are comments with a verdict; plain comments are not counted.
180 assert_eq!(DAYS_SQL.matches("verdict IS NOT NULL").count(), 1);
181 assert_eq!(TOUCHED_SQL.matches("created_at >= ?2").count(), 3);
182 }
183
184 #[test]
185 fn the_year_is_today_and_the_364_days_before() {
186 // 2026-10-08T12:00:00Z.
187 let now = 1_791_460_800_000;
188 assert_eq!(&rfc3339(now)[..10], "2026-10-08");
189 assert_eq!(first_day(now), "2025-10-09");
190 assert_eq!(first_day(0), "1970-01-01");
191 }
192
193 #[test]
194 fn tallies_days_and_drops_the_empty_and_the_early() {
195 let rows = vec![
196 DayRow { day: "2025-10-08".into(), n: 4 },
197 DayRow { day: "2025-10-09".into(), n: 2 },
198 DayRow { day: "2026-01-01".into(), n: 0 },
199 DayRow { day: "2026-10-08".into(), n: 3 },
200 ];
201 let year = tally(rows, Vec::new(), "2025-10-09".into());
202 assert_eq!(year.total, 5);
203 assert_eq!(
204 year.days,
205 vec![
206 ContributionDay { date: "2025-10-09".into(), count: 2, commits: 0 },
207 ContributionDay { date: "2026-10-08".into(), count: 3, commits: 0 },
208 ]
209 );
210 assert_eq!(tally(Vec::new(), Vec::new(), "2025-10-09".into()).total, 0);
211 }
212
213 #[test]
214 fn commits_join_the_days_they_were_pushed() {
215 let rows = vec![DayRow { day: "2026-10-01".into(), n: 2 }];
216 let commits = vec![
217 ContributionDay { date: "2026-10-01".into(), count: 3, commits: 3 },
218 ContributionDay { date: "2026-10-02".into(), count: 1, commits: 1 },
219 ContributionDay { date: "2025-01-01".into(), count: 9, commits: 9 },
220 ];
221 let year = tally(rows, commits, "2025-10-09".into());
222 assert_eq!(year.total, 6);
223 assert_eq!(
224 year.days,
225 vec![
226 ContributionDay { date: "2026-10-01".into(), count: 5, commits: 3 },
227 ContributionDay { date: "2026-10-02".into(), count: 1, commits: 1 },
228 ]
229 );
230 }
231}