Skip to content
173 linesCodeBlameRaw
1//! A person's year, for the contribution calendar on their profile: how
2//! many issues and pull requests they opened (or g1t opened for them, see
3//! `requested_by`) and how many reviews they gave, day by day (UTC).
4//!
5//! As with `by_author` (authored.rs), only repositories the viewer may read
6//! are counted: the repos service decides which (`readable`) over every
7//! repository the person worked in that year, and every count here is then
8//! confined to that set. A day's number never includes work in a private
9//! repository the viewer could not open.
10
11use g1t_contracts::identity::UsernameArgs;
12use g1t_contracts::repos::{ReadableArgs, Repo, MAX_READABLE};
13use g1t_contracts::time::rfc3339;
14use g1t_contracts::work::*;
15use g1t_contracts::{FailureCode, Outcome, Viewer};
16use g1t_kit::now_ms;
17use serde::Deserialize;
18use worker::Result;
19
20use crate::Work;
21
22/// Every repository the person did something in since `?2`, busiest first.
23const TOUCHED_SQL: &str = "SELECT repo_id, count(*) AS n FROM (
24 SELECT repo_id FROM issues WHERE COALESCE(requested_by_id, author_id) = ?1 AND created_at >= ?2
25 UNION ALL SELECT repo_id FROM pulls WHERE COALESCE(requested_by_id, author_id) = ?1 AND created_at >= ?2
26 UNION ALL SELECT repo_id FROM comments WHERE author_id = ?1 AND created_at >= ?2 AND verdict IS NOT NULL
27 ) GROUP BY repo_id ORDER BY n DESC LIMIT ?3";
28
29/// What they did each day since `?3`, in the repositories `?2` (a JSON
30/// array of ids) only.
31const DAYS_SQL: &str = "SELECT day, count(*) AS n FROM (
32 SELECT substr(created_at, 1, 10) AS day FROM issues
33 WHERE COALESCE(requested_by_id, author_id) = ?1 AND created_at >= ?3
34 AND repo_id IN (SELECT value FROM json_each(?2))
35 UNION ALL
36 SELECT substr(created_at, 1, 10) AS day FROM pulls
37 WHERE COALESCE(requested_by_id, author_id) = ?1 AND created_at >= ?3
38 AND repo_id IN (SELECT value FROM json_each(?2))
39 UNION ALL
40 SELECT substr(created_at, 1, 10) AS day FROM comments
41 WHERE author_id = ?1 AND created_at >= ?3 AND verdict IS NOT NULL
42 AND repo_id IN (SELECT value FROM json_each(?2))
43 ) GROUP BY day ORDER BY day";
44
45#[derive(Deserialize)]
46struct RepoCount {
47 repo_id: String,
48}
49
50#[derive(Deserialize)]
51struct DayRow {
52 day: String,
53 n: u32,
54}
55
56/// The first day counted, `YYYY-MM-DD`, when it is `now_ms`: today and the
57/// days before it, [`CONTRIBUTION_DAYS`] in all.
58fn first_day(now_ms: u64) -> String {
59 let back = (CONTRIBUTION_DAYS - 1) * 86_400_000;
60 rfc3339(now_ms.saturating_sub(back))[..10].to_owned()
61}
62
63/// The rows as the calendar's days and their total.
64fn tally(rows: Vec<DayRow>, from: String) -> Contributions {
65 let days: Vec<ContributionDay> = rows
66 .into_iter()
67 .filter(|row| row.n > 0 && row.day.len() == 10 && row.day.as_str() >= from.as_str())
68 .map(|row| ContributionDay {
69 date: row.day,
70 count: row.n,
71 })
72 .collect();
73 let total = days.iter().map(|day| day.count).sum();
74 Contributions { days, total, from }
75}
76
77impl Work {
78 pub(crate) async fn contributions(&self, a: ContributionsArgs) -> Result<Outcome<Contributions>> {
79 let person: Viewer = g1t_kit::call(
80 &self.identity,
81 "user_by_username",
82 &UsernameArgs {
83 username: a.username.trim().to_lowercase(),
84 },
85 )
86 .await?;
87 let Some(person) = person else {
88 return Ok(Outcome::fail(FailureCode::NotFound, "There is no such account."));
89 };
90 let from = first_day(now_ms());
91
92 let touched = self
93 .db
94 .prepare(TOUCHED_SQL)
95 .bind(&[
96 person.id.as_str().into(),
97 from.as_str().into(),
98 (MAX_READABLE as u32).into(),
99 ])?
100 .all()
101 .await?
102 .results::<RepoCount>()?;
103 if touched.is_empty() {
104 return Ok(Outcome::Ok(tally(Vec::new(), from)));
105 }
106 let readable: Vec<Repo> = g1t_kit::call(
107 &self.repos,
108 "readable",
109 &ReadableArgs {
110 ids: touched.into_iter().map(|row| row.repo_id).collect(),
111 viewer: a.viewer,
112 },
113 )
114 .await?;
115 if readable.is_empty() {
116 return Ok(Outcome::Ok(tally(Vec::new(), from)));
117 }
118 let ids: Vec<&str> = readable.iter().map(|repo| repo.id.as_str()).collect();
119 let visible = serde_json::to_string(&ids)?;
120 let rows = self
121 .db
122 .prepare(DAYS_SQL)
123 .bind(&[person.id.as_str().into(), visible.as_str().into(), from.as_str().into()])?
124 .all()
125 .await?
126 .results::<DayRow>()?;
127 Ok(Outcome::Ok(tally(rows, from)))
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn every_count_is_confined_to_the_readable_set() {
137 // One `json_each(?2)` per kind of work: nothing is counted outside it.
138 assert_eq!(DAYS_SQL.matches("json_each(?2)").count(), 3);
139 assert_eq!(DAYS_SQL.matches("created_at >= ?3").count(), 3);
140 // Reviews are comments with a verdict; plain comments are not counted.
141 assert_eq!(DAYS_SQL.matches("verdict IS NOT NULL").count(), 1);
142 assert_eq!(TOUCHED_SQL.matches("created_at >= ?2").count(), 3);
143 }
144
145 #[test]
146 fn the_year_is_today_and_the_364_days_before() {
147 // 2026-10-08T12:00:00Z.
148 let now = 1_791_460_800_000;
149 assert_eq!(&rfc3339(now)[..10], "2026-10-08");
150 assert_eq!(first_day(now), "2025-10-09");
151 assert_eq!(first_day(0), "1970-01-01");
152 }
153
154 #[test]
155 fn tallies_days_and_drops_the_empty_and_the_early() {
156 let rows = vec![
157 DayRow { day: "2025-10-08".into(), n: 4 },
158 DayRow { day: "2025-10-09".into(), n: 2 },
159 DayRow { day: "2026-01-01".into(), n: 0 },
160 DayRow { day: "2026-10-08".into(), n: 3 },
161 ];
162 let year = tally(rows, "2025-10-09".into());
163 assert_eq!(year.total, 5);
164 assert_eq!(
165 year.days,
166 vec![
167 ContributionDay { date: "2025-10-09".into(), count: 2 },
168 ContributionDay { date: "2026-10-08".into(), count: 3 },
169 ]
170 );
171 assert_eq!(tally(Vec::new(), "2025-10-09".into()).total, 0);
172 }
173}